Merge branch 'develop' into feature-oled-black
This commit is contained in:
commit
4e5542396f
319 changed files with 8286 additions and 2172 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,
|
||||
)
|
||||
|
|
@ -84,6 +84,7 @@ import kotlinx.coroutines.flow.collectLatest
|
|||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.transform
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
|
@ -262,11 +263,16 @@ private fun TimelinePrefetchingHelper(
|
|||
firstVisibleItemIndex + layoutInfo.visibleItemsInfo.size >= layoutInfo.totalItemsCount - 40
|
||||
}
|
||||
|
||||
// If we have no timeline items, we need to back paginate to load some messages. This usually happens on all timelines except for live ones.
|
||||
// This automatic pagination was previously done by the SDK, and we received a `Reset` update, but now we need to do it ourselves.
|
||||
val isEmptyTimelineFlow = layoutInfoFlow.map { it.totalItemsCount == 0 }
|
||||
|
||||
combine(
|
||||
isCloseToStartOfLoadedTimelineFlow.distinctUntilChanged(),
|
||||
isScrollingFlow.distinctUntilChanged(),
|
||||
) { needsPrefetch, isScrolling ->
|
||||
needsPrefetch && isScrolling
|
||||
isEmptyTimelineFlow,
|
||||
) { needsPrefetch, isScrolling, isEmptyAndNeedsBackPagination ->
|
||||
isEmptyAndNeedsBackPagination || needsPrefetch && isScrolling
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { needsPrefetch ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -521,6 +522,9 @@ class MessagesViewTest {
|
|||
rule.setMessagesView(
|
||||
state = stateWithActionListState,
|
||||
)
|
||||
// Clear initial 'LoadMore' event emitted when setting the state
|
||||
eventsRecorder.clear()
|
||||
|
||||
val verifiedUserSendFailure = rule.activity.getString(CommonStrings.screen_timeline_item_menu_send_failure_changed_identity, "Alice")
|
||||
rule.onNodeWithText(verifiedUserSendFailure).performClick()
|
||||
// Give time for the close animation to complete
|
||||
|
|
@ -584,6 +588,9 @@ class MessagesViewTest {
|
|||
),
|
||||
)
|
||||
rule.setMessagesView(state = state)
|
||||
// Clear initial 'LoadMore' event emitted when setting the state
|
||||
eventsRecorder.clear()
|
||||
|
||||
rule.onNodeWithText("This is a pinned message").performClick()
|
||||
eventsRecorder.assertSingle(TimelineEvent.FocusOnEvent(AN_EVENT_ID, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds))
|
||||
}
|
||||
|
|
@ -600,12 +607,32 @@ class MessagesViewTest {
|
|||
timelineState = aTimelineState(eventSink = eventsRecorder)
|
||||
)
|
||||
rule.setMessagesView(state = state)
|
||||
// Clear initial 'LoadMore' event emitted when setting the state
|
||||
eventsRecorder.clear()
|
||||
|
||||
val text = rule.activity.getString(R.string.screen_room_timeline_tombstoned_room_action)
|
||||
// The bottomsheet subcompose seems to make the node to appear twice
|
||||
rule.onAllNodesWithText(text).onFirst().performClick()
|
||||
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 +657,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 +674,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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -67,24 +67,31 @@ class TimelineViewTest {
|
|||
|
||||
@Test
|
||||
fun `reaching the end of the timeline does not send a LoadMore event`() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>(expectEvents = false)
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
rule.setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scroll to bottom on live timeline does not emit the Event`() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>(expectEvents = false)
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
rule.setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
|
||||
isLive = true,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
forceJumpToBottomVisibility = true,
|
||||
)
|
||||
|
||||
eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0))
|
||||
eventsRecorder.clear()
|
||||
|
||||
val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom)
|
||||
rule.onNodeWithContentDescription(contentDescription).performClick()
|
||||
}
|
||||
|
|
@ -94,15 +101,33 @@ class TimelineViewTest {
|
|||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
rule.setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
|
||||
isLive = false,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0))
|
||||
eventsRecorder.clear()
|
||||
|
||||
val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom)
|
||||
rule.onNodeWithContentDescription(contentDescription).performClick()
|
||||
eventsRecorder.assertSingle(TimelineEvent.JumpToLive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty timeline triggers a prefetch`() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
rule.setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
|
||||
eventsRecorder.assertSingle(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show shield dialog`() {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
|
|
@ -133,11 +158,15 @@ class TimelineViewTest {
|
|||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
rule.setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
|
||||
isLive = false,
|
||||
eventSink = eventsRecorder,
|
||||
messageShield = aCriticalShield(),
|
||||
),
|
||||
)
|
||||
eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0))
|
||||
eventsRecorder.clear()
|
||||
|
||||
rule.clickOn(CommonStrings.action_ok)
|
||||
eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue