mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 21:32:34 +00:00
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:
+48
-32
@@ -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)
|
||||
}
|
||||
|
||||
+29
-23
@@ -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)
|
||||
}
|
||||
|
||||
-4
@@ -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,
|
||||
|
||||
+3
-5
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -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,
|
||||
)
|
||||
+8
@@ -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",
|
||||
|
||||
+22
-4
@@ -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>
|
||||
}
|
||||
|
||||
+28
@@ -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
|
||||
}
|
||||
+2
@@ -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,
|
||||
|
||||
+1
@@ -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))
|
||||
}
|
||||
})
|
||||
|
||||
+19
-17
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-4
@@ -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()
|
||||
|
||||
+9
-10
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+32
-11
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-13
@@ -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
|
||||
}
|
||||
|
||||
+15
-2
@@ -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)
|
||||
|
||||
+17
@@ -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)
|
||||
}
|
||||
+3
-5
@@ -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)
|
||||
|
||||
+25
@@ -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
|
||||
}
|
||||
+67
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+15
-4
@@ -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
|
||||
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+32
@@ -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() },
|
||||
|
||||
+1
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -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
|
||||
|
||||
+2
-7
@@ -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,
|
||||
|
||||
-1
@@ -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,
|
||||
/**
|
||||
|
||||
+1
-5
@@ -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,
|
||||
|
||||
+105
-67
@@ -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(
|
||||
|
||||
+27
-15
@@ -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
|
||||
|
||||
+50
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user