diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index f77f06fad3..646a19895a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -39,6 +39,7 @@ import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimel import io.element.android.features.messages.impl.pinned.list.PinnedMessagesListNode import io.element.android.features.messages.impl.report.ReportMessageNode import io.element.android.features.messages.impl.threads.ThreadedMessagesNode +import io.element.android.features.messages.impl.threads.list.ThreadsListNode import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode import io.element.android.features.messages.impl.timeline.model.TimelineItem @@ -179,6 +180,9 @@ class MessagesFlowNode( @Parcelize data class Thread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget + + @Parcelize + data object ThreadsList : NavTarget } private val callback: MessagesEntryPoint.Callback = callback() @@ -294,6 +298,10 @@ class MessagesFlowNode( backstack.push(NavTarget.Thread(threadRootId, focusedEventId)) } + override fun navigateToThreadsList() { + backstack.push(NavTarget.ThreadsList) + } + override fun navigateToDeveloperSettings() { callback.navigateToDeveloperSettings() } @@ -517,6 +525,14 @@ class MessagesFlowNode( } createNode(buildContext, listOf(inputs, callback)) } + NavTarget.ThreadsList -> { + val callback = object : ThreadsListNode.Callback { + override fun openThread(threadId: ThreadId) { + backstack.push(NavTarget.Thread(threadId, focusedEventId = null)) + } + } + createNode(buildContext, listOf(callback)) + } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index 20cdc51035..a2cf4a3da0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -131,6 +131,8 @@ class MessagesNode( fun navigateToPinnedMessagesList() fun navigateToKnockRequestsList() fun navigateToDeveloperSettings() + + fun navigateToThreadsList() } override fun onBuilt() { @@ -299,6 +301,7 @@ class MessagesNode( onViewRequestsClick = callback::navigateToKnockRequestsList, ) }, + onThreadsListClick = callback::navigateToThreadsList, ) roomMemberModerationRenderer.Render( state = state.roomMemberModerationState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index edb11ff389..f115dd2799 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable @@ -27,6 +28,7 @@ import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.PinUnpinAction import io.element.android.appconfig.MessageComposerConfig import io.element.android.features.messages.api.timeline.HtmlConverterProvider +import io.element.android.features.messages.impl.MessagesState.Threads import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState @@ -85,8 +87,11 @@ import io.element.android.libraries.recentemojis.api.AddRecentEmoji import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -160,6 +165,13 @@ class MessagesPresenter( val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present() val roomCallState = roomCallStatePresenter.present() val roomMemberModerationState = roomMemberModerationPresenter.present() + val threadsList by produceState(persistentListOf()) { + room.threadsListService.subscribeToItemUpdates() + .onStart { room.threadsListService.paginate() } + .collectLatest { value = it.toImmutableList() } + } + + val canOpenThreadList by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomThreadList).collectAsState(initial = false) val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms -> perms.userEventPermissions() @@ -294,6 +306,11 @@ class MessagesPresenter( roomMemberModerationState = roomMemberModerationState, topBarSharedHistoryIcon = topBarSharedHistoryIcon, successorRoom = roomInfo.successorRoom, + threads = Threads( + hasThreads = canOpenThreadList && threadsList.isNotEmpty(), + // TODO calculate this properly based on the thread list and the read state of each thread + hasUnreadThreads = false, + ), eventSink = ::handleEvent, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index c18fb461e0..862f30832b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -57,9 +57,15 @@ data class MessagesState( /** Type of "shared history" icon to show in the top bar. */ val topBarSharedHistoryIcon: SharedHistoryIcon, val successorRoom: SuccessorRoom?, + val threads: Threads, val eventSink: (MessagesEvent) -> Unit ) { val isTombstoned = successorRoom != null + + data class Threads( + val hasThreads: Boolean, + val hasUnreadThreads: Boolean, + ) } /** Type of "shared history" icon to show in the top bar. */ diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index d969ae1491..16021df3e9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -122,6 +122,10 @@ fun aMessagesState( roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(), topBarSharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE, successorRoom: SuccessorRoom? = null, + threads: MessagesState.Threads = MessagesState.Threads( + hasThreads = false, + hasUnreadThreads = false, + ), eventSink: (MessagesEvent) -> Unit = {}, ) = MessagesState( roomId = RoomId("!id:domain"), @@ -150,6 +154,7 @@ fun aMessagesState( roomMemberModerationState = roomMemberModerationState, topBarSharedHistoryIcon = topBarSharedHistoryIcon, successorRoom = successorRoom, + threads = threads, eventSink = eventSink, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 8e81ee74a7..bf20c8dc6b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -12,10 +12,12 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -26,6 +28,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -52,6 +55,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent import io.element.android.features.messages.impl.actionlist.ActionListEvent import io.element.android.features.messages.impl.actionlist.ActionListView @@ -74,6 +78,7 @@ import io.element.android.features.messages.impl.timeline.aGroupedEvents import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineState +import io.element.android.features.messages.impl.timeline.components.CallMenuItem import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent @@ -88,6 +93,7 @@ import io.element.android.features.messages.impl.topbars.MessagesViewTopBar import io.element.android.features.messages.impl.topbars.ThreadTopBar import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog +import io.element.android.features.roomcall.api.RoomCallState import io.element.android.libraries.androidutils.ui.hideKeyboard import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule import io.element.android.libraries.designsystem.components.ExpandableBottomSheetLayout @@ -99,6 +105,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.toAnnotatedString import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle +import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.HideKeyboardWhenDisposed @@ -133,6 +140,7 @@ fun MessagesView( onCreatePollClick: () -> Unit, onJoinCallClick: (isAudioCall: Boolean) -> Unit, onViewAllPinnedMessagesClick: () -> Unit, + onThreadsListClick: () -> Unit, modifier: Modifier = Modifier, forceJumpToBottomVisibility: Boolean = false, knockRequestsBannerView: @Composable () -> Unit, @@ -224,12 +232,18 @@ fun MessagesView( roomAvatar = state.roomAvatar, isTombstoned = state.isTombstoned, heroes = state.heroes, - roomCallState = state.roomCallState, dmUserIdentityState = state.dmUserVerificationState, sharedHistoryIcon = state.topBarSharedHistoryIcon, onBackClick = { hidingKeyboard { onBackClick() } }, onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } }, - onJoinCallClick = onJoinCallClick, + menuActions = { + MessagesMenuActions( + displayThreads = state.timelineState.timelineMode !is Timeline.Mode.Thread && state.threads.hasThreads, + roomCallState = state.roomCallState, + onJoinCallClick = onJoinCallClick, + onThreadsListClick = onThreadsListClick + ) + } ) } }, @@ -397,6 +411,28 @@ fun MessagesView( ) } +@Composable +internal fun MessagesMenuActions( + displayThreads: Boolean, + roomCallState: RoomCallState, + onJoinCallClick: (isAudioCall: Boolean) -> Unit, + onThreadsListClick: () -> Unit, +) { + if (displayThreads) { + Icon( + modifier = Modifier.clickable(enabled = true, onClick = onThreadsListClick), + imageVector = CompoundIcons.ThreadsSolid(), + contentDescription = stringResource(CommonStrings.common_threads), + ) + Spacer(Modifier.width(8.dp)) + } + CallMenuItem( + roomCallState = roomCallState, + onJoinCallClick = onJoinCallClick, + ) + Spacer(Modifier.width(8.dp)) +} + @Composable private fun ReinviteDialog(state: MessagesState) { if (state.showReinvitePrompt) { @@ -601,6 +637,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class) onViewAllPinnedMessagesClick = { }, forceJumpToBottomVisibility = true, knockRequestsBannerView = {}, + onThreadsListClick = {}, ) } @@ -652,7 +689,8 @@ internal fun MessagesViewA11yPreview() = ElementPreview { onSendLocationClick = {}, onCreatePollClick = {}, onJoinCallClick = {}, - onViewAllPinnedMessagesClick = { }, + onViewAllPinnedMessagesClick = {}, + onThreadsListClick = {}, forceJumpToBottomVisibility = true, knockRequestsBannerView = {}, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt index b434656f7a..2c1a1bbe23 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt @@ -41,6 +41,7 @@ internal fun MessagesViewWithIdentityChangePreview( onCreatePollClick = {}, onJoinCallClick = {}, onViewAllPinnedMessagesClick = {}, - knockRequestsBannerView = {} + knockRequestsBannerView = {}, + onThreadsListClick = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt index 4bb3471660..0949237862 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt @@ -300,6 +300,7 @@ class ThreadedMessagesNode( onViewAllPinnedMessagesClick = {}, modifier = modifier, knockRequestsBannerView = {}, + onThreadsListClick = {}, ) roomMemberModerationRenderer.Render( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadListRowItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadListRowItem.kt new file mode 100644 index 0000000000..3380a32f61 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadListRowItem.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.threads.list + +import io.element.android.libraries.matrix.api.room.threads.ThreadListItem + +data class ThreadListRowItem( + val item: ThreadListItem, + val rootEventText: String?, + val latestEventText: String?, + val formattedTimestamp: String, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListNode.kt new file mode 100644 index 0000000000..1954dc60a5 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListNode.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.threads.list + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.ThreadId + +@ContributesNode(RoomScope::class) +@AssistedInject +class ThreadsListNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: ThreadsListPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun openThread(threadId: ThreadId) + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + ThreadsListView( + state = presenter.present(), + modifier = modifier, + onThreadClick = callback::openThread, + onBackClick = this::navigateUp, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListPresenter.kt new file mode 100644 index 0000000000..9d15376e9f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListPresenter.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.threads.list + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFactory +import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.api.DateFormatterMode +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import timber.log.Timber + +@Inject +class ThreadsListPresenter( + private val room: JoinedRoom, + private val timelineItemContentFactory: TimelineItemContentFactory, + private val messageSummaryFormatter: MessageSummaryFormatter, + private val dateFormatter: DateFormatter, +) : Presenter { + @Composable + override fun present(): ThreadsListState { + val coroutineScope = rememberCoroutineScope() + val threadsListService = room.threadsListService + + val threads by produceState(initialValue = persistentListOf(), key1 = threadsListService) { + threadsListService.subscribeToItemUpdates() + .onStart { threadsListService.paginate() } + .collect { items -> + Timber.d("Received thread list update with ${items.size} items") + value = items.map { item -> + val rootTimelineEvent = item.rootEvent.content?.let { + timelineItemContentFactory.create( + itemContent = it, + eventId = item.rootEvent.eventId, + isEditable = false, + sender = item.rootEvent.senderId, + senderProfile = item.rootEvent.senderProfile, + ) + } + val rootEventText = rootTimelineEvent?.let { messageSummaryFormatter.format(it) } + + val latestTimelineEvent = item.latestEvent?.content?.let { + timelineItemContentFactory.create( + itemContent = it, + eventId = item.latestEvent!!.eventId, + isEditable = false, + sender = item.latestEvent!!.senderId, + senderProfile = item.latestEvent!!.senderProfile, + ) + } + val latestEventText = latestTimelineEvent?.let { messageSummaryFormatter.format(it) } + + val formattedTimestamp = dateFormatter.format( + timestamp = item.latestEvent?.timestamp ?: item.rootEvent.timestamp, + mode = DateFormatterMode.TimeOrDate, + useRelative = true, + ) + + ThreadListRowItem( + item = item, + rootEventText = rootEventText, + latestEventText = latestEventText, + formattedTimestamp = formattedTimestamp, + ) + }.toImmutableList() + } + } + + val paginationStatus by produceState( + initialValue = ThreadListPaginationStatus.Idle(hasMoreToLoad = true), + key1 = threadsListService + ) { + threadsListService + .subscribeToPaginationUpdates() + .collect { value = it } + } + + val roomInfo by room.roomInfoFlow.collectAsState() + + DisposableEffect(Unit) { + onDispose { + threadsListService.destroy() + } + } + + fun handleEvent(event: ThreadsListEvents) { + when (event) { + ThreadsListEvents.Paginate -> if ((paginationStatus as? ThreadListPaginationStatus.Idle)?.hasMoreToLoad == true) { + coroutineScope.launch { + Timber.d("Paginating thread list: $paginationStatus") + threadsListService.paginate() + } + } else { + Timber.d("Not paginating since there is nothing else to load, current status: $paginationStatus") + } + } + } + + return ThreadsListState( + threads = threads, + roomId = room.roomId, + roomName = roomInfo.name ?: room.roomId.value, + roomAvatarUrl = roomInfo.avatarUrl, + isRoomTombstoned = roomInfo.successorRoom != null, + eventSink = ::handleEvent, + ) + } +} + +data class ThreadsListState( + val roomId: RoomId, + val roomName: String, + val roomAvatarUrl: String?, + val isRoomTombstoned: Boolean, + val threads: ImmutableList, + val eventSink: (ThreadsListEvents) -> Unit, +) + +sealed interface ThreadsListEvents { + data object Paginate : ThreadsListEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt new file mode 100644 index 0000000000..c93af5c162 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt @@ -0,0 +1,380 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.threads.list + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.core.asEventId +import io.element.android.libraries.matrix.api.room.threads.ThreadListItem +import io.element.android.libraries.matrix.api.room.threads.ThreadListItemEvent +import io.element.android.libraries.matrix.api.timeline.item.event.EventContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl +import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ThreadsListView( + state: ThreadsListState, + onThreadClick: (ThreadId) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Avatar( + avatarData = AvatarData( + id = state.roomId.value, + name = state.roomName, + url = state.roomAvatarUrl, + size = AvatarSize.CurrentUserTopBar, + ), + avatarType = AvatarType.Room(isTombstoned = state.isRoomTombstoned), + contentDescription = null, + ) + Column { + Text( + text = stringResource(CommonStrings.common_threads), + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = state.roomName, + style = ElementTheme.typography.fontBodyXsRegular, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, + navigationIcon = { + BackButton(onBackClick) + } + ) + } + ) { padding -> + val lazyListState = rememberLazyListState() + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = padding, + state = lazyListState, + ) { + itemsIndexed(state.threads, key = { _, row -> row.item.threadId }) { index, row -> + ThreadListItemRow( + threadItem = row, + onClick = onThreadClick, + ) + + if (index < state.threads.size - 1) { + HorizontalDivider() + } + } + } + + ScrollHelper(lazyListState) { + state.eventSink(ThreadsListEvents.Paginate) + } + } +} + +@Composable +private fun ScrollHelper( + listState: LazyListState, + onPaginate: () -> Unit, +) { + val lastVisibleItemIndex by remember { + derivedStateOf { listState.firstVisibleItemIndex + listState.layoutInfo.visibleItemsInfo.size - 1 } + } + val needsPagination by remember { + derivedStateOf { + val canLoadNewItems = listState.isScrollInProgress || listState.firstVisibleItemScrollOffset == 0 + canLoadNewItems && lastVisibleItemIndex == listState.layoutInfo.totalItemsCount - 1 + } + } + LaunchedEffect(needsPagination, lastVisibleItemIndex) { + if (needsPagination) { + onPaginate() + delay(400L) + } + } +} + +@Composable +private fun ThreadListItemRow( + threadItem: ThreadListRowItem, + onClick: (ThreadId) -> Unit, +) { + Row( + modifier = Modifier + .clickable { onClick(threadItem.item.threadId) } + .fillMaxWidth() + .padding(top = 4.dp, bottom = 8.dp, start = 16.dp, end = 16.dp), + ) { + val rootEvent = threadItem.item.rootEvent + val senderProfile = rootEvent.senderProfile + Avatar( + modifier = Modifier.align(Alignment.CenterVertically), + avatarData = AvatarData( + id = rootEvent.senderId.value, + name = senderProfile.getDisambiguatedDisplayName(rootEvent.senderId), + url = senderProfile.getAvatarUrl(), + size = AvatarSize.ThreadsListItem, + ), + avatarType = AvatarType.User, + contentDescription = null, + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.fillMaxWidth()) { + // TODO actually compute these values based on the thread state (not available yet) + val hasMentions = false + val hasUnreadNotifications = false + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = senderProfile.getDisambiguatedDisplayName(rootEvent.senderId), + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = threadItem.formattedTimestamp, + style = ElementTheme.typography.fontBodySmRegular, + color = if (hasUnreadNotifications || hasMentions) ElementTheme.colors.textActionAccent else ElementTheme.colors.textSecondary, + ) + } + + Spacer(modifier = Modifier.height(2.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = threadItem.rootEventText.orEmpty(), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(7.dp) + ) { + if (hasMentions) { + Icon( + modifier = Modifier.size(14.dp), + imageVector = CompoundIcons.Mention(), + contentDescription = null, + tint = ElementTheme.colors.textActionAccent, + ) + } + + UnreadIndicatorAtom( + size = 14.dp, + isVisible = hasUnreadNotifications, + ) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${threadItem.item.numberOfReplies}", + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.width(4.dp)) + + Icon( + modifier = Modifier.size(20.dp), + imageVector = CompoundIcons.ThreadsSolid(), + contentDescription = null, + tint = ElementTheme.colors.iconSecondary + ) + + Spacer(modifier = Modifier.width(8.dp)) + + threadItem.item.latestEvent?.let { latestEvent -> + Avatar( + avatarData = AvatarData( + id = latestEvent.senderId.value, + name = latestEvent.senderProfile.getDisambiguatedDisplayName(latestEvent.senderId), + url = latestEvent.senderProfile.getAvatarUrl(), + size = AvatarSize.TimelineThreadLatestEventSender, + ), + avatarType = AvatarType.User, + contentDescription = null, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = threadItem.latestEventText.orEmpty(), + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun ThreadsListViewPreview() { + ElementPreview { + ThreadsListView( + state = ThreadsListState( + roomId = RoomId("!room-id:server"), + roomName = "Room name", + roomAvatarUrl = null, + threads = List(10) { aThreadListRowItem(threadId = ThreadId("\$thread-$it")) }.toImmutableList(), + isRoomTombstoned = false, + eventSink = {}, + ), + onThreadClick = {}, + onBackClick = {}, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun ThreadListItemRowPreview() { + ElementPreview { + ThreadListItemRow( + threadItem = aThreadListRowItem(), + onClick = {}, + ) + } +} + +fun aThreadListRowItem( + threadId: ThreadId = ThreadId("\$a-thread-id"), + rootEvent: ThreadListItemEvent = aThreadListItemEvent(threadId = threadId), + latestEvent: ThreadListItemEvent? = aThreadListItemEvent(threadId = threadId), + numberOfReplies: Long = 42, + rootEventText: String? = "Hello world!", + latestEventText: String? = "Hello again!", + formattedTimestamp: String = "12:34", +) = ThreadListRowItem( + item = aThreadListItem( + threadId = threadId, + rootEvent = rootEvent, + latestEvent = latestEvent, + numberOfReplies = numberOfReplies, + ), + rootEventText = rootEventText, + latestEventText = latestEventText, + formattedTimestamp = formattedTimestamp, +) + +fun aThreadListItem( + threadId: ThreadId = ThreadId("\$a-thread-id"), + rootEvent: ThreadListItemEvent = aThreadListItemEvent(threadId = threadId), + latestEvent: ThreadListItemEvent? = aThreadListItemEvent(threadId = threadId), + numberOfReplies: Long = 42, +) = ThreadListItem( + rootEvent = rootEvent, + latestEvent = latestEvent, + numberOfReplies = numberOfReplies, +) + +fun aThreadListItemEvent( + threadId: ThreadId = ThreadId("\$a-thread-id"), + senderId: UserId = UserId("@a-user-id:server"), + senderProfile: ProfileDetails = ProfileDetails.Ready(displayName = "Alice", displayNameAmbiguous = false, avatarUrl = null), + isOwn: Boolean = false, + content: EventContent = MessageContent( + body = "Hello world!", + inReplyTo = null, + isEdited = false, + threadInfo = null, + type = TextMessageType("Hello world!", null), + ), + timestamp: Long = 0L, +) = ThreadListItemEvent( + eventId = threadId.asEventId(), + senderId = senderId, + senderProfile = senderProfile, + isOwn = isOwn, + content = content, + timestamp = timestamp, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt index 24cd71ae84..4d7242ebf5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt @@ -12,10 +12,9 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable @@ -30,8 +29,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.MessagesMenuActions import io.element.android.features.messages.impl.SharedHistoryIcon -import io.element.android.features.messages.impl.timeline.components.CallMenuItem import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roomcall.api.anOngoingCallState @@ -62,13 +61,12 @@ internal fun MessagesViewTopBar( roomAvatar: AvatarData, isTombstoned: Boolean, heroes: ImmutableList, - roomCallState: RoomCallState, dmUserIdentityState: IdentityState?, sharedHistoryIcon: SharedHistoryIcon, onRoomDetailsClick: () -> Unit, - onJoinCallClick: (isAudioCall: Boolean) -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, + menuActions: @Composable RowScope.() -> Unit, ) { TopAppBar( modifier = modifier, @@ -126,13 +124,7 @@ internal fun MessagesViewTopBar( } } }, - actions = { - CallMenuItem( - roomCallState = roomCallState, - onJoinCallClick = onJoinCallClick, - ) - Spacer(Modifier.width(8.dp)) - }, + actions = menuActions, windowInsets = WindowInsets(0.dp) ) } @@ -186,17 +178,24 @@ internal fun MessagesViewTopBarPreview() = ElementPreview { roomCallState: RoomCallState = RoomCallState.Unavailable, dmUserIdentityState: IdentityState? = null, sharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE, + displayThreads: Boolean = false, ) = MessagesViewTopBar( roomName = roomName, roomAvatar = roomAvatar, isTombstoned = isTombstoned, heroes = heroes, - roomCallState = roomCallState, dmUserIdentityState = dmUserIdentityState, sharedHistoryIcon = sharedHistoryIcon, onRoomDetailsClick = {}, - onJoinCallClick = {}, onBackClick = {}, + menuActions = { + MessagesMenuActions( + roomCallState = roomCallState, + displayThreads = displayThreads, + onJoinCallClick = {}, + onThreadsListClick = {}, + ) + } ) Column { AMessagesViewTopBar() @@ -237,5 +236,9 @@ internal fun MessagesViewTopBarPreview() = ElementPreview { roomName = "A room with world_readable history", sharedHistoryIcon = SharedHistoryIcon.WORLD_READABLE, ) + HorizontalDivider() + AMessagesViewTopBar( + displayThreads = true, + ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index f6967de0e5..6e12c607d8 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState +import io.element.android.features.messages.impl.threads.list.aThreadListItem import io.element.android.features.messages.impl.timeline.FakeMarkAsFullyRead import io.element.android.features.messages.impl.timeline.MarkAsFullyRead import io.element.android.features.messages.impl.timeline.TimelineController @@ -88,6 +89,7 @@ import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions +import io.element.android.libraries.matrix.test.room.threads.FakeThreadsListService import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.aTimelineItemDebugInfo import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails @@ -110,6 +112,7 @@ import io.element.android.tests.testutils.testWithLifecycleOwner import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent @@ -1258,6 +1261,35 @@ class MessagesPresenterTest { } } + @Test + fun `present - only has threads enabled if the feature flag is on`() = runTest { + val itemsFlow = MutableStateFlow(listOf(aThreadListItem())) + val room = FakeJoinedRoom( + threadsListService = FakeThreadsListService(items = itemsFlow) + ) + val featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.Threads.key to false) + ) + val presenter = createMessagesPresenter( + joinedRoom = room, + featureFlagService = featureFlagService + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + // The feature flag is disabled, so even if the thread list has items, it will return it doesn't have any + assertThat(initialState.threads.hasThreads).isFalse() + + // Enable the feature flag, now it should reflect the thread list state + featureFlagService.setFeatureEnabled(FeatureFlags.RoomThreadList, true) + skipItems(1) + assertThat(awaitItem().threads.hasThreads).isTrue() + + // And if we remove the items, it should update accordingly + itemsFlow.value = emptyList() + assertThat(awaitItem().threads.hasThreads).isFalse() + } + } + private fun roomPermissions( canStartCall: Boolean = true, canRedactOther: Boolean = true, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index c78aa39265..ff4bc37fa3 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -73,6 +73,7 @@ import io.element.android.tests.testutils.assertNoNodeWithText import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.setSafeContent import kotlinx.collections.immutable.persistentListOf @@ -606,6 +607,23 @@ class MessagesViewTest { eventsRecorder.assertSingle(TimelineEvent.NavigateToPredecessorOrSuccessorRoom(successorRoomId)) } + @Test + fun `clicking on threads list button calls the expected function`() { + val state = aMessagesState( + threads = MessagesState.Threads( + hasThreads = true, + hasUnreadThreads = false, + ) + ) + val onThreadsListClicked = lambdaRecorder {} + rule.setMessagesView( + state = state, + onThreadsListClicked = onThreadsListClicked, + ) + rule.onNodeWithContentDescription("Threads").performClick() + onThreadsListClicked.assertions().isCalledOnce() + } + @Test fun `no banner shown when there is no successor room`() { val eventsRecorder = EventsRecorder(expectEvents = false) @@ -630,6 +648,7 @@ private fun AndroidComposeTestRule.setMessa onCreatePollClick: () -> Unit = EnsureNeverCalled(), onJoinCallClick: (Boolean) -> Unit = EnsureNeverCalledWithParam(), onViewAllPinnedMessagesClick: () -> Unit = EnsureNeverCalled(), + onThreadsListClicked: () -> Unit = EnsureNeverCalled(), ) { setSafeContent { // Cannot use the RichTextEditor, so simulate a LocalInspectionMode @@ -646,6 +665,7 @@ private fun AndroidComposeTestRule.setMessa onJoinCallClick = onJoinCallClick, onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick, knockRequestsBannerView = {}, + onThreadsListClick = onThreadsListClicked, ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt index c7eb7c0bce..2f87af3df0 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt @@ -48,38 +48,42 @@ internal fun TestScope.aTimelineItemsFactoryCreator(): TimelineItemsFactory.Crea } } +internal fun aTimelineItemContentFactory( + timelineEventFormatter: TimelineEventFormatter = aTimelineEventFormatter(), + matrixClient: FakeMatrixClient = FakeMatrixClient(), +): TimelineItemContentFactory = TimelineItemContentFactory( + messageFactory = TimelineItemContentMessageFactory( + fileSizeFormatter = FakeFileSizeFormatter(), + fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), + htmlConverterProvider = FakeHtmlConverterProvider(), + permalinkParser = FakePermalinkParser(), + textPillificationHelper = FakeTextPillificationHelper(), + ), + redactedMessageFactory = TimelineItemContentRedactedFactory(), + stickerFactory = TimelineItemContentStickerFactory( + fileSizeFormatter = FakeFileSizeFormatter(), + fileExtensionExtractor = FileExtensionExtractorWithoutValidation() + ), + pollFactory = TimelineItemContentPollFactory(FakePollContentStateFactory()), + utdFactory = TimelineItemContentUTDFactory(), + roomMembershipFactory = TimelineItemContentRoomMembershipFactory(timelineEventFormatter), + profileChangeFactory = TimelineItemContentProfileChangeFactory(timelineEventFormatter), + stateFactory = TimelineItemContentStateFactory(timelineEventFormatter), + failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(), + failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(), + sessionId = matrixClient.sessionId, +) + internal fun TestScope.aTimelineItemsFactory( config: TimelineItemsFactoryConfig, ): TimelineItemsFactory { - val timelineEventFormatter = aTimelineEventFormatter() val matrixClient = FakeMatrixClient() return TimelineItemsFactory( dispatchers = testCoroutineDispatchers(), eventItemFactoryCreator = object : TimelineItemEventFactory.Creator { override fun create(config: TimelineItemsFactoryConfig): TimelineItemEventFactory { return TimelineItemEventFactory( - contentFactory = TimelineItemContentFactory( - messageFactory = TimelineItemContentMessageFactory( - fileSizeFormatter = FakeFileSizeFormatter(), - fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), - htmlConverterProvider = FakeHtmlConverterProvider(), - permalinkParser = FakePermalinkParser(), - textPillificationHelper = FakeTextPillificationHelper(), - ), - redactedMessageFactory = TimelineItemContentRedactedFactory(), - stickerFactory = TimelineItemContentStickerFactory( - fileSizeFormatter = FakeFileSizeFormatter(), - fileExtensionExtractor = FileExtensionExtractorWithoutValidation() - ), - pollFactory = TimelineItemContentPollFactory(FakePollContentStateFactory()), - utdFactory = TimelineItemContentUTDFactory(), - roomMembershipFactory = TimelineItemContentRoomMembershipFactory(timelineEventFormatter), - profileChangeFactory = TimelineItemContentProfileChangeFactory(timelineEventFormatter), - stateFactory = TimelineItemContentStateFactory(timelineEventFormatter), - failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(), - failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(), - sessionId = matrixClient.sessionId, - ), + contentFactory = aTimelineItemContentFactory(matrixClient = matrixClient), matrixClient = matrixClient, dateFormatter = FakeDateFormatter(), permalinkParser = FakePermalinkParser(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/threads/ThreadsListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/threads/ThreadsListPresenterTest.kt new file mode 100644 index 0000000000..9f8d210a16 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/threads/ThreadsListPresenterTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.threads + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.fixtures.aTimelineItemContentFactory +import io.element.android.features.messages.impl.messagesummary.FakeMessageSummaryFormatter +import io.element.android.features.messages.impl.threads.list.ThreadsListEvents +import io.element.android.features.messages.impl.threads.list.ThreadsListPresenter +import io.element.android.features.messages.impl.threads.list.aThreadListItem +import io.element.android.libraries.dateformatter.test.FakeDateFormatter +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_NAME +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.threads.FakeThreadsListService +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ThreadsListPresenterTest { + @Test + fun `present - initial state`() = runTest { + createThreadsListPresenter().test { + awaitItem().run { + assertThat(threads).isEmpty() + assertThat(roomId).isEqualTo(A_ROOM_ID) + assertThat(roomName).isEqualTo(A_ROOM_NAME) + assertThat(roomAvatarUrl).isEqualTo(AN_AVATAR_URL) + } + } + } + + @Test + fun `present - paginate`() = runTest { + val paginateRecorder = lambdaRecorder> { Result.success(Unit) } + val threadsListService = FakeThreadsListService(paginate = paginateRecorder) + val room = FakeJoinedRoom(threadsListService = threadsListService) + createThreadsListPresenter(room).test { + val initialItem = awaitItem() + + // Pagination is automatically triggered on start, so we should have one call to paginate already + paginateRecorder.assertions().isCalledOnce() + + initialItem.eventSink(ThreadsListEvents.Paginate) + + // Simulate a pagination result + threadsListService.emit(listOf(aThreadListItem())) + + // We should have a second call to paginate after the event is sent + paginateRecorder.assertions().isCalledExactly(2) + + // And we receive the new items + assertThat(awaitItem().threads).isNotEmpty() + } + } + + private fun createThreadsListPresenter( + room: FakeJoinedRoom = FakeJoinedRoom(), + ): ThreadsListPresenter { + return ThreadsListPresenter( + room = room, + timelineItemContentFactory = aTimelineItemContentFactory(), + messageSummaryFormatter = FakeMessageSummaryFormatter(), + dateFormatter = FakeDateFormatter(), + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index 53d5a7c281..b1e3356fc3 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -13,10 +13,12 @@ import androidx.compose.ui.unit.dp enum class AvatarSize(val dp: Dp) { CurrentUserTopBar(32.dp), + CurrentRoomTopBar(32.dp), IncomingCall(140.dp), RoomDetailsHeader(96.dp), RoomListItem(52.dp), + ThreadsListItem(52.dp), SpaceListItem(52.dp), diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 5b65a32f61..eaa32e8adc 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -155,4 +155,11 @@ enum class FeatureFlags( defaultValue = { false }, isFinished = false, ), + RoomThreadList( + key = "feature.room_thread_list", + title = "Add a list of threads in a room", + description = "Add a new screen with a list of threads in a room.", + defaultValue = { false }, + isFinished = false, + ), } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt index 808f37c7c9..32a6f2e409 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt @@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.room.knock.KnockRequest import io.element.android.libraries.matrix.api.room.location.LiveLocationShare import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange +import io.element.android.libraries.matrix.api.room.threads.ThreadsListService import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver @@ -44,6 +45,8 @@ interface JoinedRoom : BaseRoom { */ val liveTimeline: Timeline + val threadsListService: ThreadsListService + /** * Create a new timeline. * @param createTimelineParams contains parameters about how to filter the timeline. Will also configure the date separators. diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadListItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadListItem.kt new file mode 100644 index 0000000000..8282caafd1 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadListItem.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.threads + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.core.toThreadId +import io.element.android.libraries.matrix.api.timeline.item.event.EventContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails + +@Immutable +data class ThreadListItem( + val rootEvent: ThreadListItemEvent, + val latestEvent: ThreadListItemEvent?, + val numberOfReplies: Long, +) { + val threadId = rootEvent.eventId.toThreadId() +} + +@Immutable +data class ThreadListItemEvent( + val eventId: EventId, + val senderId: UserId, + val senderProfile: ProfileDetails, + val isOwn: Boolean, + val content: EventContent?, + val timestamp: Long, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadListPaginationStatus.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadListPaginationStatus.kt new file mode 100644 index 0000000000..0716ca7c11 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadListPaginationStatus.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.threads + +sealed interface ThreadListPaginationStatus { + data class Idle( + val hasMoreToLoad: Boolean, + ) : ThreadListPaginationStatus + + data object Loading : ThreadListPaginationStatus +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadsListService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadsListService.kt new file mode 100644 index 0000000000..7f819c540c --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadsListService.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.threads + +import kotlinx.coroutines.flow.Flow + +interface ThreadsListService { + fun subscribeToItemUpdates(): Flow> + fun subscribeToPaginationUpdates(): Flow + suspend fun paginate(): Result + suspend fun reset(): Result + fun destroy() +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index 73e76f73a8..e6287d0d16 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.location.LiveLocationShare import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange import io.element.android.libraries.matrix.api.room.roomNotificationSettings +import io.element.android.libraries.matrix.api.room.threads.ThreadsListService import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver @@ -44,8 +45,10 @@ import io.element.android.libraries.matrix.impl.room.history.map import io.element.android.libraries.matrix.impl.room.join.map import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher +import io.element.android.libraries.matrix.impl.room.threads.RustThreadsListService import io.element.android.libraries.matrix.impl.roomdirectory.map import io.element.android.libraries.matrix.impl.timeline.RustTimeline +import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper import io.element.android.libraries.matrix.impl.util.MessageEventContent import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver @@ -145,6 +148,12 @@ class JoinedRustRoom( override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.Live) + override val threadsListService: ThreadsListService = RustThreadsListService( + inner = innerRoom.threadListService(), + contentMapper = TimelineEventContentMapper(), + roomCoroutineScope = roomCoroutineScope, + ) + override val syncUpdateFlow = flow { var counter = 0L liveTimeline.onSyncedEventReceived.collect { @@ -528,6 +537,7 @@ class JoinedRustRoom( override fun destroy() { baseRoom.destroy() liveInnerTimeline.destroy() + threadsListService.destroy() Timber.d("Room $roomId destroyed") } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt new file mode 100644 index 0000000000..a74c5bc378 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.threads + +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.threads.ThreadListItem +import io.element.android.libraries.matrix.api.room.threads.ThreadListItemEvent +import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus +import io.element.android.libraries.matrix.api.room.threads.ThreadsListService +import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper +import io.element.android.libraries.matrix.impl.timeline.item.event.map +import io.element.android.libraries.matrix.impl.util.mxCallbackFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import org.matrix.rustcomponents.sdk.ThreadListEntriesListener +import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener +import org.matrix.rustcomponents.sdk.ThreadListUpdate +import uniffi.matrix_sdk_ui.ThreadListPaginationState +import org.matrix.rustcomponents.sdk.ThreadListService as InnerThreadListService + +class RustThreadsListService( + private val inner: InnerThreadListService, + private val roomCoroutineScope: CoroutineScope, + private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper(), +) : ThreadsListService { + private var itemSubscriptionJob: Job? = null + + private val items = MutableStateFlow>(emptyList()) + + override fun subscribeToItemUpdates(): Flow> { + if (itemSubscriptionJob?.isActive != true) { + itemSubscriptionJob = doSubscribeToItemUpdates() + } + + return items + } + + private fun doSubscribeToItemUpdates(): Job { + val updatesFlow = mxCallbackFlow { + inner.subscribeToItemsUpdates(object : ThreadListEntriesListener { + override fun onUpdate(diff: List) { + trySend(diff) + } + }) + } + + return updatesFlow + .onStart { items.value = inner.items().map { it.map(contentMapper) } } + .onEach { diff -> + val updated = items.value.toMutableList() + updated.apply(diff, contentMapper) + items.value = updated + } + .launchIn(roomCoroutineScope) + } + + override fun subscribeToPaginationUpdates(): Flow { + return mxCallbackFlow { + inner.subscribeToPaginationStateUpdates(object : ThreadListPaginationStateListener { + override fun onUpdate(state: ThreadListPaginationState) { + trySend(state.map()) + } + }).also { + // Send the initial state + trySend(inner.paginationState().map()) + } + } + } + + override suspend fun paginate(): Result = runCatchingExceptions { + inner.paginate() + } + + override suspend fun reset(): Result = runCatchingExceptions { + inner.reset() + } + + override fun destroy() { + itemSubscriptionJob?.cancel() + inner.destroy() + } +} + +private fun MutableList.apply( + diff: List, + contentMapper: TimelineEventContentMapper +) { + for (diffItem in diff) { + when (diffItem) { + is ThreadListUpdate.Append -> { + val newItems = diffItem.values.map { it.map(contentMapper) } + addAll(newItems) + } + ThreadListUpdate.Clear -> clear() + is ThreadListUpdate.Insert -> { + add(diffItem.index.toInt(), diffItem.value.map(contentMapper)) + } + ThreadListUpdate.PopBack -> { + removeAt(lastIndex) + } + ThreadListUpdate.PopFront -> { + removeAt(0) + } + is ThreadListUpdate.PushBack -> { + add(diffItem.value.map(contentMapper)) + } + is ThreadListUpdate.PushFront -> { + add(0, diffItem.value.map(contentMapper)) + } + is ThreadListUpdate.Remove -> { + removeAt(diffItem.index.toInt()) + } + is ThreadListUpdate.Reset -> { + clear() + addAll(diffItem.values.map { it.map(contentMapper) }) + } + is ThreadListUpdate.Set -> { + set(diffItem.index.toInt(), diffItem.value.map(contentMapper)) + } + is ThreadListUpdate.Truncate -> { + subList(diffItem.length.toInt(), size).clear() + } + } + } +} + +fun org.matrix.rustcomponents.sdk.ThreadListItem.map(contentMapper: TimelineEventContentMapper): ThreadListItem = ThreadListItem( + rootEvent = rootEvent.map(contentMapper), + latestEvent = latestEvent?.map(contentMapper), + numberOfReplies = numReplies.toLong(), +) + +fun org.matrix.rustcomponents.sdk.ThreadListItemEvent.map(contentMapper: TimelineEventContentMapper): ThreadListItemEvent = ThreadListItemEvent( + eventId = EventId(eventId), + senderId = UserId(sender), + isOwn = isOwn, + senderProfile = senderProfile.map(), + content = content?.let(contentMapper::map), + timestamp = timestamp.toLong(), +) + +fun ThreadListPaginationState.map(): ThreadListPaginationStatus = when (this) { + is ThreadListPaginationState.Idle -> ThreadListPaginationStatus.Idle(hasMoreToLoad = !endReached) + ThreadListPaginationState.Loading -> ThreadListPaginationStatus.Loading +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiThreadListService.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiThreadListService.kt new file mode 100644 index 0000000000..009e6a3348 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiThreadListService.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.TaskHandle +import org.matrix.rustcomponents.sdk.ThreadListEntriesListener +import org.matrix.rustcomponents.sdk.ThreadListItem +import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener +import org.matrix.rustcomponents.sdk.ThreadListService +import org.matrix.rustcomponents.sdk.ThreadListUpdate +import uniffi.matrix_sdk_ui.ThreadListPaginationState + +class FakeFfiThreadListService( + private val subscribeToItemsUpdates: (ThreadListEntriesListener) -> TaskHandle = { FakeFfiTaskHandle() }, + private val subscribeToPaginationStateUpdates: (ThreadListPaginationStateListener) -> TaskHandle = { FakeFfiTaskHandle() }, + private val items: () -> List = { emptyList() }, + private val paginationState: () -> ThreadListPaginationState = { ThreadListPaginationState.Idle(endReached = false) }, + private val paginate: suspend () -> Unit = {}, + private val reset: suspend () -> Unit = {}, + private val destroy: () -> Unit = {}, +) : ThreadListService(NoHandle) { + private var itemsListener: ThreadListEntriesListener? = null + private var paginationStateListener: ThreadListPaginationStateListener? = null + + override fun subscribeToItemsUpdates(listener: ThreadListEntriesListener): TaskHandle { + itemsListener = listener + return subscribeToItemsUpdates.invoke(listener) + } + + override fun subscribeToPaginationStateUpdates(listener: ThreadListPaginationStateListener): TaskHandle { + paginationStateListener = listener + return subscribeToPaginationStateUpdates.invoke(listener) + } + + override fun items(): List = items.invoke() + + override fun paginationState(): ThreadListPaginationState = paginationState.invoke() + + override suspend fun paginate() = paginate.invoke() + + override suspend fun reset() = reset.invoke() + + override fun destroy() = destroy.invoke() + + fun emitUpdates(updates: List) { + itemsListener?.onUpdate(updates) + } + + fun emitPaginationState(state: ThreadListPaginationState) { + paginationStateListener?.onUpdate(state) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt new file mode 100644 index 0000000000..0fc9ad0603 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.threads + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustTimelineItemContentMsgLike +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTaskHandle +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiThreadListService +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_TIMESTAMP +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.matrix.rustcomponents.sdk.ProfileDetails +import org.matrix.rustcomponents.sdk.TaskHandle +import org.matrix.rustcomponents.sdk.ThreadListEntriesListener +import org.matrix.rustcomponents.sdk.ThreadListItem +import org.matrix.rustcomponents.sdk.ThreadListItemEvent +import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener +import org.matrix.rustcomponents.sdk.ThreadListUpdate +import uniffi.matrix_sdk_ui.ThreadListPaginationState + +@OptIn(ExperimentalCoroutinesApi::class) +class RustThreadsListServiceTest { + @Test + fun `subscribing to item updates calls the FFI method and allows retrieving new items`() = runTest { + val subscribeToItemsUpdatesRecorder = lambdaRecorder { FakeFfiTaskHandle() } + val inner = FakeFfiThreadListService(subscribeToItemsUpdates = subscribeToItemsUpdatesRecorder) + val service = createThreadsListService(inner = inner) + + service.subscribeToItemUpdates().test { + assertThat(awaitItem()).isEmpty() + + runCurrent() + subscribeToItemsUpdatesRecorder.assertions().isCalledOnce() + + inner.emitUpdates(listOf(aRustThreadListUpdate())) + + assertThat(awaitItem()).isNotEmpty() + } + } + + @Suppress("UnusedFlow") + @Test + fun `subscribing to item updates twice only calls the FFI method once`() = runTest { + val subscribeToItemsUpdatesRecorder = lambdaRecorder { FakeFfiTaskHandle() } + val inner = FakeFfiThreadListService(subscribeToItemsUpdates = subscribeToItemsUpdatesRecorder) + val service = createThreadsListService(inner = inner) + + service.subscribeToItemUpdates() + service.subscribeToItemUpdates() + + runCurrent() + + subscribeToItemsUpdatesRecorder.assertions().isCalledOnce() + } + + @Test + fun `subscribing to pagination updates calls the FFI method and allows retrieving new items`() = runTest { + val subscribeToPaginationUpdatesRecorder = lambdaRecorder { FakeFfiTaskHandle() } + val inner = FakeFfiThreadListService(subscribeToPaginationStateUpdates = subscribeToPaginationUpdatesRecorder) + val service = createThreadsListService(inner = inner) + + service.subscribeToPaginationUpdates().test { + assertThat(awaitItem()).isEqualTo(ThreadListPaginationStatus.Idle(hasMoreToLoad = true)) + + runCurrent() + subscribeToPaginationUpdatesRecorder.assertions().isCalledOnce() + + inner.emitPaginationState(ThreadListPaginationState.Loading) + + assertThat(awaitItem()).isEqualTo(ThreadListPaginationStatus.Loading) + } + } + + @Test + fun `paginate calls the FFI method`() = runTest { + val paginateRecorder = lambdaRecorder {} + val inner = FakeFfiThreadListService(paginate = paginateRecorder) + val service = createThreadsListService(inner = inner) + + service.paginate() + + paginateRecorder.assertions().isCalledOnce() + } + + @Test + fun `reset calls the FFI method`() = runTest { + val resetRecorder = lambdaRecorder {} + val inner = FakeFfiThreadListService(reset = resetRecorder) + val service = createThreadsListService(inner = inner) + + service.reset() + + resetRecorder.assertions().isCalledOnce() + } + + @Test + fun `destroy calls the FFI method`() = runTest { + val destroyRecorder = lambdaRecorder {} + val inner = FakeFfiThreadListService(destroy = destroyRecorder) + val service = createThreadsListService(inner = inner) + + service.destroy() + + destroyRecorder.assertions().isCalledOnce() + } + + private fun TestScope.createThreadsListService( + inner: FakeFfiThreadListService = FakeFfiThreadListService(), + ) = RustThreadsListService( + inner = inner, + roomCoroutineScope = backgroundScope, + ) + + private fun aRustThreadListUpdate() = ThreadListUpdate.Append( + values = listOf( + ThreadListItem( + rootEvent = ThreadListItemEvent( + eventId = AN_EVENT_ID.value, + timestamp = A_TIMESTAMP.toULong(), + sender = A_USER_ID.value, + senderProfile = ProfileDetails.Pending, + isOwn = true, + content = aRustTimelineItemContentMsgLike(), + ), + numReplies = 0u, + latestEvent = null, + ) + ), + ) +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt index a4580334e4..84497b38de 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt @@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService +import io.element.android.libraries.matrix.test.room.threads.FakeThreadsListService import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.simulateLongTask @@ -56,6 +57,7 @@ class FakeJoinedRoom( override val roomNotificationSettingsStateFlow: StateFlow = MutableStateFlow(RoomNotificationSettingsState.Unknown), override val knockRequestsFlow: Flow> = MutableStateFlow(emptyList()), + override val threadsListService: FakeThreadsListService = FakeThreadsListService(), private val roomNotificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), private var createTimelineResult: (CreateTimelineParams) -> Result = { lambdaError() }, private val editMessageLambda: (EventId, String, String?, List) -> Result = { _, _, _, _ -> lambdaError() }, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/threads/FakeThreadsListService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/threads/FakeThreadsListService.kt new file mode 100644 index 0000000000..a1e719ffb2 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/threads/FakeThreadsListService.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.room.threads + +import io.element.android.libraries.matrix.api.room.threads.ThreadListItem +import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus +import io.element.android.libraries.matrix.api.room.threads.ThreadsListService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeThreadsListService( + private val items: MutableStateFlow> = MutableStateFlow(emptyList()), + private val paginationStatus: MutableStateFlow = MutableStateFlow(ThreadListPaginationStatus.Idle(hasMoreToLoad = true)), + private val subscribeToItemUpdates: () -> Flow> = { items }, + private val subscribeToPaginationUpdates: () -> Flow = { paginationStatus }, + private val paginate: suspend () -> Result = { Result.success(Unit) }, + private val reset: suspend () -> Result = { Result.success(Unit) }, + private val destroy: () -> Unit = {}, +) : ThreadsListService { + override fun subscribeToItemUpdates(): Flow> { + return subscribeToItemUpdates.invoke() + } + + override fun subscribeToPaginationUpdates(): Flow { + return subscribeToPaginationUpdates.invoke() + } + + override suspend fun paginate(): Result { + return paginate.invoke() + } + + override suspend fun reset(): Result { + return reset.invoke() + } + + override fun destroy() { + return destroy.invoke() + } + + suspend fun emit(items: List) { + this.items.emit(items) + } +} diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadListItemRow_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadListItemRow_Day_0_en.png new file mode 100644 index 0000000000..ac6836af2b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadListItemRow_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c523f3a502600b837c07ecd5804831da2d9aba5a74886b7001affbb90169112a +size 12455 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadListItemRow_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadListItemRow_Night_0_en.png new file mode 100644 index 0000000000..05f2571669 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadListItemRow_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:528c2f3183a153b9129606806fc457265819ececd71cf5021d1d843970c0b774 +size 12366 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadsListView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadsListView_Day_0_en.png new file mode 100644 index 0000000000..adeb4f7c4d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadsListView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c02999b2d0eba92f1e9b1f5fac52bcff303401f177459d1a439b7db906e5c2a +size 64622 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadsListView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadsListView_Night_0_en.png new file mode 100644 index 0000000000..d89f062c11 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.threads.list_ThreadsListView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a7f6b64bfe0b47546a009efe23060ba091adbad32288ce546cb3b030d7a9ec88 +size 66177 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_MessagesViewTopBar_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_MessagesViewTopBar_Day_0_en.png index 8c023c5e17..b2c131b124 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_MessagesViewTopBar_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_MessagesViewTopBar_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3136c95bc9134eba4cfdfe8d552473a54287c356bd3895291b9cd4ec11969d9c -size 52706 +oid sha256:e26887bd81e10726414e1833029b4b51e22e534684a230777ca50f86024af994 +size 56430 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_MessagesViewTopBar_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_MessagesViewTopBar_Night_0_en.png index 3b3133cf67..41b8b16bcc 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_MessagesViewTopBar_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.topbars_MessagesViewTopBar_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6770e720477c2547f593c626cfe3bdafb9b7c78d0b66e910fb9eb1163730045f -size 51707 +oid sha256:ce44cf850169736008a3f3fc21a2be4fb044badfae631b0284ce379b325879df +size 55533