Feature: add room threads list (#6575)

Add threads list screen for rooms:

- Add `ThreadsListService` to subscribe to thread changes in the room.
- Create `ThreadsListView` and its associated node a presenters (the UI may change).
- Add a menu icon in the room screen to open it.

This is still pending info about unread threads, so several UI components related to it will be hidden.

* Add feature flag and use it to hide the access to this new screen

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa 2026-04-15 14:14:22 +02:00 committed by GitHub
parent 6a57220d5c
commit 15419bb675
35 changed files with 1357 additions and 45 deletions

View file

@ -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.pinned.list.PinnedMessagesListNode
import io.element.android.features.messages.impl.report.ReportMessageNode 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.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.TimelineController
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
@ -179,6 +180,9 @@ class MessagesFlowNode(
@Parcelize @Parcelize
data class Thread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget data class Thread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget
@Parcelize
data object ThreadsList : NavTarget
} }
private val callback: MessagesEntryPoint.Callback = callback() private val callback: MessagesEntryPoint.Callback = callback()
@ -294,6 +298,10 @@ class MessagesFlowNode(
backstack.push(NavTarget.Thread(threadRootId, focusedEventId)) backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
} }
override fun navigateToThreadsList() {
backstack.push(NavTarget.ThreadsList)
}
override fun navigateToDeveloperSettings() { override fun navigateToDeveloperSettings() {
callback.navigateToDeveloperSettings() callback.navigateToDeveloperSettings()
} }
@ -517,6 +525,14 @@ class MessagesFlowNode(
} }
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback)) createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
} }
NavTarget.ThreadsList -> {
val callback = object : ThreadsListNode.Callback {
override fun openThread(threadId: ThreadId) {
backstack.push(NavTarget.Thread(threadId, focusedEventId = null))
}
}
createNode<ThreadsListNode>(buildContext, listOf(callback))
}
} }
} }

View file

@ -131,6 +131,8 @@ class MessagesNode(
fun navigateToPinnedMessagesList() fun navigateToPinnedMessagesList()
fun navigateToKnockRequestsList() fun navigateToKnockRequestsList()
fun navigateToDeveloperSettings() fun navigateToDeveloperSettings()
fun navigateToThreadsList()
} }
override fun onBuilt() { override fun onBuilt() {
@ -299,6 +301,7 @@ class MessagesNode(
onViewRequestsClick = callback::navigateToKnockRequestsList, onViewRequestsClick = callback::navigateToKnockRequestsList,
) )
}, },
onThreadsListClick = callback::navigateToThreadsList,
) )
roomMemberModerationRenderer.Render( roomMemberModerationRenderer.Render(
state = state.roomMemberModerationState, state = state.roomMemberModerationState,

View file

@ -16,6 +16,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
@ -27,6 +28,7 @@ import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.PinUnpinAction import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.appconfig.MessageComposerConfig import io.element.android.appconfig.MessageComposerConfig
import io.element.android.features.messages.api.timeline.HtmlConverterProvider 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.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState 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.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
@ -160,6 +165,13 @@ class MessagesPresenter(
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present() val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
val roomCallState = roomCallStatePresenter.present() val roomCallState = roomCallStatePresenter.present()
val roomMemberModerationState = roomMemberModerationPresenter.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 -> val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
perms.userEventPermissions() perms.userEventPermissions()
@ -294,6 +306,11 @@ class MessagesPresenter(
roomMemberModerationState = roomMemberModerationState, roomMemberModerationState = roomMemberModerationState,
topBarSharedHistoryIcon = topBarSharedHistoryIcon, topBarSharedHistoryIcon = topBarSharedHistoryIcon,
successorRoom = roomInfo.successorRoom, 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, eventSink = ::handleEvent,
) )
} }

View file

@ -57,9 +57,15 @@ data class MessagesState(
/** Type of "shared history" icon to show in the top bar. */ /** Type of "shared history" icon to show in the top bar. */
val topBarSharedHistoryIcon: SharedHistoryIcon, val topBarSharedHistoryIcon: SharedHistoryIcon,
val successorRoom: SuccessorRoom?, val successorRoom: SuccessorRoom?,
val threads: Threads,
val eventSink: (MessagesEvent) -> Unit val eventSink: (MessagesEvent) -> Unit
) { ) {
val isTombstoned = successorRoom != null val isTombstoned = successorRoom != null
data class Threads(
val hasThreads: Boolean,
val hasUnreadThreads: Boolean,
)
} }
/** Type of "shared history" icon to show in the top bar. */ /** Type of "shared history" icon to show in the top bar. */

View file

@ -122,6 +122,10 @@ fun aMessagesState(
roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(), roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(),
topBarSharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE, topBarSharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE,
successorRoom: SuccessorRoom? = null, successorRoom: SuccessorRoom? = null,
threads: MessagesState.Threads = MessagesState.Threads(
hasThreads = false,
hasUnreadThreads = false,
),
eventSink: (MessagesEvent) -> Unit = {}, eventSink: (MessagesEvent) -> Unit = {},
) = MessagesState( ) = MessagesState(
roomId = RoomId("!id:domain"), roomId = RoomId("!id:domain"),
@ -150,6 +154,7 @@ fun aMessagesState(
roomMemberModerationState = roomMemberModerationState, roomMemberModerationState = roomMemberModerationState,
topBarSharedHistoryIcon = topBarSharedHistoryIcon, topBarSharedHistoryIcon = topBarSharedHistoryIcon,
successorRoom = successorRoom, successorRoom = successorRoom,
threads = threads,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -12,10 +12,12 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize 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.padding
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect 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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme 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.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
import io.element.android.features.messages.impl.actionlist.ActionListEvent import io.element.android.features.messages.impl.actionlist.ActionListEvent
import io.element.android.features.messages.impl.actionlist.ActionListView 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.aTimelineItemDaySeparator
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent 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.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.CustomReactionBottomSheet
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent 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.topbars.ThreadTopBar
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog 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.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.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule
import io.element.android.libraries.designsystem.components.ExpandableBottomSheetLayout 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.toAnnotatedString
import io.element.android.libraries.designsystem.text.toDp 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.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.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.HideKeyboardWhenDisposed import io.element.android.libraries.designsystem.utils.HideKeyboardWhenDisposed
@ -133,6 +140,7 @@ fun MessagesView(
onCreatePollClick: () -> Unit, onCreatePollClick: () -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit, onJoinCallClick: (isAudioCall: Boolean) -> Unit,
onViewAllPinnedMessagesClick: () -> Unit, onViewAllPinnedMessagesClick: () -> Unit,
onThreadsListClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
forceJumpToBottomVisibility: Boolean = false, forceJumpToBottomVisibility: Boolean = false,
knockRequestsBannerView: @Composable () -> Unit, knockRequestsBannerView: @Composable () -> Unit,
@ -224,12 +232,18 @@ fun MessagesView(
roomAvatar = state.roomAvatar, roomAvatar = state.roomAvatar,
isTombstoned = state.isTombstoned, isTombstoned = state.isTombstoned,
heroes = state.heroes, heroes = state.heroes,
roomCallState = state.roomCallState,
dmUserIdentityState = state.dmUserVerificationState, dmUserIdentityState = state.dmUserVerificationState,
sharedHistoryIcon = state.topBarSharedHistoryIcon, sharedHistoryIcon = state.topBarSharedHistoryIcon,
onBackClick = { hidingKeyboard { onBackClick() } }, onBackClick = { hidingKeyboard { onBackClick() } },
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } }, 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 @Composable
private fun ReinviteDialog(state: MessagesState) { private fun ReinviteDialog(state: MessagesState) {
if (state.showReinvitePrompt) { if (state.showReinvitePrompt) {
@ -601,6 +637,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
onViewAllPinnedMessagesClick = { }, onViewAllPinnedMessagesClick = { },
forceJumpToBottomVisibility = true, forceJumpToBottomVisibility = true,
knockRequestsBannerView = {}, knockRequestsBannerView = {},
onThreadsListClick = {},
) )
} }
@ -652,7 +689,8 @@ internal fun MessagesViewA11yPreview() = ElementPreview {
onSendLocationClick = {}, onSendLocationClick = {},
onCreatePollClick = {}, onCreatePollClick = {},
onJoinCallClick = {}, onJoinCallClick = {},
onViewAllPinnedMessagesClick = { }, onViewAllPinnedMessagesClick = {},
onThreadsListClick = {},
forceJumpToBottomVisibility = true, forceJumpToBottomVisibility = true,
knockRequestsBannerView = {}, knockRequestsBannerView = {},
) )

View file

@ -41,6 +41,7 @@ internal fun MessagesViewWithIdentityChangePreview(
onCreatePollClick = {}, onCreatePollClick = {},
onJoinCallClick = {}, onJoinCallClick = {},
onViewAllPinnedMessagesClick = {}, onViewAllPinnedMessagesClick = {},
knockRequestsBannerView = {} knockRequestsBannerView = {},
onThreadsListClick = {},
) )
} }

View file

@ -300,6 +300,7 @@ class ThreadedMessagesNode(
onViewAllPinnedMessagesClick = {}, onViewAllPinnedMessagesClick = {},
modifier = modifier, modifier = modifier,
knockRequestsBannerView = {}, knockRequestsBannerView = {},
onThreadsListClick = {},
) )
roomMemberModerationRenderer.Render( roomMemberModerationRenderer.Render(

View file

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

View file

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

View file

@ -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<ThreadsListState> {
@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<ThreadListPaginationStatus>(
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<ThreadListRowItem>,
val eventSink: (ThreadsListEvents) -> Unit,
)
sealed interface ThreadsListEvents {
data object Paginate : ThreadsListEvents
}

View file

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

View file

@ -12,10 +12,9 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.WindowInsets
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -30,8 +29,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons 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.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.RoomCallState
import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roomcall.api.anOngoingCallState import io.element.android.features.roomcall.api.anOngoingCallState
@ -62,13 +61,12 @@ internal fun MessagesViewTopBar(
roomAvatar: AvatarData, roomAvatar: AvatarData,
isTombstoned: Boolean, isTombstoned: Boolean,
heroes: ImmutableList<AvatarData>, heroes: ImmutableList<AvatarData>,
roomCallState: RoomCallState,
dmUserIdentityState: IdentityState?, dmUserIdentityState: IdentityState?,
sharedHistoryIcon: SharedHistoryIcon, sharedHistoryIcon: SharedHistoryIcon,
onRoomDetailsClick: () -> Unit, onRoomDetailsClick: () -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
menuActions: @Composable RowScope.() -> Unit,
) { ) {
TopAppBar( TopAppBar(
modifier = modifier, modifier = modifier,
@ -126,13 +124,7 @@ internal fun MessagesViewTopBar(
} }
} }
}, },
actions = { actions = menuActions,
CallMenuItem(
roomCallState = roomCallState,
onJoinCallClick = onJoinCallClick,
)
Spacer(Modifier.width(8.dp))
},
windowInsets = WindowInsets(0.dp) windowInsets = WindowInsets(0.dp)
) )
} }
@ -186,17 +178,24 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
roomCallState: RoomCallState = RoomCallState.Unavailable, roomCallState: RoomCallState = RoomCallState.Unavailable,
dmUserIdentityState: IdentityState? = null, dmUserIdentityState: IdentityState? = null,
sharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE, sharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE,
displayThreads: Boolean = false,
) = MessagesViewTopBar( ) = MessagesViewTopBar(
roomName = roomName, roomName = roomName,
roomAvatar = roomAvatar, roomAvatar = roomAvatar,
isTombstoned = isTombstoned, isTombstoned = isTombstoned,
heroes = heroes, heroes = heroes,
roomCallState = roomCallState,
dmUserIdentityState = dmUserIdentityState, dmUserIdentityState = dmUserIdentityState,
sharedHistoryIcon = sharedHistoryIcon, sharedHistoryIcon = sharedHistoryIcon,
onRoomDetailsClick = {}, onRoomDetailsClick = {},
onJoinCallClick = {},
onBackClick = {}, onBackClick = {},
menuActions = {
MessagesMenuActions(
roomCallState = roomCallState,
displayThreads = displayThreads,
onJoinCallClick = {},
onThreadsListClick = {},
)
}
) )
Column { Column {
AMessagesViewTopBar() AMessagesViewTopBar()
@ -237,5 +236,9 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
roomName = "A room with world_readable history", roomName = "A room with world_readable history",
sharedHistoryIcon = SharedHistoryIcon.WORLD_READABLE, sharedHistoryIcon = SharedHistoryIcon.WORLD_READABLE,
) )
HorizontalDivider()
AMessagesViewTopBar(
displayThreads = true,
)
} }
} }

View file

@ -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.MessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState 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.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.FakeMarkAsFullyRead
import io.element.android.features.messages.impl.timeline.MarkAsFullyRead import io.element.android.features.messages.impl.timeline.MarkAsFullyRead
import io.element.android.features.messages.impl.timeline.TimelineController 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.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember 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.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.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aTimelineItemDebugInfo import io.element.android.libraries.matrix.test.timeline.aTimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails 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.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent 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( private fun roomPermissions(
canStartCall: Boolean = true, canStartCall: Boolean = true,
canRedactOther: Boolean = true, canRedactOther: Boolean = true,

View file

@ -73,6 +73,7 @@ import io.element.android.tests.testutils.assertNoNodeWithText
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam 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.pressBack
import io.element.android.tests.testutils.setSafeContent import io.element.android.tests.testutils.setSafeContent
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -606,6 +607,23 @@ class MessagesViewTest {
eventsRecorder.assertSingle(TimelineEvent.NavigateToPredecessorOrSuccessorRoom(successorRoomId)) 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<Unit> {}
rule.setMessagesView(
state = state,
onThreadsListClicked = onThreadsListClicked,
)
rule.onNodeWithContentDescription("Threads").performClick()
onThreadsListClicked.assertions().isCalledOnce()
}
@Test @Test
fun `no banner shown when there is no successor room`() { fun `no banner shown when there is no successor room`() {
val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
@ -630,6 +648,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
onCreatePollClick: () -> Unit = EnsureNeverCalled(), onCreatePollClick: () -> Unit = EnsureNeverCalled(),
onJoinCallClick: (Boolean) -> Unit = EnsureNeverCalledWithParam(), onJoinCallClick: (Boolean) -> Unit = EnsureNeverCalledWithParam(),
onViewAllPinnedMessagesClick: () -> Unit = EnsureNeverCalled(), onViewAllPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
onThreadsListClicked: () -> Unit = EnsureNeverCalled(),
) { ) {
setSafeContent { setSafeContent {
// Cannot use the RichTextEditor, so simulate a LocalInspectionMode // Cannot use the RichTextEditor, so simulate a LocalInspectionMode
@ -646,6 +665,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
onJoinCallClick = onJoinCallClick, onJoinCallClick = onJoinCallClick,
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick, onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
knockRequestsBannerView = {}, knockRequestsBannerView = {},
onThreadsListClick = onThreadsListClicked,
) )
} }
} }

View file

@ -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( internal fun TestScope.aTimelineItemsFactory(
config: TimelineItemsFactoryConfig, config: TimelineItemsFactoryConfig,
): TimelineItemsFactory { ): TimelineItemsFactory {
val timelineEventFormatter = aTimelineEventFormatter()
val matrixClient = FakeMatrixClient() val matrixClient = FakeMatrixClient()
return TimelineItemsFactory( return TimelineItemsFactory(
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
eventItemFactoryCreator = object : TimelineItemEventFactory.Creator { eventItemFactoryCreator = object : TimelineItemEventFactory.Creator {
override fun create(config: TimelineItemsFactoryConfig): TimelineItemEventFactory { override fun create(config: TimelineItemsFactoryConfig): TimelineItemEventFactory {
return TimelineItemEventFactory( return TimelineItemEventFactory(
contentFactory = TimelineItemContentFactory( contentFactory = aTimelineItemContentFactory(matrixClient = matrixClient),
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,
),
matrixClient = matrixClient, matrixClient = matrixClient,
dateFormatter = FakeDateFormatter(), dateFormatter = FakeDateFormatter(),
permalinkParser = FakePermalinkParser(), permalinkParser = FakePermalinkParser(),

View file

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

View file

@ -13,10 +13,12 @@ import androidx.compose.ui.unit.dp
enum class AvatarSize(val dp: Dp) { enum class AvatarSize(val dp: Dp) {
CurrentUserTopBar(32.dp), CurrentUserTopBar(32.dp),
CurrentRoomTopBar(32.dp),
IncomingCall(140.dp), IncomingCall(140.dp),
RoomDetailsHeader(96.dp), RoomDetailsHeader(96.dp),
RoomListItem(52.dp), RoomListItem(52.dp),
ThreadsListItem(52.dp),
SpaceListItem(52.dp), SpaceListItem(52.dp),

View file

@ -155,4 +155,11 @@ enum class FeatureFlags(
defaultValue = { false }, defaultValue = { false },
isFinished = 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,
),
} }

View file

@ -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.location.LiveLocationShare
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues 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.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.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.timeline.Timeline 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.MatrixWidgetDriver
@ -44,6 +45,8 @@ interface JoinedRoom : BaseRoom {
*/ */
val liveTimeline: Timeline val liveTimeline: Timeline
val threadsListService: ThreadsListService
/** /**
* Create a new timeline. * Create a new timeline.
* @param createTimelineParams contains parameters about how to filter the timeline. Will also configure the date separators. * @param createTimelineParams contains parameters about how to filter the timeline. Will also configure the date separators.

View file

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

View file

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

View file

@ -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<List<ThreadListItem>>
fun subscribeToPaginationUpdates(): Flow<ThreadListPaginationStatus>
suspend fun paginate(): Result<Unit>
suspend fun reset(): Result<Unit>
fun destroy()
}

View file

@ -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.RoomPowerLevelsValues
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange 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.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.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.timeline.Timeline 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.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.join.map
import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest 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.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.roomdirectory.map
import io.element.android.libraries.matrix.impl.timeline.RustTimeline 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.MessageEventContent
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver 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 liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.Live)
override val threadsListService: ThreadsListService = RustThreadsListService(
inner = innerRoom.threadListService(),
contentMapper = TimelineEventContentMapper(),
roomCoroutineScope = roomCoroutineScope,
)
override val syncUpdateFlow = flow { override val syncUpdateFlow = flow {
var counter = 0L var counter = 0L
liveTimeline.onSyncedEventReceived.collect { liveTimeline.onSyncedEventReceived.collect {
@ -528,6 +537,7 @@ class JoinedRustRoom(
override fun destroy() { override fun destroy() {
baseRoom.destroy() baseRoom.destroy()
liveInnerTimeline.destroy() liveInnerTimeline.destroy()
threadsListService.destroy()
Timber.d("Room $roomId destroyed") Timber.d("Room $roomId destroyed")
} }

View file

@ -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<List<ThreadListItem>>(emptyList())
override fun subscribeToItemUpdates(): Flow<List<ThreadListItem>> {
if (itemSubscriptionJob?.isActive != true) {
itemSubscriptionJob = doSubscribeToItemUpdates()
}
return items
}
private fun doSubscribeToItemUpdates(): Job {
val updatesFlow = mxCallbackFlow {
inner.subscribeToItemsUpdates(object : ThreadListEntriesListener {
override fun onUpdate(diff: List<ThreadListUpdate>) {
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<ThreadListPaginationStatus> {
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<Unit> = runCatchingExceptions {
inner.paginate()
}
override suspend fun reset(): Result<Unit> = runCatchingExceptions {
inner.reset()
}
override fun destroy() {
itemSubscriptionJob?.cancel()
inner.destroy()
}
}
private fun MutableList<ThreadListItem>.apply(
diff: List<ThreadListUpdate>,
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
}

View file

@ -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<ThreadListItem> = { 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<ThreadListItem> = 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<ThreadListUpdate>) {
itemsListener?.onUpdate(updates)
}
fun emitPaginationState(state: ThreadListPaginationState) {
paginationStateListener?.onUpdate(state)
}
}

View file

@ -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<ThreadListEntriesListener, TaskHandle> { 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<ThreadListEntriesListener, TaskHandle> { 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<ThreadListPaginationStateListener, TaskHandle> { 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<Unit> {}
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<Unit> {}
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<Unit> {}
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,
)
),
)
}

View file

@ -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.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings 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.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.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask import io.element.android.tests.testutils.simulateLongTask
@ -56,6 +57,7 @@ class FakeJoinedRoom(
override val roomNotificationSettingsStateFlow: StateFlow<RoomNotificationSettingsState> = override val roomNotificationSettingsStateFlow: StateFlow<RoomNotificationSettingsState> =
MutableStateFlow(RoomNotificationSettingsState.Unknown), MutableStateFlow(RoomNotificationSettingsState.Unknown),
override val knockRequestsFlow: Flow<List<KnockRequest>> = MutableStateFlow(emptyList()), override val knockRequestsFlow: Flow<List<KnockRequest>> = MutableStateFlow(emptyList()),
override val threadsListService: FakeThreadsListService = FakeThreadsListService(),
private val roomNotificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), private val roomNotificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
private var createTimelineResult: (CreateTimelineParams) -> Result<Timeline> = { lambdaError() }, private var createTimelineResult: (CreateTimelineParams) -> Result<Timeline> = { lambdaError() },
private val editMessageLambda: (EventId, String, String?, List<IntentionalMention>) -> Result<Unit> = { _, _, _, _ -> lambdaError() }, private val editMessageLambda: (EventId, String, String?, List<IntentionalMention>) -> Result<Unit> = { _, _, _, _ -> lambdaError() },

View file

@ -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<List<ThreadListItem>> = MutableStateFlow(emptyList()),
private val paginationStatus: MutableStateFlow<ThreadListPaginationStatus> = MutableStateFlow(ThreadListPaginationStatus.Idle(hasMoreToLoad = true)),
private val subscribeToItemUpdates: () -> Flow<List<ThreadListItem>> = { items },
private val subscribeToPaginationUpdates: () -> Flow<ThreadListPaginationStatus> = { paginationStatus },
private val paginate: suspend () -> Result<Unit> = { Result.success(Unit) },
private val reset: suspend () -> Result<Unit> = { Result.success(Unit) },
private val destroy: () -> Unit = {},
) : ThreadsListService {
override fun subscribeToItemUpdates(): Flow<List<ThreadListItem>> {
return subscribeToItemUpdates.invoke()
}
override fun subscribeToPaginationUpdates(): Flow<ThreadListPaginationStatus> {
return subscribeToPaginationUpdates.invoke()
}
override suspend fun paginate(): Result<Unit> {
return paginate.invoke()
}
override suspend fun reset(): Result<Unit> {
return reset.invoke()
}
override fun destroy() {
return destroy.invoke()
}
suspend fun emit(items: List<ThreadListItem>) {
this.items.emit(items)
}
}

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c523f3a502600b837c07ecd5804831da2d9aba5a74886b7001affbb90169112a
size 12455

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:528c2f3183a153b9129606806fc457265819ececd71cf5021d1d843970c0b774
size 12366

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4c02999b2d0eba92f1e9b1f5fac52bcff303401f177459d1a439b7db906e5c2a
size 64622

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a7f6b64bfe0b47546a009efe23060ba091adbad32288ce546cb3b030d7a9ec88
size 66177

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3136c95bc9134eba4cfdfe8d552473a54287c356bd3895291b9cd4ec11969d9c oid sha256:e26887bd81e10726414e1833029b4b51e22e534684a230777ca50f86024af994
size 52706 size 56430

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6770e720477c2547f593c626cfe3bdafb9b7c78d0b66e910fb9eb1163730045f oid sha256:ce44cf850169736008a3f3fc21a2be4fb044badfae631b0284ce379b325879df
size 51707 size 55533