Merge branch 'develop' into feature-oled-black

This commit is contained in:
Benoit Marty 2026-04-17 14:47:15 +02:00 committed by GitHub
commit 6d4258e248
319 changed files with 8286 additions and 2172 deletions

View file

@ -39,6 +39,7 @@ import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimel
import io.element.android.features.messages.impl.pinned.list.PinnedMessagesListNode
import io.element.android.features.messages.impl.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))
}
}
}

View file

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

View file

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

View file

@ -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. */

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,17 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.threads.list
import io.element.android.libraries.matrix.api.room.threads.ThreadListItem
data class ThreadListRowItem(
val item: ThreadListItem,
val rootEventText: String?,
val latestEventText: String?,
val formattedTimestamp: String,
)

View file

@ -0,0 +1,44 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.threads.list
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.ThreadId
@ContributesNode(RoomScope::class)
@AssistedInject
class ThreadsListNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: ThreadsListPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun openThread(threadId: ThreadId)
}
private val callback: Callback = callback()
@Composable
override fun View(modifier: Modifier) {
ThreadsListView(
state = presenter.present(),
modifier = modifier,
onThreadClick = callback::openThread,
onBackClick = this::navigateUp,
)
}
}

View file

@ -0,0 +1,141 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.threads.list
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFactory
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import timber.log.Timber
@Inject
class ThreadsListPresenter(
private val room: JoinedRoom,
private val timelineItemContentFactory: TimelineItemContentFactory,
private val messageSummaryFormatter: MessageSummaryFormatter,
private val dateFormatter: DateFormatter,
) : Presenter<ThreadsListState> {
@Composable
override fun present(): ThreadsListState {
val coroutineScope = rememberCoroutineScope()
val threadsListService = room.threadsListService
val threads by produceState(initialValue = persistentListOf(), key1 = threadsListService) {
threadsListService.subscribeToItemUpdates()
.onStart { threadsListService.paginate() }
.collect { items ->
Timber.d("Received thread list update with ${items.size} items")
value = items.map { item ->
val rootTimelineEvent = item.rootEvent.content?.let {
timelineItemContentFactory.create(
itemContent = it,
eventId = item.rootEvent.eventId,
isEditable = false,
sender = item.rootEvent.senderId,
senderProfile = item.rootEvent.senderProfile,
)
}
val rootEventText = rootTimelineEvent?.let { messageSummaryFormatter.format(it) }
val latestTimelineEvent = item.latestEvent?.content?.let {
timelineItemContentFactory.create(
itemContent = it,
eventId = item.latestEvent!!.eventId,
isEditable = false,
sender = item.latestEvent!!.senderId,
senderProfile = item.latestEvent!!.senderProfile,
)
}
val latestEventText = latestTimelineEvent?.let { messageSummaryFormatter.format(it) }
val formattedTimestamp = dateFormatter.format(
timestamp = item.latestEvent?.timestamp ?: item.rootEvent.timestamp,
mode = DateFormatterMode.TimeOrDate,
useRelative = true,
)
ThreadListRowItem(
item = item,
rootEventText = rootEventText,
latestEventText = latestEventText,
formattedTimestamp = formattedTimestamp,
)
}.toImmutableList()
}
}
val paginationStatus by produceState<ThreadListPaginationStatus>(
initialValue = ThreadListPaginationStatus.Idle(hasMoreToLoad = true),
key1 = threadsListService
) {
threadsListService
.subscribeToPaginationUpdates()
.collect { value = it }
}
val roomInfo by room.roomInfoFlow.collectAsState()
DisposableEffect(Unit) {
onDispose {
threadsListService.destroy()
}
}
fun handleEvent(event: ThreadsListEvents) {
when (event) {
ThreadsListEvents.Paginate -> if ((paginationStatus as? ThreadListPaginationStatus.Idle)?.hasMoreToLoad == true) {
coroutineScope.launch {
Timber.d("Paginating thread list: $paginationStatus")
threadsListService.paginate()
}
} else {
Timber.d("Not paginating since there is nothing else to load, current status: $paginationStatus")
}
}
}
return ThreadsListState(
threads = threads,
roomId = room.roomId,
roomName = roomInfo.name ?: room.roomId.value,
roomAvatarUrl = roomInfo.avatarUrl,
isRoomTombstoned = roomInfo.successorRoom != null,
eventSink = ::handleEvent,
)
}
}
data class ThreadsListState(
val roomId: RoomId,
val roomName: String,
val roomAvatarUrl: String?,
val isRoomTombstoned: Boolean,
val threads: ImmutableList<ThreadListRowItem>,
val eventSink: (ThreadsListEvents) -> Unit,
)
sealed interface ThreadsListEvents {
data object Paginate : ThreadsListEvents
}

View file

@ -0,0 +1,380 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.threads.list
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.asEventId
import io.element.android.libraries.matrix.api.room.threads.ThreadListItem
import io.element.android.libraries.matrix.api.room.threads.ThreadListItemEvent
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ThreadsListView(
state: ThreadsListState,
onThreadClick: (ThreadId) -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Avatar(
avatarData = AvatarData(
id = state.roomId.value,
name = state.roomName,
url = state.roomAvatarUrl,
size = AvatarSize.CurrentUserTopBar,
),
avatarType = AvatarType.Room(isTombstoned = state.isRoomTombstoned),
contentDescription = null,
)
Column {
Text(
text = stringResource(CommonStrings.common_threads),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = state.roomName,
style = ElementTheme.typography.fontBodyXsRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
},
navigationIcon = {
BackButton(onBackClick)
}
)
}
) { padding ->
val lazyListState = rememberLazyListState()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = padding,
state = lazyListState,
) {
itemsIndexed(state.threads, key = { _, row -> row.item.threadId }) { index, row ->
ThreadListItemRow(
threadItem = row,
onClick = onThreadClick,
)
if (index < state.threads.size - 1) {
HorizontalDivider()
}
}
}
ScrollHelper(lazyListState) {
state.eventSink(ThreadsListEvents.Paginate)
}
}
}
@Composable
private fun ScrollHelper(
listState: LazyListState,
onPaginate: () -> Unit,
) {
val lastVisibleItemIndex by remember {
derivedStateOf { listState.firstVisibleItemIndex + listState.layoutInfo.visibleItemsInfo.size - 1 }
}
val needsPagination by remember {
derivedStateOf {
val canLoadNewItems = listState.isScrollInProgress || listState.firstVisibleItemScrollOffset == 0
canLoadNewItems && lastVisibleItemIndex == listState.layoutInfo.totalItemsCount - 1
}
}
LaunchedEffect(needsPagination, lastVisibleItemIndex) {
if (needsPagination) {
onPaginate()
delay(400L)
}
}
}
@Composable
private fun ThreadListItemRow(
threadItem: ThreadListRowItem,
onClick: (ThreadId) -> Unit,
) {
Row(
modifier = Modifier
.clickable { onClick(threadItem.item.threadId) }
.fillMaxWidth()
.padding(top = 4.dp, bottom = 8.dp, start = 16.dp, end = 16.dp),
) {
val rootEvent = threadItem.item.rootEvent
val senderProfile = rootEvent.senderProfile
Avatar(
modifier = Modifier.align(Alignment.CenterVertically),
avatarData = AvatarData(
id = rootEvent.senderId.value,
name = senderProfile.getDisambiguatedDisplayName(rootEvent.senderId),
url = senderProfile.getAvatarUrl(),
size = AvatarSize.ThreadsListItem,
),
avatarType = AvatarType.User,
contentDescription = null,
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.fillMaxWidth()) {
// TODO actually compute these values based on the thread state (not available yet)
val hasMentions = false
val hasUnreadNotifications = false
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier.weight(1f),
text = senderProfile.getDisambiguatedDisplayName(rootEvent.senderId),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = threadItem.formattedTimestamp,
style = ElementTheme.typography.fontBodySmRegular,
color = if (hasUnreadNotifications || hasMentions) ElementTheme.colors.textActionAccent else ElementTheme.colors.textSecondary,
)
}
Spacer(modifier = Modifier.height(2.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(1f),
text = threadItem.rootEventText.orEmpty(),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(7.dp)
) {
if (hasMentions) {
Icon(
modifier = Modifier.size(14.dp),
imageVector = CompoundIcons.Mention(),
contentDescription = null,
tint = ElementTheme.colors.textActionAccent,
)
}
UnreadIndicatorAtom(
size = 14.dp,
isVisible = hasUnreadNotifications,
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "${threadItem.item.numberOfReplies}",
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.width(4.dp))
Icon(
modifier = Modifier.size(20.dp),
imageVector = CompoundIcons.ThreadsSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary
)
Spacer(modifier = Modifier.width(8.dp))
threadItem.item.latestEvent?.let { latestEvent ->
Avatar(
avatarData = AvatarData(
id = latestEvent.senderId.value,
name = latestEvent.senderProfile.getDisambiguatedDisplayName(latestEvent.senderId),
url = latestEvent.senderProfile.getAvatarUrl(),
size = AvatarSize.TimelineThreadLatestEventSender,
),
avatarType = AvatarType.User,
contentDescription = null,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = threadItem.latestEventText.orEmpty(),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
@PreviewsDayNight
@Composable
internal fun ThreadsListViewPreview() {
ElementPreview {
ThreadsListView(
state = ThreadsListState(
roomId = RoomId("!room-id:server"),
roomName = "Room name",
roomAvatarUrl = null,
threads = List(10) { aThreadListRowItem(threadId = ThreadId("\$thread-$it")) }.toImmutableList(),
isRoomTombstoned = false,
eventSink = {},
),
onThreadClick = {},
onBackClick = {},
)
}
}
@PreviewsDayNight
@Composable
internal fun ThreadListItemRowPreview() {
ElementPreview {
ThreadListItemRow(
threadItem = aThreadListRowItem(),
onClick = {},
)
}
}
fun aThreadListRowItem(
threadId: ThreadId = ThreadId("\$a-thread-id"),
rootEvent: ThreadListItemEvent = aThreadListItemEvent(threadId = threadId),
latestEvent: ThreadListItemEvent? = aThreadListItemEvent(threadId = threadId),
numberOfReplies: Long = 42,
rootEventText: String? = "Hello world!",
latestEventText: String? = "Hello again!",
formattedTimestamp: String = "12:34",
) = ThreadListRowItem(
item = aThreadListItem(
threadId = threadId,
rootEvent = rootEvent,
latestEvent = latestEvent,
numberOfReplies = numberOfReplies,
),
rootEventText = rootEventText,
latestEventText = latestEventText,
formattedTimestamp = formattedTimestamp,
)
fun aThreadListItem(
threadId: ThreadId = ThreadId("\$a-thread-id"),
rootEvent: ThreadListItemEvent = aThreadListItemEvent(threadId = threadId),
latestEvent: ThreadListItemEvent? = aThreadListItemEvent(threadId = threadId),
numberOfReplies: Long = 42,
) = ThreadListItem(
rootEvent = rootEvent,
latestEvent = latestEvent,
numberOfReplies = numberOfReplies,
)
fun aThreadListItemEvent(
threadId: ThreadId = ThreadId("\$a-thread-id"),
senderId: UserId = UserId("@a-user-id:server"),
senderProfile: ProfileDetails = ProfileDetails.Ready(displayName = "Alice", displayNameAmbiguous = false, avatarUrl = null),
isOwn: Boolean = false,
content: EventContent = MessageContent(
body = "Hello world!",
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = TextMessageType("Hello world!", null),
),
timestamp: Long = 0L,
) = ThreadListItemEvent(
eventId = threadId.asEventId(),
senderId = senderId,
senderProfile = senderProfile,
isOwn = isOwn,
content = content,
timestamp = timestamp,
)

View file

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

View file

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