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()
}
}
fun liveLocalUnreadThreadList(): Flow<List<TimelineEvent>> {
return room.getNumberOfLocalThreadNotificationsLive().asFlow()
.startWith(room.coroutineDispatchers.io) {
room.getNumberOfLocalThreadNotifications()
}
}
}
fun Room.flow(): FlowRoom {

View File

@ -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)
}

View File

@ -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
)

View File

@ -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)
}
}

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
* 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,
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

View File

@ -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()
)
}

View File

@ -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() {

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.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
}
}
}

View File

@ -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)
}
}

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
timelineInput.onNewTimelineEvents(roomId = roomId, eventIds = eventIds)

View File

@ -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)

View File

@ -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,

View File

@ -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()

View File

@ -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)
}
}

View File

@ -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())

View File

@ -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"