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:
parent
be775d686e
commit
80470b3792
35 changed files with 1357 additions and 45 deletions
|
|
@ -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<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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ internal fun MessagesViewWithIdentityChangePreview(
|
|||
onCreatePollClick = {},
|
||||
onJoinCallClick = {},
|
||||
onViewAllPinnedMessagesClick = {},
|
||||
knockRequestsBannerView = {}
|
||||
knockRequestsBannerView = {},
|
||||
onThreadsListClick = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -300,6 +300,7 @@ class ThreadedMessagesNode(
|
|||
onViewAllPinnedMessagesClick = {},
|
||||
modifier = modifier,
|
||||
knockRequestsBannerView = {},
|
||||
onThreadsListClick = {},
|
||||
)
|
||||
|
||||
roomMemberModerationRenderer.Render(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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<AvatarData>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Unit> {}
|
||||
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<MessagesEvent>(expectEvents = false)
|
||||
|
|
@ -630,6 +648,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
|
|||
onJoinCallClick = onJoinCallClick,
|
||||
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
|
||||
knockRequestsBannerView = {},
|
||||
onThreadsListClick = onThreadsListClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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<RoomNotificationSettingsState> =
|
||||
MutableStateFlow(RoomNotificationSettingsState.Unknown),
|
||||
override val knockRequestsFlow: Flow<List<KnockRequest>> = MutableStateFlow(emptyList()),
|
||||
override val threadsListService: FakeThreadsListService = FakeThreadsListService(),
|
||||
private val roomNotificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
|
||||
private var createTimelineResult: (CreateTimelineParams) -> Result<Timeline> = { lambdaError() },
|
||||
private val editMessageLambda: (EventId, String, String?, List<IntentionalMention>) -> Result<Unit> = { _, _, _, _ -> lambdaError() },
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c523f3a502600b837c07ecd5804831da2d9aba5a74886b7001affbb90169112a
|
||||
size 12455
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:528c2f3183a153b9129606806fc457265819ececd71cf5021d1d843970c0b774
|
||||
size 12366
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4c02999b2d0eba92f1e9b1f5fac52bcff303401f177459d1a439b7db906e5c2a
|
||||
size 64622
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a7f6b64bfe0b47546a009efe23060ba091adbad32288ce546cb3b030d7a9ec88
|
||||
size 66177
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3136c95bc9134eba4cfdfe8d552473a54287c356bd3895291b9cd4ec11969d9c
|
||||
size 52706
|
||||
oid sha256:e26887bd81e10726414e1833029b4b51e22e534684a230777ca50f86024af994
|
||||
size 56430
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6770e720477c2547f593c626cfe3bdafb9b7c78d0b66e910fb9eb1163730045f
|
||||
size 51707
|
||||
oid sha256:ce44cf850169736008a3f3fc21a2be4fb044badfae631b0284ce379b325879df
|
||||
size 55533
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue