Implement LOCAL thread notifications that work only on real time.

This commit is contained in:
ariskotsomitopoulos 2021-12-03 18:15:25 +00:00
parent d1bb96cec0
commit c40a686cff
16 changed files with 141 additions and 18 deletions

View file

@ -106,6 +106,13 @@ class FlowRoom(private val room: Room) {
room.getAllThreads() room.getAllThreads()
} }
} }
fun liveLocalUnreadThreadList(): Flow<List<TimelineEvent>> {
return room.getNumberOfLocalThreadNotificationsLive().asFlow()
.startWith(room.coroutineDispatchers.io) {
room.getNumberOfLocalThreadNotifications()
}
}
} }
fun Room.flow(): FlowRoom { fun Room.flow(): FlowRoom {

View file

@ -68,11 +68,28 @@ interface TimelineService {
*/ */
fun getAllThreads(): List<TimelineEvent> fun getAllThreads(): List<TimelineEvent>
/**
* Get a live list of all the local unread threads for the specified roomId
* @return the [LiveData] of [TimelineEvent]
*/
fun getNumberOfLocalThreadNotificationsLive(): LiveData<List<TimelineEvent>>
/**
* Get a list of all the local unread threads for the specified roomId
* @return the [LiveData] of [TimelineEvent]
*/
fun getNumberOfLocalThreadNotifications(): List<TimelineEvent>
/** /**
* Returns whether or not the current user is participating in the thread * Returns whether or not the current user is participating in the thread
* @param rootThreadEventId the eventId of the current thread * @param rootThreadEventId the eventId of the current thread
*/ */
fun isUserParticipatingInThread(rootThreadEventId: String, senderId: String): Boolean fun isUserParticipatingInThread(rootThreadEventId: String, senderId: String): Boolean
/**
* Marks the current thread as read. This is a local implementation
* @param rootThreadEventId the eventId of the current thread
*/
suspend fun markThreadAsRead(rootThreadEventId: String)
} }

View file

@ -22,5 +22,6 @@ data class ThreadDetails(
val isRootThread: Boolean = false, val isRootThread: Boolean = false,
val numberOfThreads: Int = 0, val numberOfThreads: Int = 0,
val threadSummarySenderInfo: SenderInfo? = null, val threadSummarySenderInfo: SenderInfo? = null,
val threadSummaryLatestTextMessage: String? = null val threadSummaryLatestTextMessage: String? = null,
val hasUnreadMessage: Boolean = false
) )

View file

@ -375,6 +375,7 @@ internal object RealmSessionStoreMigration : RealmMigration {
?.addField(EventEntityFields.IS_ROOT_THREAD, Boolean::class.java, FieldAttribute.INDEXED) ?.addField(EventEntityFields.IS_ROOT_THREAD, Boolean::class.java, FieldAttribute.INDEXED)
?.addField(EventEntityFields.ROOT_THREAD_EVENT_ID, String::class.java, FieldAttribute.INDEXED) ?.addField(EventEntityFields.ROOT_THREAD_EVENT_ID, String::class.java, FieldAttribute.INDEXED)
?.addField(EventEntityFields.NUMBER_OF_THREADS, Int::class.java) ?.addField(EventEntityFields.NUMBER_OF_THREADS, Int::class.java)
?.addField(EventEntityFields.HAS_UNREAD_THREAD_MESSAGES, Boolean::class.java)
?.addRealmObjectField(EventEntityFields.THREAD_SUMMARY_LATEST_MESSAGE.`$`, eventEntity) ?.addRealmObjectField(EventEntityFields.THREAD_SUMMARY_LATEST_MESSAGE.`$`, eventEntity)
} }
} }

View file

@ -31,7 +31,7 @@ import org.matrix.android.sdk.internal.database.query.whereRoomId
* Finds the root thread event and update it with the latest message summary along with the number * Finds the root thread event and update it with the latest message summary along with the number
* of threads included. If there is no root thread event no action is done * of threads included. If there is no root thread event no action is done
*/ */
internal fun Map<String, EventEntity>.updateThreadSummaryIfNeeded() { internal fun Map<String, EventEntity>.updateThreadSummaryIfNeeded(isInitialSync: Boolean = false, currentUserId: String? = null) {
if (!BuildConfig.THREADING_ENABLED) return if (!BuildConfig.THREADING_ENABLED) return
@ -47,6 +47,8 @@ internal fun Map<String, EventEntity>.updateThreadSummaryIfNeeded() {
val rootThreadEvent = if (eventEntity.isThread()) eventEntity.findRootThreadEvent() else eventEntity val rootThreadEvent = if (eventEntity.isThread()) eventEntity.findRootThreadEvent() else eventEntity
rootThreadEvent?.markEventAsRoot( rootThreadEvent?.markEventAsRoot(
isInitialSync = isInitialSync,
currentUserId = currentUserId,
threadsCounted = it.size, threadsCounted = it.size,
latestMessageTimelineEventEntity = latestMessage latestMessageTimelineEventEntity = latestMessage
) )
@ -68,11 +70,20 @@ internal fun EventEntity.findRootThreadEvent(): EventEntity? =
/** /**
* Mark or update the current event a root thread event * Mark or update the current event a root thread event
*/ */
internal fun EventEntity.markEventAsRoot(threadsCounted: Int, internal fun EventEntity.markEventAsRoot(
latestMessageTimelineEventEntity: TimelineEventEntity?) { isInitialSync: Boolean,
currentUserId: String?,
threadsCounted: Int,
latestMessageTimelineEventEntity: TimelineEventEntity?) {
isRootThread = true isRootThread = true
numberOfThreads = threadsCounted numberOfThreads = threadsCounted
threadSummaryLatestMessage = latestMessageTimelineEventEntity threadSummaryLatestMessage = latestMessageTimelineEventEntity
// skip notification coming from messages from the same user, also retain already marked events
hasUnreadThreadMessages = if (hasUnreadThreadMessages) {
latestMessageTimelineEventEntity?.root?.sender != currentUserId
} else {
if (latestMessageTimelineEventEntity?.root?.sender == currentUserId) false else !isInitialSync
}
} }
/** /**
@ -96,6 +107,16 @@ internal fun TimelineEventEntity.Companion.findAllThreadsForRoomId(realm: Realm,
.equalTo(TimelineEventEntityFields.ROOT.IS_ROOT_THREAD, true) .equalTo(TimelineEventEntityFields.ROOT.IS_ROOT_THREAD, true)
.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) .sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING)
/**
* Find the number of all the local notifications for the specified room
* @param roomId The room that the number of notifications will be returned
*/
internal fun TimelineEventEntity.Companion.findAllLocalThreadNotificationsForRoomId(realm: Realm, roomId: String): RealmQuery<TimelineEventEntity> =
TimelineEventEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(TimelineEventEntityFields.ROOT.IS_ROOT_THREAD, true)
.equalTo(TimelineEventEntityFields.ROOT.HAS_UNREAD_THREAD_MESSAGES, true)
/** /**
* Returns whether or not the given user is participating in a current thread * Returns whether or not the given user is participating in a current thread
* @param roomId the room that the thread exists * @param roomId the room that the thread exists

View file

@ -55,6 +55,7 @@ internal object EventMapper {
eventEntity.decryptionErrorReason = event.mCryptoErrorReason eventEntity.decryptionErrorReason = event.mCryptoErrorReason
eventEntity.decryptionErrorCode = event.mCryptoError?.name eventEntity.decryptionErrorCode = event.mCryptoError?.name
eventEntity.isRootThread = event.threadDetails?.isRootThread ?: false eventEntity.isRootThread = event.threadDetails?.isRootThread ?: false
eventEntity.hasUnreadThreadMessages = event.threadDetails?.hasUnreadMessage ?: false
eventEntity.rootThreadEventId = event.getRootThreadEventId() eventEntity.rootThreadEventId = event.getRootThreadEventId()
eventEntity.numberOfThreads = event.threadDetails?.numberOfThreads ?: 0 eventEntity.numberOfThreads = event.threadDetails?.numberOfThreads ?: 0
return eventEntity return eventEntity
@ -111,6 +112,7 @@ internal object EventMapper {
avatarUrl = timelineEventEntity.senderAvatar avatarUrl = timelineEventEntity.senderAvatar
) )
}, },
hasUnreadMessage = eventEntity.hasUnreadThreadMessages,
threadSummaryLatestTextMessage = eventEntity.threadSummaryLatestMessage?.root?.asDomain()?.getDecryptedTextSummary().orEmpty() threadSummaryLatestTextMessage = eventEntity.threadSummaryLatestMessage?.root?.asDomain()?.getDecryptedTextSummary().orEmpty()
) )
} }

View file

@ -46,6 +46,7 @@ internal open class EventEntity(@Index var eventId: String = "",
@Index var isRootThread: Boolean = false, @Index var isRootThread: Boolean = false,
@Index var rootThreadEventId: String? = null, @Index var rootThreadEventId: String? = null,
var numberOfThreads: Int = 0, var numberOfThreads: Int = 0,
var hasUnreadThreadMessages: Boolean = false,
var threadSummaryLatestMessage: TimelineEventEntity? = null var threadSummaryLatestMessage: TimelineEventEntity? = null
) : RealmObject() { ) : RealmObject() {

View file

@ -32,9 +32,11 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineService
import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings
import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.internal.database.RealmSessionProvider import org.matrix.android.sdk.internal.database.RealmSessionProvider
import org.matrix.android.sdk.internal.database.helper.findAllLocalThreadNotificationsForRoomId
import org.matrix.android.sdk.internal.database.helper.findAllThreadsForRoomId import org.matrix.android.sdk.internal.database.helper.findAllThreadsForRoomId
import org.matrix.android.sdk.internal.database.helper.isUserParticipatingInThread import org.matrix.android.sdk.internal.database.helper.isUserParticipatingInThread
import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper
import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields
import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.database.query.where
@ -42,6 +44,7 @@ import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask
import org.matrix.android.sdk.internal.session.sync.handler.room.ReadReceiptHandler import org.matrix.android.sdk.internal.session.sync.handler.room.ReadReceiptHandler
import org.matrix.android.sdk.internal.task.TaskExecutor import org.matrix.android.sdk.internal.task.TaskExecutor
import org.matrix.android.sdk.internal.util.awaitTransaction
internal class DefaultTimelineService @AssistedInject constructor( internal class DefaultTimelineService @AssistedInject constructor(
@Assisted private val roomId: String, @Assisted private val roomId: String,
@ -106,6 +109,20 @@ internal class DefaultTimelineService @AssistedInject constructor(
} }
} }
override fun getNumberOfLocalThreadNotificationsLive(): LiveData<List<TimelineEvent>> {
return monarchy.findAllMappedWithChanges(
{ TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) },
{ timelineEventMapper.map(it) }
)
}
override fun getNumberOfLocalThreadNotifications(): List<TimelineEvent> {
return monarchy.fetchAllMappedSync(
{ TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) },
{ timelineEventMapper.map(it) }
)
}
override fun getAllThreadsLive(): LiveData<List<TimelineEvent>> { override fun getAllThreadsLive(): LiveData<List<TimelineEvent>> {
return monarchy.findAllMappedWithChanges( return monarchy.findAllMappedWithChanges(
{ TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) }, { TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) },
@ -129,4 +146,12 @@ internal class DefaultTimelineService @AssistedInject constructor(
senderId = senderId) senderId = senderId)
} }
} }
override suspend fun markThreadAsRead(rootThreadEventId: String) {
monarchy.awaitTransaction {
EventEntity.where(
realm = it,
eventId = rootThreadEventId).findFirst()?.hasUnreadThreadMessages = false
}
}
} }

View file

@ -267,7 +267,9 @@ internal class TokenChunkEventPersistor @Inject constructor(@SessionDatabase pri
RoomEntity.where(realm, roomId).findFirst()?.addIfNecessary(currentChunk) RoomEntity.where(realm, roomId).findFirst()?.addIfNecessary(currentChunk)
} }
optimizedThreadSummaryMap.updateThreadSummaryIfNeeded() // passing isInitialSync = true because we want to disable local notifications
// they do not work properly without the API
optimizedThreadSummaryMap.updateThreadSummaryIfNeeded(true)
} }
} }

View file

@ -425,7 +425,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
} }
} }
optimizedThreadSummaryMap.updateThreadSummaryIfNeeded() optimizedThreadSummaryMap.updateThreadSummaryIfNeeded(insertType == EventInsertType.INITIAL_SYNC, userId)
// posting new events to timeline if any is registered // posting new events to timeline if any is registered
timelineInput.onNewTimelineEvents(roomId = roomId, eventIds = eventIds) timelineInput.onNewTimelineEvents(roomId = roomId, eventIds = eventIds)

View file

@ -189,8 +189,12 @@ class RoomDetailViewModel @AssistedInject constructor(
if (OutboundSessionKeySharingStrategy.WhenEnteringRoom == BuildConfig.outboundSessionKeySharingStrategy && room.isEncrypted()) { if (OutboundSessionKeySharingStrategy.WhenEnteringRoom == BuildConfig.outboundSessionKeySharingStrategy && room.isEncrypted()) {
prepareForEncryption() prepareForEncryption()
} }
markThreadTimelineAsReadLocal()
observeLocalThreadNotifications()
} }
private fun observeDataStore() { private fun observeDataStore() {
viewModelScope.launch { viewModelScope.launch {
vectorDataStore.pushCounterFlow.collect { nbOfPush -> vectorDataStore.pushCounterFlow.collect { nbOfPush ->
@ -280,6 +284,17 @@ class RoomDetailViewModel @AssistedInject constructor(
} }
} }
/**
* Observe local unread threads
*/
private fun observeLocalThreadNotifications(){
room.flow()
.liveLocalUnreadThreadList()
.execute {
copy(numberOfLocalUnreadThreads = it.invoke()?.size ?: 0)
}
}
fun getOtherUserIds() = room.roomSummary()?.otherMemberIds fun getOtherUserIds() = room.roomSummary()?.otherMemberIds
fun getRoomSummary() = room.roomSummary() fun getRoomSummary() = room.roomSummary()
@ -1112,6 +1127,17 @@ class RoomDetailViewModel @AssistedInject constructor(
} }
} }
/**
* Mark the thread as read, while the user navigated within the thread
* This is a local implementation has nothing to do with APIs
*/
private fun markThreadTimelineAsReadLocal(){
initialState.rootThreadEventId?.let{
session.coroutineScope.launch {
room.markThreadAsRead(it)
}
}
}
override fun onTimelineUpdated(snapshot: List<TimelineEvent>) { override fun onTimelineUpdated(snapshot: List<TimelineEvent>) {
timelineEvents.tryEmit(snapshot) timelineEvents.tryEmit(snapshot)

View file

@ -67,8 +67,9 @@ data class RoomDetailViewState(
val isAllowedToStartWebRTCCall: Boolean = true, val isAllowedToStartWebRTCCall: Boolean = true,
val hasFailedSending: Boolean = false, val hasFailedSending: Boolean = false,
val jitsiState: JitsiState = JitsiState(), val jitsiState: JitsiState = JitsiState(),
val rootThreadEventId: String? = null val rootThreadEventId: String? = null,
) : MavericksState { val numberOfLocalUnreadThreads: Int = 0
) : MavericksState {
constructor(args: TimelineArgs) : this( constructor(args: TimelineArgs) : this(
roomId = args.roomId, roomId = args.roomId,

View file

@ -1031,9 +1031,9 @@ class TimelineFragment @Inject constructor(
val badgeFrameLayout = menuThreadList.findViewById<FrameLayout>(R.id.threadNotificationBadgeFrameLayout) val badgeFrameLayout = menuThreadList.findViewById<FrameLayout>(R.id.threadNotificationBadgeFrameLayout)
val badgeTextView = menuThreadList.findViewById<TextView>(R.id.threadNotificationBadgeTextView) val badgeTextView = menuThreadList.findViewById<TextView>(R.id.threadNotificationBadgeTextView)
val unreadThreadMessages = 18 + state.pushCounter val unreadThreadMessages = state.numberOfLocalUnreadThreads
val userIsMentioned = false
val userIsMentioned = true
if (unreadThreadMessages > 0) { if (unreadThreadMessages > 0) {
badgeFrameLayout.isVisible = true badgeFrameLayout.isVisible = true
badgeTextView.text = unreadThreadMessages.toString() badgeTextView.text = unreadThreadMessages.toString()

View file

@ -19,6 +19,7 @@ package im.vector.app.features.home.room.threads.list.model
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R import im.vector.app.R
@ -42,6 +43,7 @@ abstract class ThreadListModel : VectorEpoxyModel<ThreadListModel.Holder>() {
@EpoxyAttribute lateinit var date: String @EpoxyAttribute lateinit var date: String
@EpoxyAttribute lateinit var rootMessage: String @EpoxyAttribute lateinit var rootMessage: String
@EpoxyAttribute lateinit var lastMessage: String @EpoxyAttribute lateinit var lastMessage: String
@EpoxyAttribute var unreadMessage: Boolean = false
@EpoxyAttribute lateinit var lastMessageCounter: String @EpoxyAttribute lateinit var lastMessageCounter: String
@EpoxyAttribute var rootMessageDeleted: Boolean = false @EpoxyAttribute var rootMessageDeleted: Boolean = false
@EpoxyAttribute var lastMessageMatrixItem: MatrixItem? = null @EpoxyAttribute var lastMessageMatrixItem: MatrixItem? = null
@ -69,6 +71,7 @@ abstract class ThreadListModel : VectorEpoxyModel<ThreadListModel.Holder>() {
holder.lastMessageAvatarImageView.contentDescription = lastMessageMatrixItem?.getBestName() holder.lastMessageAvatarImageView.contentDescription = lastMessageMatrixItem?.getBestName()
holder.lastMessageTextView.text = lastMessage holder.lastMessageTextView.text = lastMessage
holder.lastMessageCounterTextView.text = lastMessageCounter holder.lastMessageCounterTextView.text = lastMessageCounter
holder.unreadImageView.isVisible = unreadMessage
} }
class Holder : VectorEpoxyHolder() { class Holder : VectorEpoxyHolder() {
@ -79,6 +82,8 @@ abstract class ThreadListModel : VectorEpoxyModel<ThreadListModel.Holder>() {
val lastMessageAvatarImageView by bind<ImageView>(R.id.messageThreadSummaryAvatarImageView) val lastMessageAvatarImageView by bind<ImageView>(R.id.messageThreadSummaryAvatarImageView)
val lastMessageCounterTextView by bind<TextView>(R.id.messageThreadSummaryCounterTextView) val lastMessageCounterTextView by bind<TextView>(R.id.messageThreadSummaryCounterTextView)
val lastMessageTextView by bind<TextView>(R.id.messageThreadSummaryInfoTextView) val lastMessageTextView by bind<TextView>(R.id.messageThreadSummaryInfoTextView)
val unreadImageView by bind<ImageView>(R.id.threadSummaryUnreadImageView)
val rootView by bind<ConstraintLayout>(R.id.threadSummaryRootConstraintLayout) val rootView by bind<ConstraintLayout>(R.id.threadSummaryRootConstraintLayout)
} }
} }

View file

@ -53,6 +53,7 @@ class ThreadListController @Inject constructor(
title(timelineEvent.senderInfo.displayName) title(timelineEvent.senderInfo.displayName)
date(date) date(date)
rootMessageDeleted(timelineEvent.root.isRedacted()) rootMessageDeleted(timelineEvent.root.isRedacted())
unreadMessage(timelineEvent.root.threadDetails?.hasUnreadMessage ?: false)
rootMessage(timelineEvent.root.getDecryptedTextSummary()) rootMessage(timelineEvent.root.getDecryptedTextSummary())
lastMessage(timelineEvent.root.threadDetails?.threadSummaryLatestTextMessage.orEmpty()) lastMessage(timelineEvent.root.threadDetails?.threadSummaryLatestTextMessage.orEmpty())
lastMessageCounter(timelineEvent.root.threadDetails?.numberOfThreads.toString()) lastMessageCounter(timelineEvent.root.threadDetails?.numberOfThreads.toString())

View file

@ -1,18 +1,17 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/threadSummaryRootConstraintLayout" android:id="@+id/threadSummaryRootConstraintLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingStart="12dp"
android:paddingTop="12dp"
android:paddingEnd="0dp"
android:background="?android:colorBackground" android:background="?android:colorBackground"
android:clickable="true" android:clickable="true"
android:focusable="true" android:focusable="true"
android:foreground="?attr/selectableItemBackground"> android:foreground="?attr/selectableItemBackground"
android:paddingStart="12dp"
android:paddingTop="12dp"
android:paddingEnd="0dp">
<ImageView <ImageView
android:id="@+id/threadSummaryAvatarImageView" android:id="@+id/threadSummaryAvatarImageView"
@ -32,8 +31,8 @@
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:ellipsize="end" android:ellipsize="end"
android:maxLines="1" android:maxLines="1"
android:textStyle="bold"
android:textColor="@color/element_name_04" android:textColor="@color/element_name_04"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@id/threadSummaryDateTextView" app:layout_constraintEnd_toStartOf="@id/threadSummaryDateTextView"
app:layout_constraintStart_toEndOf="@id/threadSummaryAvatarImageView" app:layout_constraintStart_toEndOf="@id/threadSummaryAvatarImageView"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
@ -47,14 +46,28 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:layout_marginEnd="25dp" android:layout_marginEnd="25dp"
android:maxLines="1"
android:gravity="end" android:gravity="end"
android:maxLines="1"
android:textColor="?vctr_content_secondary" android:textColor="?vctr_content_secondary"
app:layout_constraintBottom_toBottomOf="@id/threadSummaryTitleTextView" app:layout_constraintBottom_toBottomOf="@id/threadSummaryTitleTextView"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/threadSummaryTitleTextView" app:layout_constraintTop_toTopOf="@id/threadSummaryTitleTextView"
tools:text="10 minutes" /> tools:text="10 minutes" />
<ImageView
android:id="@+id/threadSummaryUnreadImageView"
android:layout_width="8dp"
android:layout_height="8dp"
android:src="@drawable/notification_badge"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/threadSummaryDateTextView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/threadSummaryDateTextView"
app:layout_constraintTop_toTopOf="@id/threadSummaryDateTextView"
app:tint="@color/palette_gray_200"
tools:ignore="ContentDescription"
tools:visibility="visible" />
<TextView <TextView
android:id="@+id/threadSummaryRootMessageTextView" android:id="@+id/threadSummaryRootMessageTextView"
style="@style/Widget.Vector.TextView.Body" style="@style/Widget.Vector.TextView.Body"