Split notifications for messages in threads (#5595)

* Separate thread notifications into their own notifications when the feature flag is enabled.

Otherwise, set the `threadId` to null so it'll behave as usual. It's done this way to avoid having to inject `FeatureFlagService` in several places.

* Add permalink navigation to threads from notifications, focusing on the latest event in the list of messages of the notification tapped

* Fix redactions in threads

* Clear notifications for a thread when visiting it

* Fix opening a thread happening twice, first because of the `openThreadId` value, then because of the `focusedEventId` one

* Make opening a room through a notification also focus on the latest event

* Add helper `NotificationCreator.messageTag` function

* Remove unused `ROOM_CALL_NOTIFICATION_ID`: `FOREGROUND_SERVICE_NOTIFICATION_ID`+ `ForegroundServiceType` is used instead

* Simplify `DefaultDeepLinkCreator`

* Make sure the main timeline focuses on the thread root id too when navigating to a thread

* Handle "Mark as read" action for thread notification, using `timeline.markAsRead`

* Log failures to mark rooms as read using the notification action

---------

Co-authored-by: Benoit Marty <benoit@matrix.org>
This commit is contained in:
Jorge Martin Espinosa 2025-10-30 16:15:00 +01:00 committed by GitHub
parent e8b7db22cd
commit 7facc40771
55 changed files with 702 additions and 284 deletions

View file

@ -33,6 +33,7 @@ import io.element.android.features.location.api.LocationService
import io.element.android.features.location.api.SendLocationEntryPoint
import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.api.MessagesEntryPointNode
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
import io.element.android.features.messages.impl.pinned.PinnedEventsTimelineProvider
@ -87,10 +88,12 @@ import io.element.android.libraries.textcomposer.mentions.MentionSpanUpdater
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import kotlin.time.Duration.Companion.milliseconds
@ContributesNode(RoomScope::class)
@AssistedInject
@ -126,8 +129,9 @@ class MessagesFlowNode(
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
plugins = plugins,
),
MessagesEntryPointNode {
sealed interface NavTarget : Parcelable {
@Parcelize
data class Messages(val focusedEventId: EventId?) : NavTarget
@ -175,7 +179,7 @@ class MessagesFlowNode(
data object KnockRequestsList : NavTarget
@Parcelize
data class OpenThread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget
data class Thread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget
}
private val callbacks = plugins<MessagesEntryPoint.Callback>()
@ -287,7 +291,7 @@ class MessagesFlowNode(
}
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {
backstack.push(NavTarget.OpenThread(threadRootId, focusedEventId))
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
}
}
val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId)
@ -420,7 +424,7 @@ class MessagesFlowNode(
NavTarget.KnockRequestsList -> {
knockRequestsListEntryPoint.createNode(this, buildContext)
}
is NavTarget.OpenThread -> {
is NavTarget.Thread -> {
val inputs = ThreadedMessagesNode.Inputs(
threadRootEventId = navTarget.threadRootId,
focusedEventId = navTarget.focusedEventId,
@ -485,7 +489,7 @@ class MessagesFlowNode(
}
override fun onOpenThread(threadRootId: ThreadId, focusedEventId: EventId?) {
backstack.push(NavTarget.OpenThread(threadRootId, focusedEventId))
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
}
}
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
@ -603,6 +607,16 @@ class MessagesFlowNode(
)
}
override suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) {
// Wait until we have the UI for the main timeline attached
waitForChildAttached<MessagesNode>()
// Give some time for the items in the main timeline to be received, otherwise loading the focused thread root id won't work
// (look at TimelineItemIndexer and firstProcessLatch for more info)
delay(10.milliseconds)
// Then push the new threads screen on top
backstack.push(NavTarget.Thread(threadId, focusedEventId))
}
@Composable
override fun View(modifier: Modifier) {
mentionSpanTheme.updateStyles()

View file

@ -337,12 +337,11 @@ class MessagesNode(
var focusedEventId by rememberSaveable {
mutableStateOf(inputs.focusedEventId)
}
LaunchedEffect(Unit) {
focusedEventId?.also { eventId ->
state.timelineState.eventSink(TimelineEvents.FocusOnEvent(eventId))
LaunchedEffect(focusedEventId) {
if (focusedEventId != null) {
state.timelineState.eventSink(TimelineEvents.FocusOnEvent(focusedEventId!!))
focusedEventId = null
}
// Reset the focused event id to null to avoid refocusing when restoring node.
focusedEventId = null
}
}
}

View file

@ -64,6 +64,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
@ -85,6 +86,7 @@ class ThreadedMessagesNode(
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer,
private val permalinkParser: PermalinkParser,
private val appNavigationStateService: AppNavigationStateService,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val callbacks = plugins<Callback>()
@ -131,6 +133,12 @@ class ThreadedMessagesNode(
onCreate = {
sessionCoroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) }
},
onStart = {
appNavigationStateService.onNavigateToThread(id, inputs.threadRootEventId)
},
onStop = {
appNavigationStateService.onLeavingThread(id)
},
onDestroy = {
mediaPlayer.close()
}

View file

@ -86,6 +86,7 @@ class TimelinePresenter(
private val roomCallStatePresenter: Presenter<RoomCallState>,
private val featureFlagService: FeatureFlagService,
) : Presenter<TimelineState> {
private val tag = "TimelinePresenter"
@AssistedFactory
interface Factory {
fun create(
@ -102,14 +103,14 @@ class TimelinePresenter(
)
private var timelineItems by mutableStateOf<ImmutableList<TimelineItem>>(persistentListOf())
private val focusRequestState: MutableState<FocusRequestState> = mutableStateOf(FocusRequestState.None)
@Composable
override fun present(): TimelineState {
val localScope = rememberCoroutineScope()
val timelineMode = remember { timelineController.mainTimelineMode() }
var focusRequestState: FocusRequestState by remember { mutableStateOf(FocusRequestState.None) }
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
val roomInfo by room.roomInfoFlow.collectAsState()
@ -155,7 +156,7 @@ class TimelinePresenter(
if (event.firstIndex == 0) {
newEventState.value = NewEventState.None
}
Timber.d("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}")
Timber.tag(tag).d("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}")
sessionCoroutineScope.sendReadReceiptIfNeeded(
firstVisibleIndex = event.firstIndex,
timelineItems = timelineItems,
@ -186,14 +187,17 @@ class TimelinePresenter(
is TimelineEvents.EditPoll -> {
navigator.onEditPollClick(event.pollStartId)
}
is TimelineEvents.FocusOnEvent -> {
focusRequestState = FocusRequestState.Requested(event.eventId, event.debounce)
}
is TimelineEvents.FocusOnEvent -> sessionCoroutineScope.launch {
focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce)
delay(event.debounce)
Timber.tag(tag).d("Started focus on ${event.eventId}")
focusOnEvent(event.eventId, focusRequestState)
}.start()
is TimelineEvents.OnFocusEventRender -> {
focusRequestState = focusRequestState.onFocusEventRender()
focusRequestState.value = focusRequestState.value.onFocusEventRender()
}
is TimelineEvents.ClearFocusRequestState -> {
focusRequestState = FocusRequestState.None
focusRequestState.value = FocusRequestState.None
}
is TimelineEvents.JumpToLive -> {
timelineController.focusOnLive()
@ -236,69 +240,19 @@ class TimelinePresenter(
.launchIn(this)
}
LaunchedEffect(focusRequestState) {
Timber.d("## focusRequestState: $focusRequestState")
when (val currentFocusRequestState = focusRequestState) {
is FocusRequestState.Requested -> {
delay(currentFocusRequestState.debounce)
if (timelineItemIndexer.isKnown(currentFocusRequestState.eventId)) {
val index = timelineItemIndexer.indexOf(currentFocusRequestState.eventId)
focusRequestState = FocusRequestState.Success(eventId = currentFocusRequestState.eventId, index = index)
} else {
focusRequestState = FocusRequestState.Loading(eventId = currentFocusRequestState.eventId)
}
}
is FocusRequestState.Loading -> {
val eventId = currentFocusRequestState.eventId
val threadId = room.threadRootIdForEvent(eventId).getOrElse {
focusRequestState = FocusRequestState.Failure(it)
return@LaunchedEffect
}
if (timelineController.mainTimelineMode() is Timeline.Mode.Thread && threadId == null) {
// We are in a thread timeline, and the event isn't part of a thread, we need to navigate back to the room
focusRequestState = FocusRequestState.None
navigator.onNavigateToRoom(room.roomId, eventId, calculateServerNamesForRoom(room))
} else {
timelineController.focusOnEvent(eventId, threadId)
.onSuccess { result ->
when (result) {
is EventFocusResult.FocusedOnLive -> {
focusRequestState = FocusRequestState.Success(eventId = eventId)
}
is EventFocusResult.IsInThread -> {
val currentThreadId = (timelineController.mainTimelineMode() as? Timeline.Mode.Thread)?.threadRootId
if (currentThreadId == result.threadId) {
// It's the same thread, we just focus on the event
focusRequestState = FocusRequestState.Success(eventId = eventId)
} else {
focusRequestState = FocusRequestState.Success(eventId = result.threadId.asEventId())
// It's part of a thread we're not in, let's open it in another timeline
navigator.onOpenThread(result.threadId, eventId)
}
}
}
}
.onFailure {
focusRequestState = FocusRequestState.Failure(it)
}
}
}
else -> Unit
}
}
LaunchedEffect(timelineItems.size) {
computeNewItemState(timelineItems, prevMostRecentItemId, newEventState)
}
LaunchedEffect(timelineItems.size, focusRequestState) {
val currentFocusRequestState = focusRequestState
LaunchedEffect(timelineItems.size, focusRequestState.value) {
val currentFocusRequestState = focusRequestState.value
if (currentFocusRequestState is FocusRequestState.Success && !currentFocusRequestState.rendered) {
val eventId = currentFocusRequestState.eventId
if (timelineItemIndexer.isKnown(eventId)) {
val index = timelineItemIndexer.indexOf(eventId)
focusRequestState = FocusRequestState.Success(eventId = eventId, index = index)
focusRequestState.value = FocusRequestState.Success(eventId = eventId, index = index)
} else {
Timber.w("Unknown timeline item for focused item, can't render focus")
}
}
}
@ -319,6 +273,11 @@ class TimelinePresenter(
)
}
}
LaunchedEffect(focusRequestState.value) {
Timber.tag(tag).d("Timeline: $timelineMode | focus state: ${focusRequestState.value}")
}
return TimelineState(
timelineItems = timelineItems,
timelineMode = timelineMode,
@ -326,7 +285,7 @@ class TimelinePresenter(
renderReadReceipts = renderReadReceipts,
newEventState = newEventState.value,
isLive = isLive,
focusRequestState = focusRequestState,
focusRequestState = focusRequestState.value,
messageShield = messageShield.value,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
displayThreadSummaries = displayThreadSummaries,
@ -334,6 +293,55 @@ class TimelinePresenter(
)
}
private suspend fun focusOnEvent(
eventId: EventId,
focusRequestState: MutableState<FocusRequestState>,
) {
if (timelineItemIndexer.isKnown(eventId)) {
val index = timelineItemIndexer.indexOf(eventId)
focusRequestState.value = FocusRequestState.Success(eventId = eventId, index = index)
return
}
Timber.tag(tag).d("Event $eventId not found in the loaded timeline, loading a focused timeline")
focusRequestState.value = FocusRequestState.Loading(eventId = eventId)
val threadId = room.threadRootIdForEvent(eventId).getOrElse {
focusRequestState.value = FocusRequestState.Failure(it)
return
}
if (timelineController.mainTimelineMode() is Timeline.Mode.Thread && threadId == null) {
// We are in a thread timeline, and the event isn't part of a thread, we need to navigate back to the room
focusRequestState.value = FocusRequestState.None
navigator.onNavigateToRoom(room.roomId, eventId, calculateServerNamesForRoom(room))
} else {
Timber.tag(tag).d("Focusing on event $eventId - thread $threadId")
timelineController.focusOnEvent(eventId, threadId)
.onSuccess { result ->
when (result) {
is EventFocusResult.FocusedOnLive -> {
focusRequestState.value = FocusRequestState.Success(eventId = eventId)
}
is EventFocusResult.IsInThread -> {
val currentThreadId = (timelineController.mainTimelineMode() as? Timeline.Mode.Thread)?.threadRootId
if (currentThreadId == result.threadId) {
// It's the same thread, we just focus on the event
focusRequestState.value = FocusRequestState.Success(eventId = eventId)
} else {
focusRequestState.value = FocusRequestState.Success(eventId = result.threadId.asEventId())
// It's part of a thread we're not in, let's open it in another timeline
navigator.onOpenThread(result.threadId, eventId)
}
}
}
}
.onFailure {
focusRequestState.value = FocusRequestState.Failure(it)
}
}
}
/**
* This method compute the hasNewItem state passed as a [MutableState] each time the timeline items size changes.
* Basically, if we got new timeline event from sync or local, either from us or another user, we update the state so we tell we have new items.

View file

@ -136,7 +136,7 @@ class DefaultMessagesEntryPointTest {
@Test
fun `test initial target to nav target mapping`() {
assertThat(MessagesEntryPoint.InitialTarget.Messages(focusedEventId = AN_EVENT_ID).toNavTarget())
.isEqualTo(MessagesFlowNode.NavTarget.Messages(AN_EVENT_ID))
.isEqualTo(MessagesFlowNode.NavTarget.Messages(focusedEventId = AN_EVENT_ID))
assertThat(MessagesEntryPoint.InitialTarget.PinnedMessages.toNavTarget())
.isEqualTo(MessagesFlowNode.NavTarget.PinnedMessagesList)
}

View file

@ -563,9 +563,7 @@ class TimelinePresenterTest {
@Test
fun `present - focus on known event retrieves the event from cache`() = runTest {
val timelineItemIndexer = TimelineItemIndexer().apply {
process(listOf(aMessageEvent(eventId = AN_EVENT_ID)))
}
val timelineItemIndexer = TimelineItemIndexer()
val presenter = createTimelinePresenter(
room = FakeJoinedRoom(
liveTimeline = FakeTimeline(
@ -578,7 +576,10 @@ class TimelinePresenterTest {
)
)
),
baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }),
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
threadRootIdForEventResult = { Result.success(null) },
),
),
timelineItemIndexer = timelineItemIndexer,
)
@ -586,7 +587,16 @@ class TimelinePresenterTest {
presenter.present()
}.test {
val initialState = awaitFirstItem()
advanceUntilIdle()
// Pre-populate the indexer after the first items have been retrieved
timelineItemIndexer.process(listOf(aMessageEvent(eventId = AN_EVENT_ID)))
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
advanceUntilIdle()
awaitItem().also { state ->
assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID)
assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO))