Implement LOCAL thread notifications that work only on real time.
This commit is contained in:
parent
d1bb96cec0
commit
c40a686cff
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue