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:
parent
e8b7db22cd
commit
7facc40771
55 changed files with 702 additions and 284 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue