From c40a686cff5e01489b23f9b59fc32ddfe5bfb2f5 Mon Sep 17 00:00:00 2001
From: ariskotsomitopoulos <aris.kotsomitopoulos@gmail.com>
Date: Fri, 3 Dec 2021 18:15:25 +0000
Subject: [PATCH] Implement LOCAL thread notifications that work only on real
 time.

---
 .../org/matrix/android/sdk/flow/FlowRoom.kt   |  7 +++++
 .../session/room/timeline/TimelineService.kt  | 17 +++++++++++
 .../sdk/api/session/threads/ThreadDetails.kt  |  3 +-
 .../database/RealmSessionStoreMigration.kt    |  1 +
 .../database/helper/ThreadEventsHelper.kt     | 27 +++++++++++++++--
 .../internal/database/mapper/EventMapper.kt   |  2 ++
 .../internal/database/model/EventEntity.kt    |  1 +
 .../room/timeline/DefaultTimelineService.kt   | 25 ++++++++++++++++
 .../room/timeline/TokenChunkEventPersistor.kt |  4 ++-
 .../sync/handler/room/RoomSyncHandler.kt      |  2 +-
 .../home/room/detail/RoomDetailViewModel.kt   | 26 +++++++++++++++++
 .../home/room/detail/RoomDetailViewState.kt   |  5 ++--
 .../home/room/detail/TimelineFragment.kt      |  4 +--
 .../threads/list/model/ThreadListModel.kt     |  5 ++++
 .../list/viewmodel/ThreadListController.kt    |  1 +
 .../src/main/res/layout/item_thread_list.xml  | 29 ++++++++++++++-----
 16 files changed, 141 insertions(+), 18 deletions(-)

diff --git a/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt b/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt
index 7091905991..cdb3bdf9c2 100644
--- a/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt
+++ b/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt
@@ -106,6 +106,13 @@ class FlowRoom(private val room: Room) {
                     room.getAllThreads()
                 }
     }
+
+    fun liveLocalUnreadThreadList(): Flow<List<TimelineEvent>> {
+        return room.getNumberOfLocalThreadNotificationsLive().asFlow()
+                .startWith(room.coroutineDispatchers.io) {
+                    room.getNumberOfLocalThreadNotifications()
+                }
+    }
 }
 
 fun Room.flow(): FlowRoom {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt
index 6b1ad5554b..068fa87a66 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt
@@ -68,11 +68,28 @@ interface TimelineService {
      */
     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
      * @param rootThreadEventId the eventId of the current thread
      */
     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)
 
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadDetails.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadDetails.kt
index 04dbb18797..62568cdce1 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadDetails.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadDetails.kt
@@ -22,5 +22,6 @@ data class ThreadDetails(
         val isRootThread: Boolean = false,
         val numberOfThreads: Int = 0,
         val threadSummarySenderInfo: SenderInfo? = null,
-        val threadSummaryLatestTextMessage: String? = null
+        val threadSummaryLatestTextMessage: String? = null,
+        val hasUnreadMessage: Boolean = false
 )
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt
index 111fc50e56..301a479d01 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt
@@ -375,6 +375,7 @@ internal object RealmSessionStoreMigration : RealmMigration {
                 ?.addField(EventEntityFields.IS_ROOT_THREAD, Boolean::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.HAS_UNREAD_THREAD_MESSAGES, Boolean::class.java)
                 ?.addRealmObjectField(EventEntityFields.THREAD_SUMMARY_LATEST_MESSAGE.`$`, eventEntity)
     }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt
index aa3ba0fc25..34bc117ddf 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt
@@ -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
  * 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
 
@@ -47,6 +47,8 @@ internal fun Map<String, EventEntity>.updateThreadSummaryIfNeeded() {
             val rootThreadEvent = if (eventEntity.isThread()) eventEntity.findRootThreadEvent() else eventEntity
 
             rootThreadEvent?.markEventAsRoot(
+                    isInitialSync = isInitialSync,
+                    currentUserId = currentUserId,
                     threadsCounted = it.size,
                     latestMessageTimelineEventEntity = latestMessage
             )
@@ -68,11 +70,20 @@ internal fun EventEntity.findRootThreadEvent(): EventEntity? =
 /**
  * Mark or update the current event a root thread event
  */
-internal fun EventEntity.markEventAsRoot(threadsCounted: Int,
-                                         latestMessageTimelineEventEntity: TimelineEventEntity?) {
+internal fun EventEntity.markEventAsRoot(
+        isInitialSync: Boolean,
+        currentUserId: String?,
+        threadsCounted: Int,
+        latestMessageTimelineEventEntity: TimelineEventEntity?) {
     isRootThread = true
     numberOfThreads = threadsCounted
     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)
                 .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
  * @param roomId the room that the thread exists
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt
index cf16138196..319d91b12a 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt
@@ -55,6 +55,7 @@ internal object EventMapper {
         eventEntity.decryptionErrorReason = event.mCryptoErrorReason
         eventEntity.decryptionErrorCode = event.mCryptoError?.name
         eventEntity.isRootThread = event.threadDetails?.isRootThread ?: false
+        eventEntity.hasUnreadThreadMessages = event.threadDetails?.hasUnreadMessage ?: false
         eventEntity.rootThreadEventId = event.getRootThreadEventId()
         eventEntity.numberOfThreads = event.threadDetails?.numberOfThreads ?: 0
         return eventEntity
@@ -111,6 +112,7 @@ internal object EventMapper {
                                 avatarUrl = timelineEventEntity.senderAvatar
                         )
                     },
+                    hasUnreadMessage = eventEntity.hasUnreadThreadMessages,
                     threadSummaryLatestTextMessage = eventEntity.threadSummaryLatestMessage?.root?.asDomain()?.getDecryptedTextSummary().orEmpty()
             )
         }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt
index 1898d63af8..1ba4d564bb 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt
@@ -46,6 +46,7 @@ internal open class EventEntity(@Index var eventId: String = "",
                                 @Index var isRootThread: Boolean = false,
                                 @Index var rootThreadEventId: String? = null,
                                 var numberOfThreads: Int = 0,
+                                var hasUnreadThreadMessages: Boolean = false,
                                 var threadSummaryLatestMessage: TimelineEventEntity? = null
 
 ) : RealmObject() {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt
index 2335f7bcd2..3f702abde8 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt
@@ -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.util.Optional
 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.isUserParticipatingInThread
 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.TimelineEventEntityFields
 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.sync.handler.room.ReadReceiptHandler
 import org.matrix.android.sdk.internal.task.TaskExecutor
+import org.matrix.android.sdk.internal.util.awaitTransaction
 
 internal class DefaultTimelineService @AssistedInject constructor(
         @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>> {
         return monarchy.findAllMappedWithChanges(
                 { TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) },
@@ -129,4 +146,12 @@ internal class DefaultTimelineService @AssistedInject constructor(
                     senderId = senderId)
         }
     }
+
+    override suspend fun markThreadAsRead(rootThreadEventId: String) {
+        monarchy.awaitTransaction {
+            EventEntity.where(
+                    realm = it,
+                    eventId = rootThreadEventId).findFirst()?.hasUnreadThreadMessages = false
+        }
+    }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt
index f6441c9d60..2fa298a171 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt
@@ -267,7 +267,9 @@ internal class TokenChunkEventPersistor @Inject constructor(@SessionDatabase pri
             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)
 
     }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt
index 8d64c7fc96..8c258e7d91 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt
@@ -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
         timelineInput.onNewTimelineEvents(roomId = roomId, eventIds = eventIds)
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt
index 907ca360bb..cbe5e542fb 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt
@@ -189,8 +189,12 @@ class RoomDetailViewModel @AssistedInject constructor(
         if (OutboundSessionKeySharingStrategy.WhenEnteringRoom == BuildConfig.outboundSessionKeySharingStrategy && room.isEncrypted()) {
             prepareForEncryption()
         }
+        markThreadTimelineAsReadLocal()
+        observeLocalThreadNotifications()
     }
 
+
+
     private fun observeDataStore() {
         viewModelScope.launch {
             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 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>) {
 
         timelineEvents.tryEmit(snapshot)
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt
index fa772ca073..df6c75d30c 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt
@@ -67,8 +67,9 @@ data class RoomDetailViewState(
         val isAllowedToStartWebRTCCall: Boolean = true,
         val hasFailedSending: Boolean = false,
         val jitsiState: JitsiState = JitsiState(),
-        val rootThreadEventId: String? = null
-        ) : MavericksState {
+        val rootThreadEventId: String? = null,
+        val numberOfLocalUnreadThreads: Int = 0
+) : MavericksState {
 
     constructor(args: TimelineArgs) : this(
             roomId = args.roomId,
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
index b37ba12f37..f12ca9e84c 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
@@ -1031,9 +1031,9 @@ class TimelineFragment @Inject constructor(
         val badgeFrameLayout = menuThreadList.findViewById<FrameLayout>(R.id.threadNotificationBadgeFrameLayout)
         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) {
             badgeFrameLayout.isVisible = true
             badgeTextView.text = unreadThreadMessages.toString()
diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/model/ThreadListModel.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/model/ThreadListModel.kt
index f47f6f46cc..f3aac46ed3 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/threads/list/model/ThreadListModel.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/model/ThreadListModel.kt
@@ -19,6 +19,7 @@ package im.vector.app.features.home.room.threads.list.model
 import android.widget.ImageView
 import android.widget.TextView
 import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.view.isVisible
 import com.airbnb.epoxy.EpoxyAttribute
 import com.airbnb.epoxy.EpoxyModelClass
 import im.vector.app.R
@@ -42,6 +43,7 @@ abstract class ThreadListModel : VectorEpoxyModel<ThreadListModel.Holder>() {
     @EpoxyAttribute lateinit var date: String
     @EpoxyAttribute lateinit var rootMessage: String
     @EpoxyAttribute lateinit var lastMessage: String
+    @EpoxyAttribute  var unreadMessage: Boolean = false
     @EpoxyAttribute lateinit var lastMessageCounter: String
     @EpoxyAttribute var rootMessageDeleted: Boolean = false
     @EpoxyAttribute var lastMessageMatrixItem: MatrixItem? = null
@@ -69,6 +71,7 @@ abstract class ThreadListModel : VectorEpoxyModel<ThreadListModel.Holder>() {
         holder.lastMessageAvatarImageView.contentDescription = lastMessageMatrixItem?.getBestName()
         holder.lastMessageTextView.text = lastMessage
         holder.lastMessageCounterTextView.text = lastMessageCounter
+        holder.unreadImageView.isVisible = unreadMessage
     }
 
     class Holder : VectorEpoxyHolder() {
@@ -79,6 +82,8 @@ abstract class ThreadListModel : VectorEpoxyModel<ThreadListModel.Holder>() {
         val lastMessageAvatarImageView by bind<ImageView>(R.id.messageThreadSummaryAvatarImageView)
         val lastMessageCounterTextView by bind<TextView>(R.id.messageThreadSummaryCounterTextView)
         val lastMessageTextView by bind<TextView>(R.id.messageThreadSummaryInfoTextView)
+        val unreadImageView by bind<ImageView>(R.id.threadSummaryUnreadImageView)
+
         val rootView by bind<ConstraintLayout>(R.id.threadSummaryRootConstraintLayout)
     }
 }
diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt
index d17dee6e51..6e07f0a95f 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt
@@ -53,6 +53,7 @@ class ThreadListController @Inject constructor(
                         title(timelineEvent.senderInfo.displayName)
                         date(date)
                         rootMessageDeleted(timelineEvent.root.isRedacted())
+                        unreadMessage(timelineEvent.root.threadDetails?.hasUnreadMessage ?: false)
                         rootMessage(timelineEvent.root.getDecryptedTextSummary())
                         lastMessage(timelineEvent.root.threadDetails?.threadSummaryLatestTextMessage.orEmpty())
                         lastMessageCounter(timelineEvent.root.threadDetails?.numberOfThreads.toString())
diff --git a/vector/src/main/res/layout/item_thread_list.xml b/vector/src/main/res/layout/item_thread_list.xml
index 8cf93c5404..6a1d075b7c 100644
--- a/vector/src/main/res/layout/item_thread_list.xml
+++ b/vector/src/main/res/layout/item_thread_list.xml
@@ -1,18 +1,17 @@
 <?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout
-    xmlns:android="http://schemas.android.com/apk/res/android"
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto"
     xmlns:tools="http://schemas.android.com/tools"
     android:id="@+id/threadSummaryRootConstraintLayout"
     android:layout_width="match_parent"
     android:layout_height="wrap_content"
-    android:paddingStart="12dp"
-    android:paddingTop="12dp"
-    android:paddingEnd="0dp"
     android:background="?android:colorBackground"
     android:clickable="true"
     android:focusable="true"
-    android:foreground="?attr/selectableItemBackground">
+    android:foreground="?attr/selectableItemBackground"
+    android:paddingStart="12dp"
+    android:paddingTop="12dp"
+    android:paddingEnd="0dp">
 
     <ImageView
         android:id="@+id/threadSummaryAvatarImageView"
@@ -32,8 +31,8 @@
         android:layout_marginEnd="8dp"
         android:ellipsize="end"
         android:maxLines="1"
-        android:textStyle="bold"
         android:textColor="@color/element_name_04"
+        android:textStyle="bold"
         app:layout_constraintEnd_toStartOf="@id/threadSummaryDateTextView"
         app:layout_constraintStart_toEndOf="@id/threadSummaryAvatarImageView"
         app:layout_constraintTop_toTopOf="parent"
@@ -47,14 +46,28 @@
         android:layout_height="wrap_content"
         android:layout_marginStart="8dp"
         android:layout_marginEnd="25dp"
-        android:maxLines="1"
         android:gravity="end"
+        android:maxLines="1"
         android:textColor="?vctr_content_secondary"
         app:layout_constraintBottom_toBottomOf="@id/threadSummaryTitleTextView"
         app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintTop_toTopOf="@id/threadSummaryTitleTextView"
         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
         android:id="@+id/threadSummaryRootMessageTextView"
         style="@style/Widget.Vector.TextView.Body"