Merge remote-tracking branch 'origin/develop' into feature/bma/multiAccountShareAtRoot

# Conflicts:
#	libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt
This commit is contained in:
Benoit Marty
2026-10-09 17:11:58 +02:00
187 changed files with 2370 additions and 581 deletions
@@ -8,26 +8,32 @@
package io.element.android.libraries.designsystem.atomic.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceAtLeast
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.SemanticColors
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Text
private const val MAX_COUNT = 99
@@ -54,7 +60,7 @@ fun CounterAtom(
modifier: Modifier = Modifier,
containerColor: Color? = null,
contentColor: Color? = null,
contentPadding: PaddingValues = PaddingValues.Zero,
contentPadding: PaddingValues = PaddingValues(vertical = 0.5.dp),
textStyle: TextStyle = CounterAtomDefaults.textStyle,
dotSize: Dp = 12.0.dp,
isCritical: Boolean = false,
@@ -65,40 +71,50 @@ fun CounterAtom(
else -> MAX_COUNT_STRING
}
val minSingleDigitSize = if (countAsText?.length == 1) {
val textMeasurer = rememberTextMeasurer()
// Measure the maximum count string size
val textLayoutResult = textMeasurer.measure(
text = countAsText,
val density = LocalDensity.current
val textMeasurer = rememberTextMeasurer()
val minTextSize = remember(density.density, textStyle) {
val result = textMeasurer.measure(
text = MAX_COUNT_STRING,
style = textStyle
)
val textSize = textLayoutResult.size
maxOf(textSize.width, textSize.height)
with(density) {
minOf(result.size.width, result.size.height).toDp()
}
}
val sizeModifier = if (countAsText?.length == 1) {
Modifier.size(minTextSize)
} else {
0
Modifier.sizeIn(minHeight = minTextSize)
}
androidx.compose.material3.Badge(
modifier = modifier.then(
if (countAsText == null) Modifier.sizeIn(minWidth = dotSize, minHeight = dotSize) else Modifier
),
containerColor = containerColor ?: if (isCritical) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconAccentPrimary,
contentColor = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
content = countAsText?.let {
@Composable {
Text(
modifier = Modifier.sizeIn(
minWidth = (minSingleDigitSize.toDp() - 8.dp).coerceAtLeast(0.dp),
minHeight = minSingleDigitSize.toDp()
).padding(contentPadding),
text = countAsText,
style = textStyle,
color = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
textAlign = TextAlign.Center,
)
}
val extraPadding = if (countAsText?.length == 1) {
contentPadding
} else {
contentPadding + PaddingValues(horizontal = 4.dp)
}
val backgroundColor = containerColor ?: if (isCritical) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconAccentPrimary
Box(
modifier = modifier
.clip(RoundedCornerShape(100))
.background(backgroundColor)
.then(
if (countAsText == null) Modifier.size(dotSize) else Modifier
)
) {
countAsText?.let {
Text(
modifier = sizeModifier
.align(Alignment.Center)
.padding(extraPadding),
text = countAsText,
style = textStyle,
color = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
textAlign = TextAlign.Center,
)
}
)
}
}
object CounterAtomDefaults {
@@ -112,7 +128,7 @@ internal fun CounterAtomPreview() = ElementPreview {
Row(horizontalArrangement = spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
CounterAtom(count = 0)
CounterAtom(count = 4)
CounterAtom(count = 99)
CounterAtom(count = 10)
CounterAtom(count = 100)
CounterAtom(count = 4, isCritical = true)
}
@@ -13,7 +13,6 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
@@ -27,35 +26,42 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.unreadIndicator
/**
* A small indicator to show the number of unread messages or notifications.
*
* @param count The number of unread messages or notifications. If null, the indicator will be hidden, if 0, a notification dot will be shown.
* @param modifier The modifier to be applied to the indicator.
* @param dotIndicatorSize The size of the dot indicator.
* @param color The background color of the indicator.
* @param contentDescription The content description for accessibility.
* @param border The border to be applied to the indicator.
* @param contentPadding The padding inside the indicator.
*/
@Composable
fun UnreadIndicatorAtom(
count: Long?,
modifier: Modifier = Modifier,
size: Dp = 12.dp,
count: Long? = null,
color: Color = ElementTheme.colors.unreadIndicator,
isVisible: Boolean = true,
dotIndicatorSize: Dp = 12.dp,
color: Color = ElementTheme.colors.iconAccentPrimary,
contentDescription: String? = null,
border: BorderStroke? = null,
contentPadding: PaddingValues = PaddingValues.Zero,
) {
when {
!isVisible -> Spacer(modifier = modifier.size(size))
else -> count?.let {
CounterAtom(
count = count.toInt(),
modifier = modifier
.semantics {
contentDescription?.let { this.contentDescription = it }
}
.then(if (border != null) Modifier.border(border, RoundedCornerShape(percent = 50)) else Modifier),
containerColor = color,
contentColor = ElementTheme.colors.bgCanvasDefault,
textStyle = ElementTheme.typography.fontBodySmMedium,
contentPadding = contentPadding,
)
}
count?.let {
CounterAtom(
count = count.toInt(),
modifier = modifier
.semantics {
contentDescription?.let { this.contentDescription = it }
}
.then(if (count == 0L) Modifier.size(dotIndicatorSize) else Modifier)
.then(if (border != null) Modifier.border(border, RoundedCornerShape(percent = 100)) else Modifier),
containerColor = color,
contentColor = ElementTheme.colors.bgCanvasDefault,
textStyle = ElementTheme.typography.fontBodySmMedium,
contentPadding = contentPadding,
)
}
}
@@ -66,7 +72,7 @@ internal fun UnreadIndicatorAtomPreview() = ElementPreview {
UnreadIndicatorAtom(count = null)
UnreadIndicatorAtom(count = 0)
UnreadIndicatorAtom(count = 1)
UnreadIndicatorAtom(count = 10)
UnreadIndicatorAtom(count = 20)
UnreadIndicatorAtom(count = 99)
UnreadIndicatorAtom(count = 999)
}
@@ -32,9 +32,6 @@ val SemanticColors.roomListRoomMessage
val SemanticColors.roomListRoomMessageDate
get() = textSecondary
val SemanticColors.unreadIndicator
get() = iconAccentTertiary
val SemanticColors.placeholderBackground
get() = bgSubtleSecondary
@@ -83,7 +80,6 @@ internal fun ColorAliasesPreview() = ElementPreview {
"roomListRoomName" to ElementTheme.colors.roomListRoomName,
"roomListRoomMessage" to ElementTheme.colors.roomListRoomMessage,
"roomListRoomMessageDate" to ElementTheme.colors.roomListRoomMessageDate,
"unreadIndicator" to ElementTheme.colors.unreadIndicator,
"placeholderBackground" to ElementTheme.colors.placeholderBackground,
"messageFromMeBackground" to ElementTheme.colors.messageFromMeBackground,
"messageFromOtherBackground" to ElementTheme.colors.messageFromOtherBackground,
@@ -36,7 +36,7 @@ import androidx.compose.ui.unit.Dp
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.CounterAtom
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.tooltip.PlainTooltip
import io.element.android.libraries.designsystem.components.tooltip.TooltipBox
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -140,12 +140,10 @@ fun HorizontalFloatingToolbarItem(
BadgedBox(
badge = {
counter?.let {
CounterAtom(
count = it,
UnreadIndicatorAtom(
count = it.toLong(),
// Tweak the offset so it's centered on the icon
modifier = Modifier.offset(x = (-16).dp, y = 10.dp),
contentPadding = PaddingValues(vertical = 0.5.dp),
textStyle = ElementTheme.typography.fontBodySmMedium,
)
}
}
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.utils
import androidx.compose.ui.input.key.Key
/**
* Keys which can be used to activate (i.e. click) the component having the keyboard focus.
*/
val activationKeys = setOf(
Key.Enter,
Key.NumPadEnter,
Key.DirectionCenter,
Key.Spacebar,
)
@@ -154,6 +154,14 @@ enum class FeatureFlags(
defaultValue = { false },
isFinished = false,
),
MessageMultiSelect(
key = "feature.message_multi_select",
title = "Multi-select messages",
description = "The Forward action enters a selection mode, to pick and forward up to 10 messages at once.",
defaultValue = { false },
isFinished = false,
isInLabs = false,
),
NewTimelineEventRenderer(
key = "feature.new_timeline_event_renderer",
title = "New timeline event renderer",
@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.item.SendTarget
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
@@ -316,12 +317,13 @@ interface Timeline : AutoCloseable {
suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Boolean>
/**
* Forwards the content of an event to other rooms, as new messages sent by the current user.
* Forwards the content of some events to other rooms, as new messages sent by the current user.
* The events are forwarded in the order of [eventIds].
*
* @param eventId the event whose content is forwarded.
* @param roomIds the rooms to forward it to.
* @param eventIds the events whose content is forwarded.
* @param roomIds the rooms to forward them to.
*/
suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit>
suspend fun forwardEvents(eventIds: List<EventId>, roomIds: List<RoomId>): Result<Unit>
/**
* Cancels an event that is still waiting in the send queue, by redacting its local echo.
@@ -422,4 +424,20 @@ interface Timeline : AutoCloseable {
* Get the latest event id of the timeline, or `null` when it holds no event yet.
*/
suspend fun getLatestEventId(): Result<EventId?>
/**
* Retries sending an event that failed to send, by putting it back in the send queue.
*
* @param eventOrTransactionId the event or transaction ID of the event to retry sending.
* @param sendTarget the target action of the send operation.
*/
suspend fun retrySend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result<Boolean>
/**
* Aborts sending an event that failed to send, by removing it from the send queue.
*
* @param eventOrTransactionId the event or transaction ID of the event to abort sending.
* @param sendTarget the target action of the send operation.
*/
suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result<Boolean>
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.timeline.item
import androidx.compose.runtime.Immutable
/**
* Represents the target action of a send operation in a timeline.
*/
@Immutable
sealed interface SendTarget {
/** Send a new event. */
data object Event : SendTarget
/** Edit an existing event. */
data object Edit : SendTarget
/** Redact an existing event. */
data object Redaction : SendTarget
/** React to an existing event with the given key. */
data class Reaction(val key: String) : SendTarget
}
@@ -24,6 +24,8 @@ data class EventTimelineItem(
val isOwn: Boolean,
val isRemote: Boolean,
val localSendState: LocalEventSendState?,
val editSendState: LocalEventSendState?,
val redactionSendState: LocalEventSendState?,
val reactions: ImmutableList<EventReaction>,
val receipts: ImmutableList<Receipt>,
val sender: UserId,
@@ -877,6 +877,7 @@ class RustMatrixClient(
override fun sendQueueDisabledFlow(): Flow<RoomId> = mxCallbackFlow {
innerClient.subscribeToSendQueueStatus(object : SendQueueRoomErrorListener {
override fun onError(roomId: String, error: ClientException) {
Timber.e(error, "Send queue error for room $roomId")
trySend(RoomId(roomId))
}
})
@@ -14,7 +14,6 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.ForwardEventException
import io.element.android.libraries.matrix.impl.roomlist.roomOrNull
import io.element.android.libraries.matrix.impl.timeline.runWithTimelineListenerRegistered
import kotlinx.coroutines.withTimeout
import org.matrix.rustcomponents.sdk.MsgLikeKind
import org.matrix.rustcomponents.sdk.RoomListService
@@ -31,33 +30,36 @@ class RoomContentForwarder(
private val roomListService: RoomListService,
) {
/**
* Forwards the event with the given [eventId] from the [fromTimeline] to the given [toRoomIds].
* @param fromTimeline the room to forward the event from
* @param eventId the id of the event to forward
* @param toRoomIds the ids of the rooms to forward the event to
* @param timeoutMs the maximum time in milliseconds to wait for the event to be sent to a room
* Forwards the events with the given [eventIds] from the [fromTimeline] to the given [toRoomIds].
* The events are sent to each room in the order they are provided in [eventIds], it is up to the caller to sort them.
* @param fromTimeline the room to forward the events from
* @param eventIds the ids of the events to forward, in the order they must be sent
* @param toRoomIds the ids of the rooms to forward the events to
* @param timeoutMs the maximum time in milliseconds to wait for an event to be sent to a room
*/
suspend fun forward(
fromTimeline: Timeline,
eventId: EventId,
eventIds: List<EventId>,
toRoomIds: List<RoomId>,
timeoutMs: Long = 5000L
) {
val messageLikeContent = (fromTimeline.getEventTimelineItemByEventId(eventId.value).content as? TimelineItemContent.MsgLike)?.content
?: throw ForwardEventException(toRoomIds)
val content = (messageLikeContent.kind as? MsgLikeKind.Message)?.content
?: throw ForwardEventException(toRoomIds)
val contents = eventIds.map { eventId ->
val timelineEvent = fromTimeline.getEventTimelineItemByEventId(eventId.value)
val content = timelineEvent.content as? TimelineItemContent.MsgLike ?: throw ForwardEventException(toRoomIds)
val message = content.content.kind as? MsgLikeKind.Message ?: throw ForwardEventException(toRoomIds)
contentWithoutRelationFromMessage(message.content)
}
val targetRooms = toRoomIds.toSet().mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) }
val failedForwardingTo = mutableSetOf<RoomId>()
targetRooms.parallelMap { room ->
room.use { targetRoom ->
runCatchingExceptions {
// Sending a message requires a registered timeline listener
targetRoom.timeline().runWithTimelineListenerRegistered {
withTimeout(timeoutMs.milliseconds) {
targetRoom.timeline().send(contentWithoutRelationFromMessage(content))
targetRoom.timeline().use { timeline ->
// Send the contents sequentially, so that they are received in the same order
contents.forEach { content ->
withTimeout(timeoutMs.milliseconds) {
timeline.send(content)
}
}
}
}
@@ -57,7 +57,7 @@ fun RoomListInterface.loadingStateFlow(): Flow<RoomListLoadingState> =
internal fun RoomListInterface.entriesFlow(
pageSize: Int,
initialFilterKind: RoomListEntriesDynamicFilterKind,
onControllerCreated: (RoomListDynamicEntriesController) -> Unit,
onControllerCreated: suspend (RoomListDynamicEntriesController) -> Unit,
): Flow<List<RoomListEntriesUpdate>> =
callbackFlow {
val listener = object : RoomListEntriesListener {
@@ -70,9 +70,13 @@ internal fun RoomListInterface.entriesFlow(
listener = listener,
)
val controller = result.controller()
controller.setFilter(initialFilterKind)
onControllerCreated(controller)
awaitClose {
// Use try/finally rather than awaitClose's block, so that the resources are released even if
// onControllerCreated is cancelled or throws before awaitClose is reached
try {
controller.setFilter(initialFilterKind)
onControllerCreated(controller)
awaitClose()
} finally {
result.entriesStream().cancelAndDestroy()
controller.destroy()
result.destroy()
@@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController
import org.matrix.rustcomponents.sdk.RoomListLoadingState
import org.matrix.rustcomponents.sdk.RoomListService
import kotlin.coroutines.CoroutineContext
@@ -49,7 +48,13 @@ internal class RoomListFactory(
val summariesFlow = MutableSharedFlow<List<RoomSummary>>(replay = 1, extraBufferCapacity = 1)
val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryFactory, analyticsService)
var innerRoomList: InnerRoomList? = null
var dynamicController: RoomListDynamicEntriesController? = null
val dynamicRoomList = RustDynamicRoomList(
summaries = summariesFlow,
loadingState = loadingStateFlow,
processor = processor,
pageSize = pageSize,
initialFilter = initialFilter,
)
val firstRoomsTransaction = analyticsService.startTransaction("Load first set of rooms", "innerRoomList.entriesFlow")
@@ -60,7 +65,7 @@ internal class RoomListFactory(
pageSize = pageSize,
initialFilterKind = RoomListFilterMapper.toRustFilter(initialFilter),
onControllerCreated = { controller ->
dynamicController = controller
dynamicRoomList.onControllerCreated(controller, initialFilter)
}
).onEach { update ->
if (!firstRoomsTransaction.isFinished()) {
@@ -80,13 +85,7 @@ internal class RoomListFactory(
}.invokeOnCompletion {
innerRoomList?.destroy()
}
return RustDynamicRoomList(
summaries = summariesFlow,
loadingState = loadingStateFlow,
processor = processor,
pageSize = pageSize,
dynamicController = { dynamicController }
)
return dynamicRoomList
}
}
@@ -24,37 +24,58 @@ internal class RustDynamicRoomList(
override val loadingState: MutableStateFlow<RoomList.LoadingState>,
private val processor: RoomSummaryListProcessor,
override val pageSize: Int,
private val dynamicController: () -> RoomListDynamicEntriesController?,
initialFilter: RoomListFilter,
private val addPagesCount: Int = DEFAULT_ADD_PAGES_COUNT
) : DynamicRoomList {
private val mutex = Mutex()
// The controller is created asynchronously, once the entries of the room list are observed
private var dynamicController: RoomListDynamicEntriesController? = null
// The last requested filter, so that it can be applied when the controller is created
private var currentFilter: RoomListFilter = initialFilter
/**
* To be called when the controller is created. The controller already has the initial filter set, so apply the filter
* only if it has been updated in the meantime.
*/
suspend fun onControllerCreated(controller: RoomListDynamicEntriesController, initialFilter: RoomListFilter) {
mutex.withLock {
dynamicController = controller
if (currentFilter != initialFilter) {
controller.applyFilter(currentFilter)
}
}
}
override suspend fun rebuildSummaries() {
processor.rebuildRoomSummaries()
}
override suspend fun updateFilter(filter: RoomListFilter) {
mutex.withLock {
dynamicController()?.let { controller ->
// Reset pagination when filter changes
controller.resetToOnePage()
val rustFilter = RoomListFilterMapper.toRustFilter(filter)
controller.setFilter(rustFilter)
// Then preload some pages
controller.addPages(addPagesCount)
}
currentFilter = filter
dynamicController?.applyFilter(filter)
}
}
private fun RoomListDynamicEntriesController.applyFilter(filter: RoomListFilter) {
// Reset pagination when filter changes
resetToOnePage()
setFilter(RoomListFilterMapper.toRustFilter(filter))
// Then preload some pages
addPages(addPagesCount)
}
override suspend fun loadMore() {
mutex.withLock {
dynamicController()?.addPages(addPagesCount)
dynamicController?.addPages(addPagesCount)
}
}
override suspend fun reset() {
mutex.withLock {
dynamicController()?.resetToOnePage()
dynamicController?.resetToOnePage()
}
}
@@ -53,16 +53,3 @@ internal fun TimelineInterface.timelineDiffFlow(): Flow<List<TimelineDiff>> =
}.catch {
Timber.d(it, "timelineDiffFlow() failed")
}.buffer(Channel.UNLIMITED)
internal suspend fun TimelineInterface.runWithTimelineListenerRegistered(action: suspend () -> Unit) {
val result = addListener(NoOpTimelineListener)
try {
action()
} finally {
result.cancelAndDestroy()
}
}
private object NoOpTimelineListener : TimelineListener {
override fun onUpdate(diff: List<TimelineDiff>) = Unit
}
@@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.SendTarget
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.impl.media.GalleryMediaUploadHandlerImpl
@@ -489,9 +490,9 @@ class RustTimeline(
}
}
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = withContext(dispatcher) {
override suspend fun forwardEvents(eventIds: List<EventId>, roomIds: List<RoomId>): Result<Unit> = withContext(dispatcher) {
runCatchingExceptions {
roomContentForwarder.forward(fromTimeline = inner, eventId = eventId, toRoomIds = roomIds)
roomContentForwarder.forward(fromTimeline = inner, eventIds = eventIds, toRoomIds = roomIds)
}.onFailure {
Timber.tag(loggerTag).e(it)
}
@@ -697,6 +698,18 @@ class RustTimeline(
}
}
override suspend fun retrySend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result<Boolean> = withContext(dispatcher) {
runCatchingExceptions {
inner.retrySend(eventOrTransactionId.toRustEventOrTransactionId(), sendTarget.map())
}
}
override suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result<Boolean> = withContext(dispatcher) {
runCatchingExceptions {
inner.abortSend(eventOrTransactionId.toRustEventOrTransactionId(), sendTarget.map())
}
}
private suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> = withContext(dispatcher) {
runCatchingExceptions {
inner.fetchDetailsForEvent(eventId.value)
@@ -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.libraries.matrix.impl.timeline
import io.element.android.libraries.matrix.api.timeline.item.SendTarget
fun SendTarget.map() = when (this) {
is SendTarget.Event -> org.matrix.rustcomponents.sdk.SendTarget.Event
is SendTarget.Edit -> org.matrix.rustcomponents.sdk.SendTarget.Edit
is SendTarget.Redaction -> org.matrix.rustcomponents.sdk.SendTarget.Redaction
is SendTarget.Reaction -> org.matrix.rustcomponents.sdk.SendTarget.Reaction(key)
}
@@ -51,6 +51,8 @@ class EventTimelineItemMapper(
isOwn = isOwn,
isRemote = isRemote,
localSendState = localSendState?.map(),
editSendState = editSendState?.map(),
redactionSendState = redactionSendState?.map(),
reactions = reactions.map(),
receipts = readReceipts.map(),
sender = UserId(sender),
@@ -113,11 +115,7 @@ fun RustEventSendState?.map(): LocalEventSendState? {
)
}
is QueueWedgeError.GenericApiError -> {
if (isRecoverable) {
LocalEventSendState.Sending.Event
} else {
LocalEventSendState.Failed.Unknown(queueWedgeError.msg)
}
LocalEventSendState.Failed.Unknown(queueWedgeError.msg)
}
is QueueWedgeError.InvalidMimeType -> {
LocalEventSendState.Failed.InvalidMimeType(queueWedgeError.mimeType)
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.fixtures.fakes
import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
class FakeFfiRoomListDynamicEntriesController : RoomListDynamicEntriesController(NoHandle) {
val filters = mutableListOf<RoomListEntriesDynamicFilterKind>()
override fun setFilter(kind: RoomListEntriesDynamicFilterKind): Boolean {
filters.add(kind)
return true
}
override fun resetToOnePage() = Unit
override fun addOnePage() = Unit
}
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.roomlist
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListDynamicEntriesController
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListService
import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RustDynamicRoomListTest {
@Test
fun `updateFilter before the controller is created applies the filter when it is created`() = runTest {
val sut = createRustDynamicRoomList()
sut.updateFilter(RoomListFilter.NormalizedMatchRoomName("query"))
val controller = FakeFfiRoomListDynamicEntriesController()
sut.onControllerCreated(controller, initialFilter = RoomListFilter.all())
assertThat(controller.filters).containsExactly(RoomListFilterMapper.toRustFilter(RoomListFilter.NormalizedMatchRoomName("query")))
}
@Test
fun `when the filter is not updated, the controller filter is not changed when it is created`() = runTest {
val sut = createRustDynamicRoomList()
val controller = FakeFfiRoomListDynamicEntriesController()
sut.onControllerCreated(controller, initialFilter = RoomListFilter.all())
assertThat(controller.filters).isEmpty()
}
@Test
fun `updateFilter after the controller is created applies the filter`() = runTest {
val sut = createRustDynamicRoomList()
val controller = FakeFfiRoomListDynamicEntriesController()
sut.onControllerCreated(controller, initialFilter = RoomListFilter.all())
sut.updateFilter(RoomListFilter.NormalizedMatchRoomName("query"))
assertThat(controller.filters).containsExactly(RoomListFilterMapper.toRustFilter(RoomListFilter.NormalizedMatchRoomName("query")))
}
private fun TestScope.createRustDynamicRoomList(): RustDynamicRoomList {
val summaries = MutableSharedFlow<List<RoomSummary>>(replay = 1)
return RustDynamicRoomList(
summaries = summaries,
loadingState = MutableStateFlow(RoomList.LoadingState.NotLoaded),
processor = RoomSummaryListProcessor(
summaries,
FakeFfiRoomListService(),
coroutineContext = StandardTestDispatcher(testScheduler),
roomSummaryFactory = RoomSummaryFactory(),
analyticsService = FakeAnalyticsService(),
),
pageSize = 10,
initialFilter = RoomListFilter.all(),
)
}
}
@@ -24,6 +24,7 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.SendTarget
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
@@ -60,7 +61,9 @@ class FakeTimeline(
receiptType: ReceiptType,
) -> Result<Unit> = { _, _ ->
lambdaError()
}
},
private val retrySendResult: (eventOrTransactionId: EventOrTransactionId) -> Result<Boolean> = { lambdaError() },
private val abortSendResult: (eventOrTransactionId: EventOrTransactionId) -> Result<Boolean> = { lambdaError() },
) : Timeline {
var sendMessageLambda: (
body: String,
@@ -355,10 +358,10 @@ class FakeTimeline(
)
}
var forwardEventLambda: (eventId: EventId, roomIds: List<RoomId>) -> Result<Unit> = { _, _ -> lambdaError() }
var forwardEventsLambda: (eventIds: List<EventId>, roomIds: List<RoomId>) -> Result<Unit> = { _, _ -> lambdaError() }
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = simulateLongTask {
forwardEventLambda(eventId, roomIds)
override suspend fun forwardEvents(eventIds: List<EventId>, roomIds: List<RoomId>): Result<Unit> = simulateLongTask {
forwardEventsLambda(eventIds, roomIds)
}
var createPollLambda: (
@@ -477,6 +480,14 @@ class FakeTimeline(
return getLatestEventIdResult()
}
override suspend fun retrySend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result<Boolean> {
return retrySendResult(eventOrTransactionId)
}
override suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result<Boolean> {
return abortSendResult(eventOrTransactionId)
}
var closeCounter = 0
private set
@@ -52,6 +52,8 @@ fun anEventTimelineItem(
isOwn: Boolean = false,
isRemote: Boolean = false,
localSendState: LocalEventSendState? = null,
editSendState: LocalEventSendState? = null,
redactionSendState: LocalEventSendState? = null,
reactions: ImmutableList<EventReaction> = persistentListOf(),
receipts: ImmutableList<Receipt> = persistentListOf(),
sender: UserId = A_USER_ID,
@@ -69,6 +71,8 @@ fun anEventTimelineItem(
isOwn = isOwn,
isRemote = isRemote,
localSendState = localSendState,
editSendState = editSendState,
redactionSendState = redactionSendState,
reactions = reactions,
receipts = receipts,
sender = sender,
@@ -9,6 +9,8 @@
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -25,11 +27,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
@@ -48,6 +55,7 @@ import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.activationKeys
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@@ -62,9 +70,31 @@ fun SelectedItem(
modifier: Modifier = Modifier,
) {
val actionRemove = stringResource(id = CommonStrings.action_remove)
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = modifier
.width(avatarData.size.dp)
.then(
if (canRemove) {
// Let the whole item get the keyboard focus, so that the focus indicator is drawn around it.
Modifier
.onKeyEvent { event ->
if (event.type == KeyEventType.KeyUp && event.key in activationKeys) {
onRemoveClick()
true
} else {
false
}
}
.indication(
interactionSource = interactionSource,
indication = ripple(),
)
.focusable(interactionSource = interactionSource)
} else {
Modifier
}
)
.clearAndSetSemantics {
contentDescription = a11yContentDescription
if (canRemove) {
@@ -130,6 +160,8 @@ fun SelectedItem(
.clip(CircleShape)
.size(20.dp)
.align(Alignment.TopEnd)
// The whole item is focusable, not the remove button
.focusProperties { canFocus = false }
.clickable(
indication = ripple(),
interactionSource = remember { MutableInteractionSource() },
@@ -44,7 +44,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.unreadIndicator
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomVisibility
@@ -168,10 +167,7 @@ private fun NameAndIndicatorRow(
overflow = TextOverflow.Ellipsis
)
if (showIndicator) {
UnreadIndicatorAtom(
color = ElementTheme.colors.unreadIndicator,
count = 0,
)
UnreadIndicatorAtom(count = 0)
}
}
}
@@ -13,7 +13,6 @@ import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
sealed interface RoomSelectEvent {
data class ToggleSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvent
data object ToggleSearchActive : RoomSelectEvent
data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvent
data object ToggleAccountListExpanded : RoomSelectEvent
data class SelectAccount(val sessionId: SessionId) : RoomSelectEvent
@@ -8,7 +8,6 @@
package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -67,7 +66,6 @@ class RoomSelectPresenter(
override fun present(): RoomSelectState {
var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) }
val queryState = rememberTextFieldState()
var isSearchActive by remember { mutableStateOf(false) }
var isAccountListExpanded by remember { mutableStateOf(false) }
// Null until the sessions are loaded
val sessions by remember { sessionStore.sessionsFlow() }.collectAsState(initial = null)
@@ -157,7 +155,7 @@ class RoomSelectPresenter(
derivedStateOf {
when {
roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList())
isSearchActive -> SearchBarResultState.NoResultsFound
queryState.text.isNotBlank() -> SearchBarResultState.NoResultsFound
else -> SearchBarResultState.Initial
}
}
@@ -173,13 +171,11 @@ class RoomSelectPresenter(
selectedRooms.adding(event.room)
}
}
RoomSelectEvent.ToggleSearchActive -> isSearchActive = !isSearchActive
RoomSelectEvent.ToggleAccountListExpanded -> isAccountListExpanded = !isAccountListExpanded
is RoomSelectEvent.SelectAccount -> {
selectedSessionId = event.sessionId
// The selected rooms and the search query are specific to the session
// The selected rooms are specific to the session. The search query is kept, and applied to the rooms of the new session.
selectedRooms = persistentListOf()
queryState.clearText()
isAccountListExpanded = false
}
is RoomSelectEvent.UpdateVisibleRange -> coroutineScope.launch {
@@ -193,7 +189,6 @@ class RoomSelectPresenter(
maxNumberOfRooms = maxNumberOfRooms,
resultState = searchResults,
searchQuery = queryState,
isSearchActive = isSearchActive,
selectedRooms = selectedRooms,
selectedAccount = selectedAccount,
hasRoomListError = hasRoomListError,
@@ -20,7 +20,6 @@ data class RoomSelectState(
val maxNumberOfRooms: Int,
val resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>>,
val searchQuery: TextFieldState,
val isSearchActive: Boolean,
val selectedRooms: ImmutableList<SelectRoomInfo>,
val selectedAccount: MatrixUser,
/**
@@ -26,17 +26,15 @@ open class RoomSelectStatePreviewParam : PreviewParameterProvider<RoomSelectStat
override val values: Sequence<RoomSelectState>
get() = sequenceOf(
aRoomSelectState(),
aRoomSelectState(searchQuery = "Test", isSearchActive = true),
aRoomSelectState(searchQuery = "Test", resultState = SearchBarResultState.NoResultsFound),
aRoomSelectState(resultState = SearchBarResultState.Results(aRoomSelectRoomList())),
aRoomSelectState(
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
searchQuery = "Test",
isSearchActive = true,
),
aRoomSelectState(
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
searchQuery = "Test",
isSearchActive = true,
selectedRooms = aRoomSelectRoomList().subList(0, 1),
),
aRoomSelectState(
@@ -76,7 +74,6 @@ internal fun aRoomSelectState(
maxNumberOfRooms: Int = 10,
resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>> = SearchBarResultState.Initial,
searchQuery: String = "",
isSearchActive: Boolean = false,
selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(),
selectedAccount: MatrixUser = MatrixUser(UserId("@alice:example.org")),
hasRoomListError: Boolean = false,
@@ -88,7 +85,6 @@ internal fun aRoomSelectState(
maxNumberOfRooms = maxNumberOfRooms,
resultState = resultState,
searchQuery = TextFieldState(initialText = searchQuery),
isSearchActive = isSearchActive,
selectedRooms = selectedRooms,
selectedAccount = selectedAccount,
hasRoomListError = hasRoomListError,
@@ -10,6 +10,7 @@ package io.element.android.libraries.roomselect.impl
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@@ -24,18 +25,23 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
@@ -46,9 +52,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -70,8 +78,8 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.designsystem.theme.components.SearchField
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@@ -123,10 +131,8 @@ fun RoomSelectView(
}
var canHandleBack by remember { mutableStateOf(true) }
fun onBackButton(state: RoomSelectState) {
if (state.isSearchActive) {
state.eventSink(RoomSelectEvent.ToggleSearchActive)
} else if (canHandleBack) {
fun onBackButton() {
if (canHandleBack) {
canHandleBack = false
onDismiss()
}
@@ -134,16 +140,34 @@ fun RoomSelectView(
BackHandler(
enabled = canHandleBack,
onBack = { onBackButton(state) }
onBack = ::onBackButton,
)
val searchQuery = state.searchQuery.text.toString()
// Use a new list state when the selected account changes, so that the list of rooms is scrolled to the top
val lazyListState = key(state.selectedAccount.userId) { rememberLazyListState() }
// Scroll the list of rooms to the top when the search query changes
LaunchedEffect(lazyListState, searchQuery) {
lazyListState.scrollToItem(0)
}
// Hide the account switch section when the room list is scrolled up, and show it again as soon as it is scrolled down.
// Use a new state when the selected account changes, so that the section is fully displayed again.
val accountSwitchScrollBehavior = key(state.selectedAccount.userId) {
TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState())
}
// Collapse the account switch section when the user starts searching. It can be displayed again by scrolling down.
val isSearching = searchQuery.isNotBlank()
LaunchedEffect(isSearching) {
if (isSearching) {
val topAppBarState = accountSwitchScrollBehavior.state
animate(
initialValue = topAppBarState.heightOffset,
targetValue = topAppBarState.heightOffsetLimit,
) { value, _ ->
topAppBarState.heightOffset = value
}
}
}
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
state.eventSink(RoomSelectEvent.UpdateVisibleRange(visibleRange))
}
@@ -153,13 +177,13 @@ fun RoomSelectView(
topBar = {
TopAppBar(
titleStr = when (state.mode) {
RoomSelectMode.Forward -> stringResource(CommonStrings.common_forward_message)
RoomSelectMode.Forward -> stringResource(CommonStrings.common_forward_to)
RoomSelectMode.Share -> stringResource(CommonStrings.common_send_to)
},
navigationIcon = {
BackButton(
enabled = canHandleBack,
onClick = { onBackButton(state) }
onClick = ::onBackButton,
)
},
actions = {
@@ -177,82 +201,62 @@ fun RoomSelectView(
Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.nestedScroll(accountSwitchScrollBehavior.nestedScrollConnection)
.then(
// Only when the account switch section is displayed, else its unknown height would consume all the scroll events
if (state.showAccountSwitch) {
Modifier.nestedScroll(accountSwitchScrollBehavior.nestedScrollConnection)
} else {
Modifier
}
)
) {
SearchBar(
SearchField(
state = state.searchQuery,
modifier = Modifier
.fillMaxWidth()
.padding(
WindowInsets.safeDrawing
.only(WindowInsetsSides.Horizontal)
.asPaddingValues()
),
placeHolderTitle = stringResource(CommonStrings.action_search),
queryState = state.searchQuery,
active = state.isSearchActive,
onActiveChange = { state.eventSink(RoomSelectEvent.ToggleSearchActive) },
resultState = state.resultState,
showBackButton = false,
) { summaries ->
if (summaries.isNotEmpty()) {
ListSectionHeader(
title = stringResource(CommonStrings.common_header_rooms),
hasDivider = false,
)
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = stringResource(CommonStrings.action_search),
)
if (state.showAccountSwitch) {
// Keep this space outside the collapsing section, so that the section does not touch the search field when it is collapsing
Spacer(modifier = Modifier.height(8.dp))
TopAppBarScrollBehaviorLayout(scrollBehavior = accountSwitchScrollBehavior) {
AccountSwitchSection(
selectedAccount = state.selectedAccount,
otherAccounts = state.otherAccounts,
isExpanded = state.isAccountListExpanded,
onToggleExpand = { state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) },
onSelectAccount = { state.eventSink(RoomSelectEvent.SelectAccount(it)) },
)
}
LazyColumn(
state = lazyListState,
contentPadding = lazyColumnContentPadding,
) {
item {
SelectedRoomsHelper(
selectedRooms = state.selectedRooms,
showVerticalSpace = false,
)
}
items(summaries, key = { it.roomId.value }) { roomSummary ->
RoomSummaryView(
roomSummary,
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
onSelection = { roomSummary ->
state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary))
},
canBeSelected = state.canSelectMoreRooms,
)
}
}
}
if (!state.isSearchActive) {
if (state.showAccountSwitch) {
// Keep this space outside the collapsing section, so that the section does not touch the search bar when it is collapsing
Spacer(modifier = Modifier.height(8.dp))
TopAppBarScrollBehaviorLayout(scrollBehavior = accountSwitchScrollBehavior) {
AccountSwitchSection(
selectedAccount = state.selectedAccount,
otherAccounts = state.otherAccounts,
isExpanded = state.isAccountListExpanded,
onToggleExpand = { state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) },
onSelectAccount = { state.eventSink(RoomSelectEvent.SelectAccount(it)) },
)
}
}
SelectedRoomsHelper(
selectedRooms = state.selectedRooms,
// showVerticalSpace only if there is no other accounts
showVerticalSpace = !state.showAccountSwitch,
)
if (state.hasRoomListError) {
RoomListError()
} else if (state.resultState is SearchBarResultState.Results) {
SelectedRoomsHelper(
selectedRooms = state.selectedRooms,
// showVerticalSpace only if there is no other accounts
showVerticalSpace = !state.showAccountSwitch,
)
when {
state.hasRoomListError -> RoomListError()
state.resultState is SearchBarResultState.NoResultsFound -> NoResultsView(searchQuery = searchQuery)
state.resultState is SearchBarResultState.Results -> {
LazyColumn(
state = lazyListState,
contentPadding = lazyColumnContentPadding,
) {
if (state.resultState.results.isNotEmpty()) {
item {
val headerText = if (searchQuery.isBlank()) {
stringResource(CommonStrings.common_header_rooms)
} else {
stringResource(CommonStrings.common_results_for, searchQuery)
}
ListSectionHeader(
title = stringResource(CommonStrings.common_header_rooms),
title = headerText,
hasDivider = false,
)
}
@@ -269,11 +273,45 @@ fun RoomSelectView(
}
}
}
else -> Unit
}
}
}
}
@Composable
private fun NoResultsView(
searchQuery: String,
) {
Column(
modifier = Modifier
.fillMaxSize()
// Scrollable, so that the account switch section can be displayed again by scrolling down
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResource(CommonStrings.common_no_results),
modifier = Modifier
.semantics {
heading()
},
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
)
Text(
text = stringResource(CommonStrings.common_no_results_for, searchQuery),
modifier = Modifier.widthIn(max = 300.dp),
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
@Composable
private fun RoomListError() {
IconTitleSubtitleMolecule(
@@ -63,7 +63,6 @@ class RoomSelectPresenterTest {
val initialState = awaitItem()
assertThat(initialState.selectedRooms).isEmpty()
assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.maxNumberOfRooms).isEqualTo(10)
assertThat(initialState.canSelectMoreRooms).isTrue()
assertThat(initialState.selectedAccount).isEqualTo(MatrixUser(A_SESSION_ID))
@@ -366,6 +365,33 @@ class RoomSelectPresenterTest {
}
}
@Test
fun `present - select another account keeps the search query and applies it to the rooms of this account`() = runTest {
val roomList2 = FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary(roomId = A_ROOM_ID_2))))
val roomListServices = mapOf(
A_SESSION_ID to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary()))) }),
A_SESSION_ID_2 to FakeRoomListService(createRoomListLambda = { roomList2 }),
)
val presenter = createRoomSelectPresenter(
mode = RoomSelectMode.Share,
sessionStore = aSessionStoreWithTwoAccounts(),
matrixClientProvider = FakeMatrixClientProvider { sessionId ->
Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListServices.getValue(sessionId)))
},
)
presenter.test {
val state = awaitLastSequentialItem()
state.searchQuery.setTextAndPlaceCursorAtEnd("query")
state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2))
val finalState = consumeItemsUntilPredicate {
it.selectedAccount.userId == A_SESSION_ID_2 && it.resultState is SearchBarResultState.Results
}.last()
assertThat(finalState.searchQuery.text.toString()).isEqualTo("query")
assertThat(finalState.showAccountSwitch).isTrue()
assertThat(roomList2.currentFilter.value).isEqualTo(RoomListFilter.NormalizedMatchRoomName("query"))
}
}
@Test
fun `present - forward mode with several accounts does not show the account switch`() = runTest {
val presenter = createRoomSelectPresenter(
@@ -386,18 +412,6 @@ class RoomSelectPresenterTest {
),
)
@Test
fun `present - toggle search active`() = runTest {
val presenter = createRoomSelectPresenter()
presenter.test {
val initialState = awaitItem()
initialState.eventSink(RoomSelectEvent.ToggleSearchActive)
assertThat(awaitItem().isSearchActive).isTrue()
initialState.eventSink(RoomSelectEvent.ToggleSearchActive)
assertThat(awaitItem().isSearchActive).isFalse()
}
}
@Test
fun `present - update query`() = runTest {
val roomSummary = aRoomSummary()
@@ -416,8 +430,6 @@ class RoomSelectPresenterTest {
// Do not compare the lambda because they will be different. So copy the lambda from expectedRoomSummary to result
val result = (awaitItem().resultState as SearchBarResultState.Results).results
assertThat(result).isEqualTo(listOf(expectedRoomInfo))
initialState.eventSink(RoomSelectEvent.ToggleSearchActive)
skipItems(1)
initialState.searchQuery.setTextAndPlaceCursorAtEnd("string not contained")
assertThat(
roomList.currentFilter.value
@@ -13,8 +13,12 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeUp
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomId
@@ -29,6 +33,7 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import org.junit.Test
import org.robolectric.annotation.Config
@@ -123,6 +128,44 @@ class RoomSelectViewTest : RobolectricTest() {
onNodeWithText(activity!!.getString(CommonStrings.common_switch_account)).assertDoesNotExist()
}
@Test
fun `the room list can be scrolled when there is no other account`() = runAndroidComposeUiTest {
setRoomSelectView(
aRoomSelectState(
mode = RoomSelectMode.Share,
resultState = SearchBarResultState.Results(aLongRoomList()),
),
)
onNodeWithText("Room 0").assertIsDisplayed()
onRoot().performTouchInput { swipeUp() }
onNodeWithText("Room 0").assertIsNotDisplayed()
}
@Test
fun `the room list can be scrolled when there are other accounts`() = runAndroidComposeUiTest {
setRoomSelectView(
aRoomSelectState(
mode = RoomSelectMode.Share,
resultState = SearchBarResultState.Results(aLongRoomList()),
otherAccounts = persistentListOf(aMatrixUser(id = "@bob:example.org", displayName = "Bob")),
),
)
onNodeWithText("Room 0").assertIsDisplayed()
onRoot().performTouchInput { swipeUp() }
onNodeWithText("Room 0").assertIsNotDisplayed()
}
@Test
fun `a message is displayed when no room matches the search query`() = runAndroidComposeUiTest {
setRoomSelectView(
aRoomSelectState(
searchQuery = "Test",
resultState = SearchBarResultState.NoResultsFound,
),
)
onNodeWithText(activity!!.getString(CommonStrings.common_no_results)).assertIsDisplayed()
}
@Test
fun `an error is displayed instead of the rooms if they cannot be loaded`() = runAndroidComposeUiTest<ComponentActivity> {
setRoomSelectView(
@@ -150,3 +193,10 @@ private fun AndroidComposeUiTest<ComponentActivity>.setRoomSelectView(
)
}
}
private fun aLongRoomList() = List(30) { index ->
aSelectRoomInfo(
roomId = RoomId("!room$index:domain"),
name = "Room $index",
)
}.toImmutableList()
@@ -335,6 +335,7 @@ Reason: %1$s."</string>
<string name="common_mute">"Mute"</string>
<string name="common_name">"Name"</string>
<string name="common_name_and_id">"%1$s (%2$s)"</string>
<string name="common_no_results_for">"There are no results for “%1$s”. Try a new search term."</string>
<string name="common_no_results">"No results"</string>
<string name="common_no_room_name">"No room name"</string>
<string name="common_no_space_name">"No space name"</string>
@@ -381,6 +382,7 @@ Reason: %1$s."</string>
<string name="common_replying_to">"Replying to %1$s"</string>
<string name="common_report_a_problem">"Report a problem"</string>
<string name="common_report_submitted">"Report submitted"</string>
<string name="common_results_for">"Results for “%1$s”"</string>
<string name="common_rich_text_editor">"Rich text editor"</string>
<string name="common_role">"Role"</string>
<string name="common_room">"Room"</string>