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
@@ -0,0 +1,85 @@
name: Sync Enterprise submodule
on:
workflow_dispatch:
push:
branches: [ develop ]
paths: [ enterprise ]
schedule:
# At 06:00 UTC every day
- cron: '0 6 * * *'
permissions: {}
jobs:
sync-enterprise-submodule:
runs-on: ubuntu-latest
# Skip in forks
if: github.repository == 'element-hq/element-x-android'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Add SSH private keys for submodule repositories
uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
- name: Check enterprise submodule reference
id: check
env:
EVENT_NAME: ${{ github.event_name }}
run: |
current=$(git ls-tree HEAD enterprise | awk '{print $3}')
# Do not init the submodule: the current commit may be on a deleted branch.
git init -q "$RUNNER_TEMP/enterprise"
git -C "$RUNNER_TEMP/enterprise" fetch -q git@github.com:element-hq/element-android-enterprise.git main
main=$(git -C "$RUNNER_TEMP/enterprise" rev-parse FETCH_HEAD)
if git -C "$RUNNER_TEMP/enterprise" merge-base --is-ancestor "$current" "$main" 2>/dev/null; then
on_main=true
else
on_main=false
fi
echo "Current: $current, main: $main, current is on main: $on_main"
if [ "$current" = "$main" ]; then
echo "Submodule is already up to date."
echo "update=false" >> "$GITHUB_OUTPUT"
elif [ "$EVENT_NAME" = "push" ] && [ "$on_main" = "true" ]; then
echo "Submodule targets a commit on main, nothing to do."
echo "update=false" >> "$GITHUB_OUTPUT"
else
echo "update=true" >> "$GITHUB_OUTPUT"
fi
{
echo "body<<EOF"
if [ "$on_main" = "true" ]; then
# Do not list the commit messages, the enterprise repository is private.
count=$(git -C "$RUNNER_TEMP/enterprise" rev-list --count "$current..$main")
echo "Update the \`enterprise\` submodule from \`$current\` to the latest commit of \`main\` (\`$main\`), $count new commit(s)."
else
echo "The \`enterprise\` submodule targets \`$current\`, which is not on the \`main\` branch."
echo
echo "Update it to the latest commit of \`main\` (\`$main\`)."
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
if [ "$current" != "$main" ]; then
# Update the submodule reference without cloning it.
git update-index --cacheinfo "160000,$main,enterprise"
git -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \
commit -q -m "Update enterprise submodule to latest main" -m "Update to $main"
fi
- name: Create Pull Request to update the enterprise submodule
# On schedule and manual runs, also run when there is nothing to update, so that a stale PR gets closed.
if: ${{ steps.check.outputs.update == 'true' || github.event_name != 'push' }}
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
title: Update enterprise submodule to latest main
body: ${{ steps.check.outputs.body }}
branch: sync-enterprise-submodule
base: develop
delete-branch: true
labels: PR-Element-Pro
@@ -41,7 +41,7 @@ class SendQueues(
fun launchIn(coroutineScope: CoroutineScope) {
combine(
syncService.syncState,
matrixClient.sendQueueDisabledFlow(),
matrixClient.sendQueueDisabledFlow().onEach { Timber.tag("SendQueues").d("Send queue disabled for room: $it") },
) { syncState, _ -> syncState }
.debounce(SEND_QUEUES_RETRY_DELAY_MILLIS)
.onEach { syncState ->
@@ -201,7 +201,7 @@ class JoinedRoomLoadedFlowNode(
} else {
(graph as TimelineBindings).timelineProvider
}
val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider)
val params = ForwardEntryPoint.Params(listOf(navTarget.eventId), timelineProvider)
val callback = object : ForwardEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) {
backstack.pop()
@@ -16,6 +16,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -92,6 +93,9 @@ class ConfigureRoomPresenter(
val isKnockFeatureEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
}.collectAsState(initial = false)
val arePublicRoomsDisabled by produceState(false) {
value = sessionEnterpriseService.arePublicRoomsDisabledByHomeserver()
}
val roomAddressValidity = remember {
mutableStateOf<RoomAddressValidity>(RoomAddressValidity.Unknown)
}
@@ -145,12 +149,13 @@ class ConfigureRoomPresenter(
// 1. If we are creating a space.
// 2. If it has a parent space.
// 3. If knocking is enabled.
// 4. If the homeserver forbids public rooms.
val parentSpace = createRoomConfig.parentSpace
val availableJoinRules = remember(parentSpace, isSpace, isKnockFeatureEnabled) {
val availableJoinRules = remember(parentSpace, isSpace, isKnockFeatureEnabled, arePublicRoomsDisabled) {
when {
isSpace && parentSpace != null -> TODO("Adding a space to a parent space is not supported yet! How did you get here?")
parentSpace == null || parentSpace.joinRule == JoinRule.Public -> listOfNotNull(
JoinRuleItem.PublicVisibility.Public,
JoinRuleItem.PublicVisibility.Public.takeIf { !arePublicRoomsDisabled },
JoinRuleItem.PublicVisibility.AskToJoin.takeIf { !isSpace && isKnockFeatureEnabled },
JoinRuleItem.PrivateVisibility.Private,
).toImmutableList()
@@ -57,6 +57,7 @@ import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.matching
import io.element.android.tests.testutils.robolectric.RobolectricTest
@@ -522,7 +523,10 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
@Test
fun `present - creating a private room when encryption is disabled by the HS creates it unencrypted`() = runTest {
val sessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { true })
val sessionEnterpriseService = FakeSessionEnterpriseService(
isEncryptionDisabledResult = { true },
arePublicRoomsDisabledResult = { false },
)
val createRoomLambda = lambdaRecorder<CreateRoomParameters, Result<RoomId>> { Result.success(A_ROOM_ID) }
val matrixClient = createMatrixClient().apply {
createRoomResult = createRoomLambda
@@ -544,6 +548,38 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
}
}
@Test
fun `present - when public rooms are disabled by the HS, the public join rule is not available`() = runTest {
val presenter = createConfigureRoomPresenter(
sessionEnterpriseService = FakeSessionEnterpriseService(
isEncryptionDisabledResult = { false },
arePublicRoomsDisabledResult = { true },
),
)
presenter.test {
val expectedJoinRules = listOf(JoinRuleItem.PublicVisibility.AskToJoin, JoinRuleItem.PrivateVisibility.Private)
val state = consumeItemsUntilPredicate { it.availableJoinRules == expectedJoinRules }.last()
assertThat(state.availableJoinRules).containsExactlyElementsIn(expectedJoinRules).inOrder()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - when public rooms are disabled by the HS, only the private join rule is available for a space`() = runTest {
val presenter = createConfigureRoomPresenter(
isSpace = true,
sessionEnterpriseService = FakeSessionEnterpriseService(
isEncryptionDisabledResult = { false },
arePublicRoomsDisabledResult = { true },
),
)
presenter.test {
val state = consumeItemsUntilPredicate { it.availableJoinRules == listOf(JoinRuleItem.PrivateVisibility.Private) }.last()
assertThat(state.config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private))
cancelAndIgnoreRemainingEvents()
}
}
private suspend fun TurbineTestContext<ConfigureRoomState>.initialState(): ConfigureRoomState {
skipItems(1)
return awaitItem()
@@ -579,7 +615,10 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
isKnockFeatureEnabled: Boolean = true,
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { false }),
sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService(
isEncryptionDisabledResult = { false },
arePublicRoomsDisabledResult = { false },
),
) = ConfigureRoomPresenter(
isSpace = isSpace,
initialParentSpaceId = initialParenSpaceId,
@@ -29,4 +29,10 @@ interface SessionEnterpriseService {
/** Whether the homeserver forbids creating encrypted rooms, in which case the app must not offer the option. */
suspend fun isEncryptionDisabledByHomeserver(): Boolean
/**
* Whether the homeserver forbids public rooms and spaces, in which case the app must not offer to create one,
* nor to make an existing room or space public.
*/
suspend fun arePublicRoomsDisabledByHomeserver(): Boolean
}
@@ -18,4 +18,5 @@ class DefaultSessionEnterpriseService : SessionEnterpriseService {
override suspend fun tweakMasUrl(url: String): String = url
override suspend fun isElementCallAvailable(): Boolean = true
override suspend fun isEncryptionDisabledByHomeserver(): Boolean = false
override suspend fun arePublicRoomsDisabledByHomeserver(): Boolean = false
}
@@ -16,6 +16,7 @@ class FakeSessionEnterpriseService(
private val isElementCallAvailableResult: () -> Boolean = { lambdaError() },
private val tweakMasUrlResult: (String) -> String = { lambdaError() },
private val isEncryptionDisabledResult: () -> Boolean = { lambdaError() },
private val arePublicRoomsDisabledResult: () -> Boolean = { lambdaError() },
) : SessionEnterpriseService {
override suspend fun init() {
}
@@ -31,4 +32,8 @@ class FakeSessionEnterpriseService(
override suspend fun isEncryptionDisabledByHomeserver(): Boolean {
return isEncryptionDisabledResult()
}
override suspend fun arePublicRoomsDisabledByHomeserver(): Boolean {
return arePublicRoomsDisabledResult()
}
}
@@ -23,7 +23,7 @@ interface ForwardEntryPoint : FeatureEntryPoint {
}
data class Params(
val eventId: EventId,
val eventIds: List<EventId>,
val timelineProvider: TimelineProvider,
) : NodeInputs
@@ -27,7 +27,7 @@ class DefaultForwardEntryPoint : ForwardEntryPoint {
buildContext = buildContext,
plugins = listOf(
ForwardMessagesNode.Inputs(
eventId = params.eventId,
eventIds = params.eventIds,
timelineProvider = params.timelineProvider,
),
callback,
@@ -54,13 +54,13 @@ class ForwardMessagesNode(
object NavTarget : Parcelable
data class Inputs(
val eventId: EventId,
val eventIds: List<EventId>,
val timelineProvider: TimelineProvider,
) : NodeInputs
private val inputs = inputs<Inputs>()
private val callback: ForwardEntryPoint.Callback = callback()
private val presenter = presenterFactory.create(inputs.eventId.value, inputs.timelineProvider)
private val presenter = presenterFactory.create(inputs.eventIds, inputs.timelineProvider)
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
val callback = object : RoomSelectEntryPoint.Callback {
@@ -28,22 +28,20 @@ import timber.log.Timber
@AssistedInject
class ForwardMessagesPresenter(
@Assisted eventId: String,
@Assisted private val eventIds: List<EventId>,
@Assisted private val timelineProvider: TimelineProvider,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
) : Presenter<ForwardMessagesState> {
private val eventId: EventId = EventId(eventId)
@AssistedFactory
fun interface Factory {
fun create(eventId: String, timelineProvider: TimelineProvider): ForwardMessagesPresenter
fun create(eventIds: List<EventId>, timelineProvider: TimelineProvider): ForwardMessagesPresenter
}
private val forwardingActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized)
fun onRoomSelected(roomIds: List<RoomId>) {
sessionCoroutineScope.forwardEvent(eventId, roomIds)
sessionCoroutineScope.forwardEvents(eventIds, roomIds)
}
@Composable
@@ -60,14 +58,14 @@ class ForwardMessagesPresenter(
)
}
private fun CoroutineScope.forwardEvent(
eventId: EventId,
private fun CoroutineScope.forwardEvents(
eventIds: List<EventId>,
roomIds: List<RoomId>,
) = launch {
suspend {
timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds)
timelineProvider.getActiveTimeline().forwardEvents(eventIds, roomIds)
.onFailure {
Timber.e(it, "Error while forwarding event")
Timber.e(it, "Error while forwarding events")
}
.getOrThrow()
roomIds
@@ -47,7 +47,7 @@ class DefaultForwardEntryPointTest {
override fun onDone(roomIds: List<RoomId>) = lambdaError()
}
val params = ForwardEntryPoint.Params(
eventId = AN_EVENT_ID,
eventIds = listOf(AN_EVENT_ID),
timelineProvider = FakeTimelineProvider(),
)
val result = entryPoint.createNode(
@@ -59,7 +59,7 @@ class DefaultForwardEntryPointTest {
assertThat(result).isInstanceOf(ForwardMessagesNode::class.java)
assertThat(result.plugins).contains(
ForwardMessagesNode.Inputs(
eventId = params.eventId,
eventIds = params.eventIds,
timelineProvider = params.timelineProvider,
)
)
@@ -44,11 +44,11 @@ class ForwardMessagesPresenterTest {
@Test
fun `present - forward successful`() = runTest {
val forwardEventLambda = lambdaRecorder { _: EventId, _: List<RoomId> ->
val forwardEventsLambda = lambdaRecorder { _: List<EventId>, _: List<RoomId> ->
Result.success(Unit)
}
val timeline = FakeTimeline().apply {
this.forwardEventLambda = forwardEventLambda
this.forwardEventsLambda = forwardEventsLambda
}
val room = FakeJoinedRoom(liveTimeline = timeline)
val presenter = createForwardMessagesPresenter(fakeRoom = room)
@@ -62,17 +62,17 @@ class ForwardMessagesPresenterTest {
assertThat(forwardingState.forwardAction.isLoading()).isTrue()
val successfulForwardState = awaitItem()
assertThat(successfulForwardState.forwardAction).isEqualTo(AsyncAction.Success(listOf(summary.roomId)))
forwardEventLambda.assertions().isCalledOnce()
forwardEventsLambda.assertions().isCalledOnce()
}
}
@Test
fun `present - select a room and forward failed, then clear`() = runTest {
val forwardEventLambda = lambdaRecorder { _: EventId, _: List<RoomId> ->
val forwardEventsLambda = lambdaRecorder { _: List<EventId>, _: List<RoomId> ->
Result.failure<Unit>(IllegalStateException("error"))
}
val timeline = FakeTimeline().apply {
this.forwardEventLambda = forwardEventLambda
this.forwardEventsLambda = forwardEventsLambda
}
val room = FakeJoinedRoom(liveTimeline = timeline)
val presenter = createForwardMessagesPresenter(fakeRoom = room)
@@ -88,16 +88,16 @@ class ForwardMessagesPresenterTest {
// Then clear error
failedForwardState.eventSink(ForwardMessagesEvent.ClearError)
assertThat(awaitItem().forwardAction.isUninitialized()).isTrue()
forwardEventLambda.assertions().isCalledOnce()
forwardEventsLambda.assertions().isCalledOnce()
}
}
}
fun TestScope.createForwardMessagesPresenter(
eventId: EventId = AN_EVENT_ID,
eventIds: List<EventId> = listOf(AN_EVENT_ID),
fakeRoom: FakeJoinedRoom = FakeJoinedRoom(),
) = ForwardMessagesPresenter(
eventId = eventId.value,
eventIds = eventIds,
timelineProvider = LiveTimelineProvider(fakeRoom),
sessionCoroutineScope = this,
)
@@ -60,7 +60,6 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.roomListRoomMessage
import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate
import io.element.android.libraries.designsystem.theme.roomListRoomName
import io.element.android.libraries.designsystem.theme.unreadIndicator
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.DisplayedStatus
@@ -244,7 +243,7 @@ private fun NameAndTimestampRow(
text = timestamp ?: "",
style = ElementTheme.typography.fontBodySmMedium,
color = if (isHighlighted) {
ElementTheme.colors.unreadIndicator
ElementTheme.colors.textActionAccent
} else {
ElementTheme.colors.roomListRoomMessageDate
},
@@ -347,13 +346,12 @@ private fun MessagePreviewAndIndicatorRow(
// Call and unread
Row(
modifier = Modifier
.height(16.dp)
// Used to force this line to be read aloud earlier than the latest event when using Talkback
.zIndex(-1f),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val tint = if (room.isHighlighted) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary
val tint = if (room.isHighlighted) ElementTheme.colors.textActionAccent else ElementTheme.colors.iconQuaternary
if (room.hasRoomCall) {
OnGoingCallIcon(
color = tint,
@@ -422,10 +420,7 @@ private fun InviteNameAndIndicatorRow(
overflow = TextOverflow.Ellipsis
)
if (!isInviteSeen) {
UnreadIndicatorAtom(
color = ElementTheme.colors.unreadIndicator,
count = 0,
)
UnreadIndicatorAtom(count = 0)
}
}
}
@@ -459,7 +454,7 @@ private fun MentionIndicatorAtom() {
modifier = Modifier.size(16.dp),
contentDescription = stringResource(CommonStrings.a11y_notifications_new_mentions),
imageVector = CompoundIcons.Mention(),
tint = ElementTheme.colors.unreadIndicator,
tint = ElementTheme.colors.iconAccentPrimary,
)
}
@@ -23,6 +23,7 @@ sealed interface MessagesEvent {
data object StopLiveLocationShare : MessagesEvent
data object ShowLiveLocationShare : MessagesEvent
data object MarkAsFullyReadAndExit : MessagesEvent
data object ConfirmSelectionAction : MessagesEvent
}
enum class InviteDialogAction {
@@ -181,7 +181,7 @@ class MessagesFlowNode(
@Parcelize
data class ForwardEvent(
val eventId: EventId,
val eventIds: List<EventId>,
@IgnoredOnParcel
val timelineProvider: TimelineProvider? = null,
) : NavTarget
@@ -306,8 +306,8 @@ class MessagesFlowNode(
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
}
override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) {
backstack.push(NavTarget.ForwardEvent(eventId, timelineProvider))
override fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
backstack.push(NavTarget.ForwardEvent(eventIds, timelineProvider))
}
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
@@ -464,7 +464,7 @@ class MessagesFlowNode(
is NavTarget.ForwardEvent -> {
// If no timeline provider is received, assume the live timeline should be used
val timelineProvider = navTarget.timelineProvider ?: timelineController
val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider)
val params = ForwardEntryPoint.Params(navTarget.eventIds, timelineProvider)
val callback = object : ForwardEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) {
backstack.pop()
@@ -551,7 +551,7 @@ class MessagesFlowNode(
}
override fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider) {
backstack.push(NavTarget.ForwardEvent(eventId = eventId, timelineProvider = timelineProvider))
backstack.push(NavTarget.ForwardEvent(eventIds = listOf(eventId), timelineProvider = timelineProvider))
}
override fun navigateToThread(threadRootId: ThreadId) {
@@ -617,8 +617,8 @@ class MessagesFlowNode(
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
}
override fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider) {
backstack.push(NavTarget.ForwardEvent(eventId, timelineProvider))
override fun handleForwardEventClick(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
backstack.push(NavTarget.ForwardEvent(eventIds, timelineProvider))
}
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
@@ -19,7 +19,7 @@ import kotlinx.collections.immutable.ImmutableList
interface MessagesNavigator {
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider)
fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider)
fun navigateToReportMessage(eventId: EventId, senderId: UserId)
fun navigateToEditPoll(eventId: EventId)
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
@@ -138,7 +138,7 @@ class MessagesNode(
fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData)
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider)
fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider)
fun navigateToReportMessage(eventId: EventId, senderId: UserId)
fun navigateToSendLocation()
fun navigateToCreatePoll()
@@ -221,8 +221,8 @@ class MessagesNode(
callback.navigateToEventDebugInfo(eventId, debugInfo)
}
override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) {
callback.forwardEvent(eventId, timelineProvider)
override fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
callback.forwardEvents(eventIds, timelineProvider)
}
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
@@ -38,6 +38,8 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.MarkAsFullyRead
import io.element.android.features.messages.impl.timeline.SelectionAction
import io.element.android.features.messages.impl.timeline.SelectionState
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelineState
@@ -52,6 +54,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
import io.element.android.features.messages.impl.timeline.model.event.htmlCaptionOrNull
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.selectedEventIdsInTimelineOrder
import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent
@@ -83,6 +86,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
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.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map
@@ -257,6 +261,17 @@ class MessagesPresenter(
redactEventAction = redactEventAction,
)
}
is MessagesEvent.ConfirmSelectionAction -> {
val selection = timelineState.selectionState as? SelectionState.Active
if (selection != null) {
when (selection.action) {
SelectionAction.Forward -> {
navigator.forwardEvents(timelineState.selectedEventIdsInTimelineOrder(), timelineController)
}
}
timelineState.eventSink(TimelineEvent.ExitSelectionMode)
}
}
is MessagesEvent.ConfirmRedact -> {
val confirming = redactEventAction.value as? MessagesState.ConfirmingRedaction
redactEventAction.value = AsyncAction.Uninitialized
@@ -413,21 +428,27 @@ class MessagesPresenter(
}
}
TimelineItemAction.ViewSource -> handleShowDebugInfoAction(targetEvent)
TimelineItemAction.Forward -> handleForwardAction(targetEvent)
TimelineItemAction.Forward -> {
val eventId = targetEvent.eventId ?: return@launch
if (featureFlagService.isFeatureEnabled(FeatureFlags.MessageMultiSelect)) {
timelineState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, eventId))
} else {
navigator.forwardEvents(listOf(eventId), timelineController)
}
}
TimelineItemAction.ReportContent -> handleReportAction(targetEvent)
TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent, timelineState)
TimelineItemAction.Pin -> handlePinAction(targetEvent)
TimelineItemAction.Unpin -> handleUnpinAction(targetEvent)
TimelineItemAction.ViewInTimeline -> Unit
TimelineItemAction.RetrySending -> handleRetrySending(targetEvent)
TimelineItemAction.AbortSending -> handleActionAbortSend(targetEvent)
}
}
private suspend fun handleRetrySending(targetEvent: TimelineItem.Event) {
val sendHandle = targetEvent.sendhandle ?: return Unit.also {
Timber.w("No send handle for event ${targetEvent.eventOrTransactionId}")
}
sendHandle.retry()
val (sendTarget, _) = targetEvent.pendingSend() ?: return
timelineController.retrySend(targetEvent.eventOrTransactionId, sendTarget)
.onSuccess {
Timber.d("Succeed to add the message back to the send queue")
}
@@ -517,13 +538,18 @@ class MessagesPresenter(
private suspend fun handleActionRedact(event: TimelineItem.Event, redactEventAction: MutableState<AsyncAction<Unit>>) {
val eventId = event.eventId
if (eventId == null) {
// The message was never sent, so there is nobody to give a reason to.
redact(event.eventOrTransactionId, reason = null)
// The message (or its edit/redaction) was never sent, just remove it from the send queue.
handleActionAbortSend(event)
} else {
redactEventAction.value = MessagesState.ConfirmingRedaction(eventId)
}
}
private suspend fun handleActionAbortSend(event: TimelineItem.Event) {
val sendTarget = event.pendingSend()?.first ?: SendTarget.Event
timelineController.abortSend(event.eventOrTransactionId, sendTarget).onFailure { Timber.e(it) }
}
private suspend fun redact(eventOrTransactionId: EventOrTransactionId, reason: String?) {
timelineController.invokeOnCurrentTimeline {
redactEvent(eventOrTransactionId = eventOrTransactionId, reason = reason)
@@ -612,11 +638,6 @@ class MessagesPresenter(
navigator.navigateToEventDebugInfo(event.eventId, event.debugInfo)
}
private fun handleForwardAction(event: TimelineItem.Event) {
if (event.eventId == null) return
navigator.forwardEvent(eventId = event.eventId, timelineProvider = timelineController)
}
private fun handleReportAction(event: TimelineItem.Event) {
if (event.eventId == null) return
navigator.navigateToReportMessage(event.eventId, event.senderId)
@@ -8,6 +8,7 @@
package io.element.android.features.messages.impl
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
@@ -77,24 +78,31 @@ import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBan
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerView
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerViewDefaults
import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS
import io.element.android.features.messages.impl.timeline.SelectionAction
import io.element.android.features.messages.impl.timeline.SelectionState
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelineView
import io.element.android.features.messages.impl.timeline.aGroupedEvents
import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.canApplyTo
import io.element.android.features.messages.impl.timeline.components.CallMenuItem
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvent
import io.element.android.features.messages.impl.timeline.isSelectionModeActive
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.selectedCount
import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogView
import io.element.android.features.messages.impl.topbars.MessagesViewTopBar
import io.element.android.features.messages.impl.topbars.SelectionModeTopBar
import io.element.android.features.messages.impl.topbars.ThreadTopBar
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
@@ -134,6 +142,7 @@ import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableSet
import timber.log.Timber
import kotlin.time.Duration.Companion.milliseconds
@@ -178,6 +187,18 @@ fun MessagesView(
block()
}
// Entering selection mode collapses the composer, so dismiss the keyboard too. hideKeyboard
// triggers the IME hide animation, keeping it in sync with the composer collapse.
LaunchedEffect(state.timelineState.isSelectionModeActive) {
if (state.timelineState.isSelectionModeActive) {
localView.hideKeyboard()
}
}
BackHandler(enabled = state.timelineState.isSelectionModeActive) {
state.timelineState.eventSink(TimelineEvent.ExitSelectionMode)
}
fun onContentClick(event: TimelineItem.Event) {
Timber.v("onMessageClick= ${event.id}")
val eventId = event.eventId
@@ -234,34 +255,50 @@ fun MessagesView(
Scaffold(
contentWindowInsets = scaffoldScrollableContentInsets,
topBar = {
if (state.timelineState.timelineMode is Timeline.Mode.Thread) {
ThreadTopBar(
roomName = state.roomName,
roomAvatarData = state.roomAvatar,
heroes = state.heroes,
isTombstoned = state.isTombstoned,
onBackClick = onBackClick,
)
} else {
MessagesViewTopBar(
roomName = state.roomName,
roomAvatar = state.roomAvatar,
isTombstoned = state.isTombstoned,
heroes = state.heroes,
dmUserIdentityState = state.dmUserVerificationState,
sharedHistoryIcon = state.topBarSharedHistoryIcon,
dmUserStatus = state.dmUserStatus,
onBackClick = { hidingKeyboard { onBackClick() } },
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
menuActions = {
MessagesMenuActions(
displayThreads = state.timelineState.timelineMode !is Timeline.Mode.Thread && state.threads.hasThreads,
roomCallState = state.roomCallState,
onJoinCallClick = onJoinCallClick,
onThreadsListClick = onThreadsListClick
)
}
)
when {
state.timelineState.isSelectionModeActive -> {
val selection = state.timelineState.selectionState as SelectionState.Active
SelectionModeTopBar(
selectedCount = state.timelineState.selectedCount,
action = selection.action,
onCancelClick = {
state.timelineState.eventSink(TimelineEvent.ExitSelectionMode)
},
onConfirmClick = {
state.eventSink(MessagesEvent.ConfirmSelectionAction)
},
)
}
state.timelineState.timelineMode is Timeline.Mode.Thread -> {
ThreadTopBar(
roomName = state.roomName,
roomAvatarData = state.roomAvatar,
heroes = state.heroes,
isTombstoned = state.isTombstoned,
onBackClick = onBackClick,
)
}
else -> {
MessagesViewTopBar(
roomName = state.roomName,
roomAvatar = state.roomAvatar,
isTombstoned = state.isTombstoned,
heroes = state.heroes,
dmUserIdentityState = state.dmUserVerificationState,
sharedHistoryIcon = state.topBarSharedHistoryIcon,
dmUserStatus = state.dmUserStatus,
onBackClick = { hidingKeyboard { onBackClick() } },
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
menuActions = {
MessagesMenuActions(
displayThreads = state.timelineState.timelineMode !is Timeline.Mode.Thread && state.threads.hasThreads,
roomCallState = state.roomCallState,
onJoinCallClick = onJoinCallClick,
onThreadsListClick = onThreadsListClick
)
}
)
}
}
},
content = { padding ->
@@ -449,7 +486,7 @@ fun MessagesView(
onRemoveMessage = { event ->
state.eventSink(
MessagesEvent.HandleAction(
action = TimelineItemAction.Redact,
action = TimelineItemAction.AbortSending,
event = event,
)
)
@@ -728,6 +765,40 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStatePreviewParam::cl
)
}
@PreviewsDayNight
@Composable
internal fun MessagesViewSelectionModePreview() = ElementPreview {
val timelineItems = aTimelineItemList(aTimelineItemTextContent())
val selectedEventIds = timelineItems
.filterIsInstance<TimelineItem.Event>()
.filter { SelectionAction.Forward.canApplyTo(it) }
.take(2)
.mapNotNull { it.eventId }
.toImmutableSet()
MessagesView(
state = aMessagesState(
timelineState = aTimelineState(
timelineItems = timelineItems,
selectionState = SelectionState.Active(SelectionAction.Forward, selectedEventIds),
),
),
onBackClick = {},
onRoomDetailsClick = {},
onEventContentClick = { _, _ -> false },
onGalleryEventItemClick = { _, _, _ -> false },
onUserDataClick = {},
onLinkClick = { _, _ -> },
onSendLocationClick = {},
onCreatePollClick = {},
onJoinCallClick = {},
onViewAllPinnedMessagesClick = { },
forceJumpToBottomVisibility = true,
knockRequestsBannerView = {},
customReactionBottomSheet = {},
onThreadsListClick = {},
)
}
@Preview
@Composable
internal fun MessagesViewA11yPreview() = ElementPreview {
@@ -37,6 +37,7 @@ enum class TimelineItemAction(
Pin(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin),
Unpin(CommonStrings.action_unpin, CompoundDrawables.ic_compound_unpin),
// Note: this action is not used in UI for now
// Note: these actions are not used in UI for now
RetrySending(CommonStrings.action_retry, CompoundDrawables.ic_compound_send_solid),
AbortSending(CommonStrings.action_cancel, CompoundDrawables.ic_compound_send_solid),
}
@@ -32,8 +32,9 @@ class TimelineItemActionComparator : Comparator<TimelineItemAction> {
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
TimelineItemAction.Redact,
// Not rendered in the action list, but has to be listed here for exhaustiveness.
// Not rendered in the action list, but have to be listed here for exhaustiveness.
TimelineItemAction.RetrySending,
TimelineItemAction.AbortSending,
)
override fun compare(o1: TimelineItemAction, o2: TimelineItemAction): Int {
@@ -38,6 +38,7 @@ import io.element.android.features.messages.impl.timeline.components.event.Timel
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemSelectionData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.poll.api.pollcontent.PollTitleView
@@ -289,6 +290,7 @@ private fun PinnedMessagesListLoaded(
onContentLayoutChange = onContentLayoutChange
)
},
selectionData = TimelineItemSelectionData(),
)
}
}
@@ -143,7 +143,7 @@ class ThreadedMessagesNode(
fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData)
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider)
fun handleForwardEventClick(eventIds: List<EventId>, timelineProvider: TimelineProvider)
fun navigateToReportMessage(eventId: EventId, senderId: UserId)
fun navigateToSendLocation()
fun navigateToCreatePoll()
@@ -227,8 +227,8 @@ class ThreadedMessagesNode(
callback.navigateToEventDebugInfo(eventId, debugInfo)
}
override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) {
callback.handleForwardEventClick(eventId, timelineProvider)
override fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
callback.handleForwardEventClick(eventIds, timelineProvider)
}
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
@@ -264,11 +264,12 @@ private fun ThreadListItemRow(
)
}
UnreadIndicatorAtom(
size = 14.dp,
isVisible = hasUnreadNotifications,
count = 0,
)
if (hasUnreadNotifications) {
UnreadIndicatorAtom(
dotIndicatorSize = 14.dp,
count = 0,
)
}
}
}
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.runtime.Immutable
import io.element.android.compound.R
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableSet
/** Maximum number of messages that can be selected at once. */
const val MAX_SELECTION_COUNT = 10
@Immutable
sealed interface SelectionState {
/** Selection mode is not active. */
data object Disabled : SelectionState
/**
* Selection mode is active for [action], with the given selection.
* [selectedEventIds] can be empty: selection mode is never exited automatically, only when the user explicitly leaves it.
*/
data class Active(
val action: SelectionAction,
val selectedEventIds: ImmutableSet<EventId>,
) : SelectionState
}
/**
* An action which can be performed on several timeline events at once, and which selection mode can be entered for.
*/
enum class SelectionAction(@field:StringRes val titleRes: Int, @field:DrawableRes val iconRes: Int) {
Forward(CommonStrings.action_forward, R.drawable.ic_compound_forward),
}
/**
* Whether [event] can be part of a selection made for this action.
* Each action uses the same criteria as the single event action it mirrors.
*/
fun SelectionAction.canApplyTo(event: TimelineItem.Event): Boolean = when (this) {
SelectionAction.Forward -> event.isRemote && event.content.canBeForwarded()
}
/**
* This selection without [eventIds].
* Selection mode stays active even when nothing remains selected, see [SelectionState.Active].
*/
fun SelectionState.deselect(eventIds: Set<EventId>): SelectionState = when (this) {
SelectionState.Disabled -> this
is SelectionState.Active -> copy(selectedEventIds = (selectedEventIds - eventIds).toImmutableSet())
}
/**
* The ids of the selected events which [SelectionState.Active.action] cannot be applied to anymore, for instance because the message has been
* deleted. They are meant to be given to [deselect].
*/
fun SelectionState.eventIdsToDeselect(timelineItems: List<TimelineItem>): Set<EventId> {
val selection = this as? SelectionState.Active ?: return emptySet()
if (selection.selectedEventIds.isEmpty()) return emptySet()
val result = mutableSetOf<EventId>()
fun process(event: TimelineItem.Event) {
val eventId = event.eventId ?: return
if (eventId in selection.selectedEventIds && !selection.action.canApplyTo(event)) {
result.add(eventId)
}
}
timelineItems.forEach { item ->
when (item) {
is TimelineItem.Event -> process(item)
// A deleted message can be folded into a group of deleted messages, so look inside the groups too.
is TimelineItem.GroupedEvents -> item.events.forEach(::process)
is TimelineItem.Virtual -> Unit
}
}
return result
}
@@ -23,6 +23,8 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import io.element.android.libraries.matrix.api.timeline.item.SendTarget
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -32,6 +34,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.getAndUpdate
@@ -134,6 +137,14 @@ class TimelineController(
}
}
suspend fun retrySend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result<Boolean> {
return currentTimelineFlow.first().retrySend(eventOrTransactionId, sendTarget)
}
suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result<Boolean> {
return currentTimelineFlow.first().abortSend(eventOrTransactionId, sendTarget)
}
private val currentTimelineFlow = combine(liveTimelineFlow, detachedTimelineFlow) { live, detached ->
when {
detached.isPresent -> detached.get()
@@ -28,7 +28,9 @@ sealed interface TimelineEvent {
data object HideShieldDialog : TimelineEvent
data object MarkAllAsRead : TimelineEvent
data class EnterSelectionMode(val action: SelectionAction, val eventId: EventId) : TimelineEvent
data class ToggleSelection(val eventId: EventId) : TimelineEvent
data object ExitSelectionMode : TimelineEvent
data class CopyToClipboard(val text: String) : TimelineEvent
/**
@@ -48,6 +48,8 @@ import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
@@ -71,13 +73,17 @@ import io.element.android.services.analytics.api.finishLongRunningTransaction
import io.element.android.services.analyticsproviders.api.AnalyticsUserData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -103,6 +109,7 @@ class TimelinePresenter(
private val typingNotificationPresenter: Presenter<TypingNotificationState>,
private val roomCallStatePresenter: Presenter<RoomCallState>,
private val featureFlagService: FeatureFlagService,
private val snackbarDispatcher: SnackbarDispatcher,
private val analyticsService: AnalyticsService,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val markAsFullyRead: MarkAsFullyRead,
@@ -172,6 +179,18 @@ class TimelinePresenter(
value = featureFlagService.isFeatureEnabled(FeatureFlags.NewTimelineEventRenderer)
}
val isMultiSelectEnabled by produceState(false) {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageMultiSelect).collect { value = it }
}
var selectionState by remember { mutableStateOf<SelectionState>(SelectionState.Disabled) }
// When the feature flag is turned off, clear any active selection.
LaunchedEffect(isMultiSelectEnabled) {
if (!isMultiSelectEnabled) {
selectionState = SelectionState.Disabled
}
}
val timelineProtectionState = timelineProtectionPresenter.present()
fun handleEvent(event: TimelineEvent) {
@@ -252,6 +271,26 @@ class TimelinePresenter(
is TimelineEvent.JumpToLive -> {
timelineController.focusOnLive()
}
is TimelineEvent.EnterSelectionMode -> {
if (!isMultiSelectEnabled) return
selectionState = SelectionState.Active(event.action, persistentSetOf(event.eventId))
}
is TimelineEvent.ToggleSelection -> {
val current = selectionState as? SelectionState.Active ?: return
val selected = current.selectedEventIds
selectionState = when {
// Deselecting the last event does not exit selection mode: it is only left explicitly, as on iOS.
event.eventId in selected -> current.deselect(setOf(event.eventId))
selected.size >= MAX_SELECTION_COUNT -> {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.screen_room_maximum_messages_selected))
current
}
else -> current.copy(selectedEventIds = (selected + event.eventId).toImmutableSet())
}
}
is TimelineEvent.ExitSelectionMode -> {
selectionState = SelectionState.Disabled
}
TimelineEvent.HideShieldDialog -> messageShieldDialogData.value = null
TimelineEvent.MarkAllAsRead -> sessionCoroutineScope.launch {
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
@@ -263,7 +302,9 @@ class TimelinePresenter(
}
is TimelineEvent.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData
is TimelineEvent.ShowSendFailureDialog -> {
val sendStateFailure = event.event.localSendState as? LocalEventSendState.Failed ?: return
val (_, sendStateFailure) = event.event.pendingSend() ?: return
if (sendStateFailure !is LocalEventSendState.Failed) return
when (sendStateFailure) {
is LocalEventSendState.Failed.VerifiedUser -> {
// defer to the resolveVerifiedUserSendFailureState to handle this case,
@@ -341,6 +382,14 @@ class TimelinePresenter(
}
.launchIn(this)
// Drop the selected events which the action cannot be applied to anymore, for instance because the message has been deleted.
timelineItemsFactory.timelineItems
.map { items -> selectionState.eventIdsToDeselect(items) }
.filter { eventIdsToDeselect -> eventIdsToDeselect.isNotEmpty() }
.flowOn(dispatchers.computation)
.onEach { eventIdsToDeselect -> selectionState = selectionState.deselect(eventIdsToDeselect) }
.launchIn(this)
combine(
timelineController.timelineItems(),
room.membersStateFlow,
@@ -477,6 +526,7 @@ class TimelinePresenter(
displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread.value,
selectionState = selectionState,
useNewTimelineEventRenderer = useNewTimelineEventRenderer,
eventSink = ::handleEvent,
)
@@ -22,6 +22,8 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlin.time.Duration
data class TimelineState(
@@ -38,6 +40,7 @@ data class TimelineState(
val displayThreadSummaries: Boolean,
val displayJumpToUnread: Boolean,
val jumpToUnread: JumpToUnreadState,
val selectionState: SelectionState,
// When true, formatted message bodies are rendered with the native Compose renderer.
val useNewTimelineEventRenderer: Boolean,
val eventSink: (TimelineEvent) -> Unit,
@@ -104,3 +107,43 @@ sealed interface JumpToUnreadState {
/** The read marker event is older than the loaded window — load it via focused-event navigation. */
data class OutOfWindow(val eventId: EventId) : JumpToUnreadState
}
val TimelineState.isSelectionModeActive: Boolean
get() = selectionState is SelectionState.Active
val TimelineState.selectedCount: Int
get() = (selectionState as? SelectionState.Active)?.selectedEventIds?.size ?: 0
fun TimelineState.isSelected(item: TimelineItem): Boolean {
val selection = selectionState as? SelectionState.Active ?: return false
return when (item) {
is TimelineItem.Event -> selection.selectedEventIds.contains(item.eventId)
else -> false
}
}
/**
* Whether [item] can be selected right now: selection mode must be active, and the action it was entered for must accept this event.
*/
fun TimelineState.canSelect(item: TimelineItem): Boolean {
val selection = selectionState as? SelectionState.Active ?: return false
return when (item) {
is TimelineItem.Event -> selection.action.canApplyTo(item)
else -> false
}
}
/**
* The selected event ids, sorted from the oldest to the most recent one, as they appear in the timeline.
* The selection itself is stored as a set, in the order the events were tapped, which is not necessarily the order they must be acted on.
*/
fun TimelineState.selectedEventIdsInTimelineOrder(): ImmutableList<EventId> {
val selectedEventIds = (selectionState as? SelectionState.Active)?.selectedEventIds ?: return persistentListOf()
return timelineItems
.filterIsInstance<TimelineItem.Event>()
.mapNotNull { it.eventId }
.filter { it in selectedEventIds }
// Most recent item is first in the timeline, so reverse the result.
.reversed()
.toImmutableList()
}
@@ -63,6 +63,7 @@ fun aTimelineState(
displayJumpToUnread: Boolean = false,
jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden,
newEventState: NewEventState = NewEventState.None,
selectionState: SelectionState = SelectionState.Disabled,
useNewTimelineEventRenderer: Boolean = false,
eventSink: (TimelineEvent) -> Unit = {},
): TimelineState {
@@ -85,6 +86,7 @@ fun aTimelineState(
displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread,
selectionState = selectionState,
useNewTimelineEventRenderer = useNewTimelineEventRenderer,
eventSink = eventSink,
)
@@ -157,6 +159,8 @@ internal fun aTimelineItemEvent(
content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
sendState: LocalEventSendState? = null,
editSendState: LocalEventSendState? = null,
redactionSendState: LocalEventSendState? = null,
inReplyTo: InReplyToDetails? = null,
threadInfo: TimelineItemThreadInfo? = null,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
@@ -183,6 +187,8 @@ internal fun aTimelineItemEvent(
),
groupPosition = groupPosition,
localSendState = sendState,
editSendState = editSendState,
redactionSendState = redactionSendState,
inReplyTo = inReplyTo,
threadInfo = threadInfo,
origin = null,
@@ -11,6 +11,7 @@ package io.element.android.features.messages.impl.timeline
import android.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -88,8 +89,10 @@ import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPr
import io.element.android.features.messages.impl.timeline.focus.FocusRequestStateView
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemSelectionData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentPreviewParam
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
@@ -108,6 +111,7 @@ import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
@@ -181,6 +185,18 @@ fun TimelineView(
state.eventSink(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS))
}
val isSelectionModeActive = state.isSelectionModeActive
// Animate once here (shared by every row) rather than per-row: the value is identical for all
// rows since it only depends on the timeline-wide selection mode.
val selectionProgress by animateFloatAsState(
targetValue = if (isSelectionModeActive) 1f else 0f,
label = "selectionProgress",
)
fun onToggleSelection(event: TimelineItem.Event) {
val eventId = event.eventId ?: return
state.eventSink(TimelineEvent.ToggleSelection(eventId))
}
// Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
AnimatedVisibility(visible = true, enter = fadeIn()) {
val composeLocalTimelineEventRendererConfig = remember(state.useNewTimelineEventRenderer, state.timelineRoomInfo.currentUserId) {
@@ -213,12 +229,22 @@ fun TimelineView(
isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()),
focusedEventId = state.focusedEventId,
displayThreadSummaries = state.displayThreadSummaries,
selectionData = TimelineItemSelectionData(
isSelectionModeActive = isSelectionModeActive,
progress = selectionProgress,
isSelected = state.isSelected(timelineItem),
canBeSelected = state.canSelect(timelineItem),
),
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = ::onLinkLongClick,
onContentClick = onContentClick,
onContentClick = { event ->
if (state.canSelect(event)) onToggleSelection(event) else onContentClick(event)
},
onGalleryItemClick = onGalleryItemClick,
onLongClick = onMessageLongClick,
onLongClick = { event ->
if (state.canSelect(event)) onToggleSelection(event) else onMessageLongClick(event)
},
inReplyToClick = ::inReplyToClick,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
@@ -571,7 +597,6 @@ private fun JumpToPositionButton(
modifier = Modifier
.align(dotAlignment)
.offset { IntOffset(x = 0, y = dotYOffset.roundToPx()) },
color = ElementTheme.colors.iconSuccessPrimary,
border = BorderStroke(2.dp, ElementTheme.colors.bgCanvasDefault),
count = 0,
)
@@ -617,6 +642,36 @@ internal fun TimelineViewPreview(
}
}
@PreviewsDayNight
@Composable
internal fun TimelineViewSelectionModePreview() = ElementPreview {
val timelineItems = aTimelineItemList(aTimelineItemTextContent())
val selectedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().first { SelectionAction.Forward.canApplyTo(it) }.eventId!!
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
) {
TimelineView(
state = aTimelineState(
timelineItems = timelineItems,
selectionState = SelectionState.Active(SelectionAction.Forward, persistentSetOf(selectedEventId)),
),
timelineProtectionState = aTimelineProtectionState(),
onUserDataClick = {},
onLinkClick = {},
onContentClick = {},
onMessageLongClick = {},
onSwipeToReply = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onJoinCallClick = {},
onMoreReactionsClick = {},
onReadReceiptClick = {},
onGalleryItemClick = { _, _ -> },
forceJumpToBottomVisibility = true,
)
}
}
// The jump-to-unread FAB's indicator is always rendered when the FAB is visible — in
// production the FAB only appears when unread content exists above. So `hasUnreadAbove`
// here only varies the scroll-target state, not the upper indicator's visibility.
@@ -32,6 +32,8 @@ import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.layout.HorizontalAlignmentLine
import androidx.compose.ui.layout.layout
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
@@ -53,12 +55,20 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.libraries.ui.utils.graphics.drawInLayer
import kotlin.math.min
private val BUBBLE_RADIUS = 12.dp
private val avatarRadius = AvatarSize.TimelineSender.dp / 2
private val MIN_BUBBLE_WIDTH = 80.dp
/**
* Alignment line published by [MessageEventBubble] at its vertical center.
* It lets the timeline row align decorations with the bubble itself, instead of with the whole row, whose height also
* covers the sender information, the reactions, the thread summary and the read receipts.
*/
val BubbleVerticalCenter = HorizontalAlignmentLine(::min)
@Composable
fun MessageEventBubble(
state: BubbleState,
@@ -93,6 +103,12 @@ fun MessageEventBubble(
val updatedBorderColor by rememberUpdatedState(borderColor)
BoxWithConstraints(
modifier = modifier
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height, mapOf(BubbleVerticalCenter to placeable.height / 2)) {
placeable.place(0, 0)
}
}
.drawWithCache {
// Calculate the outline of the background and cache it
val outline = bubbleShape.createOutline(size, layoutDirection, this)
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Checkbox
/**
* Selection indicator used in the timeline selection mode. It renders the Compound [Checkbox], with the minimum
* interactive size enforcement disabled so that it fits the compact selection column. It is purely visual: the tap
* is handled by the whole-row click target, so this only reflects [selected].
*/
@Composable
internal fun SelectionIndicator(
selected: Boolean,
modifier: Modifier = Modifier,
) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
Checkbox(
modifier = modifier,
checked = selected,
onCheckedChange = null,
)
}
}
@PreviewsDayNight
@Composable
internal fun SelectionIndicatorPreview() = ElementPreview {
SelectionIndicator(selected = false)
}
@PreviewsDayNight
@Composable
internal fun SelectionIndicatorSelectedPreview() = ElementPreview {
SelectionIndicator(selected = true)
}
@@ -55,13 +55,20 @@ fun TimelineEventTimestampView(
val shield = event.messageShield
val isVerifiedUserSendFailure = event.localSendState is LocalEventSendState.Failed.VerifiedUser
val onClickLabel = when {
hasError -> stringResource(CommonStrings.a11y_view_details)
shield != null -> stringResource(CommonStrings.a11y_view_details)
isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu)
hasError -> stringResource(CommonStrings.a11y_view_details)
else -> null
}
val clickableModifier = remember(event) {
when {
hasError -> {
Modifier.clickable(
onClickLabel = onClickLabel,
) {
eventSink(TimelineEvent.ShowSendFailureDialog(event))
}
}
shield != null -> {
Modifier.clickable(
onClickLabel = onClickLabel,
@@ -69,12 +76,6 @@ fun TimelineEventTimestampView(
eventSink(TimelineEvent.ShowShieldDialog(shield))
}
}
hasError -> Modifier
.clickable(
onClickLabel = onClickLabel,
) {
eventSink(TimelineEvent.ShowSendFailureDialog(event))
}
else -> Modifier
}
}
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -59,6 +60,7 @@ import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
@@ -180,6 +182,9 @@ fun TimelineItemEventRow(
onSwipeToReply: () -> Unit,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
modifier: Modifier = Modifier,
// Horizontal offset applied to this row's content by selection mode. The read receipts are
// anchored to the end edge and should stay pinned, so they are counter-offset by this amount.
selectionContentOffset: Dp = 0.dp,
eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange ->
// Only pass down a custom clickable lambda if the content can be clicked separately
val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable }
@@ -319,14 +324,18 @@ fun TimelineItemEventRow(
}
// Read receipts / Send state
val sendState = event.pendingSend()?.second
TimelineItemReadReceiptView(
state = ReadReceiptViewState(
sendState = event.localSendState,
sendState = sendState,
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts,
),
onReadReceiptsClick = { onReadReceiptClick(event) },
modifier = Modifier.padding(top = 4.dp)
// Counter the selection-mode content slide so the end-anchored read receipts stay pinned.
modifier = Modifier
.padding(top = 4.dp)
.offset(x = -selectionContentOffset)
)
}
}
@@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.components.receipt.Rea
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.groups.isRedactedMessagesGroup
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemSelectionData
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -190,6 +191,7 @@ private fun TimelineItemGroupedEventsRowContent(
onSwipeToReply = {},
eventSink = eventSink,
eventContentView = eventContentView,
selectionData = TimelineItemSelectionData(),
)
}
}
@@ -8,28 +8,39 @@
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.AlignmentLine
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemSelectionData
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
@@ -60,6 +71,7 @@ internal fun TimelineItemRow(
timelineProtectionState: TimelineProtectionState,
focusedEventId: EventId?,
displayThreadSummaries: Boolean,
selectionData: TimelineItemSelectionData,
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
@@ -92,17 +104,148 @@ internal fun TimelineItemRow(
)
},
) {
val backgroundModifier = if (timelineItem.isEvent(focusedEventId)) {
val focusedEventOffset = if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) {
14.dp
} else {
2.dp
val isSelectionModeActive = selectionData.isSelectionModeActive
val selectionProgress = selectionData.progress
val isSelected = selectionData.isSelected
val backgroundModifier = when {
timelineItem.isEvent(focusedEventId) && !isSelectionModeActive -> {
val focusedEventOffset =
if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) {
14.dp
} else {
2.dp
}
Modifier.focusedEvent(focusedEventOffset)
}
else -> {
Modifier
}
Modifier.focusedEvent(focusedEventOffset)
} else {
Modifier
}
Box(modifier = modifier.then(backgroundModifier)) {
val selectableEvent = (timelineItem as? TimelineItem.Event)?.takeIf { selectionData.canBeSelected }
// Only start-aligned message bubbles slide to make room for the selection indicator:
// - Outgoing (own) messages are end-aligned, opposite the indicator, so they already have room.
// - Centered tiles (state events, call tiles, day dividers, grouped events) must stay put.
val isOutgoing = (timelineItem as? TimelineItem.Event)?.isMine == true
val shouldSlideContent = timelineItem.rendersAsBubble() && !isOutgoing
val layoutDirection = LocalLayoutDirection.current
val contentSlide = if (shouldSlideContent) SELECTION_COLUMN_WIDTH * selectionProgress else 0.dp
Layout(
modifier = modifier.then(backgroundModifier),
contents = listOf(
{
TimelineItemRowContent(
modifier = Modifier
.fillMaxWidth()
.offset(x = contentSlide),
selectionContentOffset = contentSlide,
timelineItem = timelineItem,
timelineMode = timelineMode,
timelineRoomInfo = timelineRoomInfo,
timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = isLastOutgoingMessage,
focusedEventId = focusedEventId,
displayThreadSummaries = displayThreadSummaries,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onContentClick = onContentClick,
onGalleryItemClick = onGalleryItemClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onJoinCallClick = onJoinCallClick,
onSwipeToReply = onSwipeToReply,
eventSink = eventSink,
eventContentView = eventContentView,
)
},
{
if (selectableEvent != null && selectionProgress > 0f) {
val slideSign = if (layoutDirection == LayoutDirection.Ltr) -1f else 1f
SelectionIndicator(
selected = isSelected,
modifier = Modifier
.graphicsLayer { translationX = slideSign * SELECTION_COLUMN_WIDTH.toPx() * (1f - selectionProgress) }
.padding(start = 16.dp)
.size(24.dp),
)
}
},
{
if (isSelectionModeActive) {
// In selection mode the whole row toggles selection.
Box(modifier = Modifier.clickable(enabled = selectionData.canBeSelected, role = Role.Checkbox) {
if (selectableEvent != null) {
onContentClick(selectableEvent)
}
})
}
},
),
) { (contentMeasurables, indicatorMeasurables, overlayMeasurables), constraints ->
val contentPlaceable = contentMeasurables.first().measure(constraints)
val width = contentPlaceable.width
val height = contentPlaceable.height
val indicatorPlaceable = indicatorMeasurables.firstOrNull()?.measure(constraints.copy(minWidth = 0, minHeight = 0))
val overlayPlaceable = overlayMeasurables.firstOrNull()?.measure(Constraints.fixed(width, height))
layout(width = width, height = height) {
contentPlaceable.place(0, 0)
if (indicatorPlaceable != null) {
val bubbleCenter = contentPlaceable[BubbleVerticalCenter].takeIf { it != AlignmentLine.Unspecified } ?: height / 2
val x = if (layoutDirection == LayoutDirection.Ltr) 0 else width - indicatorPlaceable.width
indicatorPlaceable.place(x = x, y = bubbleCenter - indicatorPlaceable.height / 2)
}
overlayPlaceable?.place(0, 0)
}
}
}
/** Width reserved for the selection indicator : the row content slides by this amount. */
private val SELECTION_COLUMN_WIDTH = 36.dp
/**
* Whether this item is rendered as a start/end-aligned message bubble, as opposed to a centered
* tile (state event, legacy call invite, call/RTC notification, day divider, grouped events).
* Mirrors the dispatch in [TimelineItemRowContent]: only bubbles shift to reveal the selection
* indicator; centered tiles stay put.
*/
private fun TimelineItem.rendersAsBubble(): Boolean = this is TimelineItem.Event &&
content !is TimelineItemStateContent &&
content !is TimelineItemLegacyCallInviteContent &&
content !is TimelineItemRtcNotificationContent
@Suppress("LongMethod")
@Composable
private fun TimelineItemRowContent(
timelineItem: TimelineItem,
timelineMode: Timeline.Mode,
timelineRoomInfo: TimelineRoomInfo,
isLastOutgoingMessage: Boolean,
timelineProtectionState: TimelineProtectionState,
focusedEventId: EventId?,
displayThreadSummaries: Boolean,
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onContentClick: (TimelineItem.Event) -> Unit,
onGalleryItemClick: (TimelineItem.Event, Int) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
modifier: Modifier = Modifier,
selectionContentOffset: Dp = 0.dp,
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit,
) {
Box(modifier) {
when (timelineItem) {
is TimelineItem.Virtual -> {
TimelineItemVirtualRow(
@@ -197,6 +340,7 @@ internal fun TimelineItemRow(
onSwipeToReply = { onSwipeToReply(timelineItem) },
onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) },
eventSink = eventSink,
selectionContentOffset = selectionContentOffset,
eventContentView = { contentModifier, onContentLayoutChange ->
eventContentView(timelineItem, contentModifier, onContentLayoutChange)
},
@@ -20,9 +20,9 @@ import kotlinx.collections.immutable.toImmutableList
class ReadReceiptViewStatePreviewParam : PreviewParameterProvider<ReadReceiptViewState> {
override val values: Sequence<ReadReceiptViewState>
get() = sequenceOf(
aReadReceiptViewState(),
aReadReceiptViewState(sendState = LocalEventSendState.Sending.Event),
aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId"))),
aReadReceiptViewState(sendState = null, isLastOutgoingMessage = true),
aReadReceiptViewState(sendState = LocalEventSendState.Sending.Event, isLastOutgoingMessage = true),
aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId")), isLastOutgoingMessage = true),
aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = List(1) { aReadReceiptData(it) },
@@ -43,12 +43,15 @@ class ReadReceiptViewStatePreviewParam : PreviewParameterProvider<ReadReceiptVie
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = List(5) { aReadReceiptData(it) },
),
aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId")), isLastOutgoingMessage = false),
aReadReceiptViewState(sendState = LocalEventSendState.Sending.Event, isLastOutgoingMessage = false),
aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId")), isLastOutgoingMessage = false),
)
}
internal fun aReadReceiptViewState(
sendState: LocalEventSendState? = null,
isLastOutgoingMessage: Boolean = true,
isLastOutgoingMessage: Boolean = false,
receipts: List<ReadReceiptData> = emptyList(),
) = ReadReceiptViewState(
sendState = sendState,
@@ -57,43 +57,60 @@ fun TimelineItemReadReceiptView(
onReadReceiptsClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (state.receipts.isNotEmpty()) {
ReadReceiptsRow(modifier = modifier) {
ReadReceiptsAvatars(
receipts = state.receipts,
onClick = onReadReceiptsClick,
)
val content: @Composable (() -> Unit)? = when (state.sendState) {
// If null, assume it's Sent and act the same way
null if state.isLastOutgoingMessage && state.receipts.isEmpty() -> {
@Composable { SentIcon() }
}
} else {
when (state.sendState) {
is LocalEventSendState.Sending -> {
ReadReceiptsRow(modifier) {
Icon(
modifier = Modifier.padding(2.dp),
imageVector = CompoundIcons.Circle(),
contentDescription = stringResource(id = CommonStrings.common_sending),
tint = ElementTheme.colors.iconSecondary
)
}
}
is LocalEventSendState.Failed -> {
// Error? The timestamp is already displayed in red
}
null,
is LocalEventSendState.Sent -> {
if (state.isLastOutgoingMessage) {
ReadReceiptsRow(modifier = modifier) {
Icon(
modifier = Modifier.padding(2.dp),
imageVector = CompoundIcons.CheckCircle(),
contentDescription = stringResource(id = CommonStrings.common_sent),
tint = ElementTheme.colors.iconSecondary
)
}
}
is LocalEventSendState.Sending -> {
@Composable { SendingIcon() }
}
is LocalEventSendState.Failed -> {
// Error? The timestamp is already displayed in red
null
}
is LocalEventSendState.Sent if state.isLastOutgoingMessage && state.receipts.isEmpty() -> {
@Composable { SentIcon() }
}
else -> if (state.receipts.isNotEmpty()) {
@Composable {
ReadReceiptsAvatars(
receipts = state.receipts,
onClick = onReadReceiptsClick,
)
}
} else {
null
}
}
// Only show the row if there is content to display
content?.let {
ReadReceiptsRow(
modifier = modifier,
content = it,
)
}
}
@Composable
private fun SendingIcon() {
Icon(
modifier = Modifier.padding(2.dp),
imageVector = CompoundIcons.Circle(),
contentDescription = stringResource(id = CommonStrings.common_sending),
tint = ElementTheme.colors.iconSecondary
)
}
@Composable
private fun SentIcon() {
Icon(
modifier = Modifier.padding(2.dp),
imageVector = CompoundIcons.CheckCircle(),
contentDescription = stringResource(id = CommonStrings.common_sent),
tint = ElementTheme.colors.iconSecondary
)
}
@Composable
@@ -19,7 +19,7 @@ class ReadReceiptBottomSheetStatePreviewParam : PreviewParameterProvider<ReadRec
// Reuse the provider ReadReceiptViewStatePreviewParam
private val readReceiptViewStateProvider = ReadReceiptViewStatePreviewParam()
override val values: Sequence<ReadReceiptBottomSheetState> = readReceiptViewStateProvider.values
.filter { it.sendState is LocalEventSendState.Sent }
.filter { it.sendState is LocalEventSendState.Sent && it.receipts.isNotEmpty() }
.map { readReceiptViewState ->
ReadReceiptBottomSheetState(
selectedEvent = aTimelineItemEvent(
@@ -123,6 +123,8 @@ class TimelineItemEventFactory(
reactionsState = currentTimelineItem.computeReactionsState(),
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers, renderReadReceipts),
localSendState = currentTimelineItem.event.localSendState,
editSendState = currentTimelineItem.event.editSendState,
redactionSendState = currentTimelineItem.event.redactionSendState,
inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser),
threadInfo = mappedThreadInfo,
origin = currentTimelineItem.event.origin,
@@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.SendTarget
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
@@ -92,6 +93,8 @@ sealed interface TimelineItem {
val reactionsState: TimelineItemReactions,
val readReceiptState: TimelineItemReadReceipts,
val localSendState: LocalEventSendState?,
private val editSendState: LocalEventSendState?,
private val redactionSendState: LocalEventSendState?,
val inReplyTo: InReplyToDetails?,
val threadInfo: TimelineItemThreadInfo?,
val origin: TimelineItemEventOrigin?,
@@ -110,7 +113,7 @@ sealed interface TimelineItem {
val safeSenderName: String = senderProfile.getDisambiguatedDisplayName(senderId)
val failedToSend: Boolean = localSendState is LocalEventSendState.Failed
val failedToSend: Boolean = listOf(localSendState, editSendState, redactionSendState).any { it is LocalEventSendState.Failed }
val isTextMessage: Boolean = content is TimelineItemTextBasedContent
@@ -143,6 +146,18 @@ sealed interface TimelineItem {
get() = timelineItemDebugInfoProvider()
val sendhandle: SendHandle? get() = sendHandleProvider()
/**
* Returns the first pending send state, if any, along with the corresponding [SendTarget].
*/
fun pendingSend(): Pair<SendTarget, LocalEventSendState>? {
return when {
localSendState != null -> SendTarget.Event to localSendState
editSendState != null -> SendTarget.Edit to editSendState
redactionSendState != null -> SendTarget.Redaction to redactionSendState
else -> null
}
}
}
data class GroupedEvents(
@@ -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.features.messages.impl.timeline.model
/**
* Row-scoped selection state passed to [io.element.android.features.messages.impl.timeline.components.TimelineItemRow].
*
* @param isSelectionModeActive whether the timeline is currently in selection mode. Drives
* behaviour (e.g. the whole-row click target) and must reflect the real mode, not [progress].
* @param isSelected whether this specific row's event is part of the current selection.
* @param canBeSelected whether this specific row's event can be selected for the action selection mode was entered for.
* Rows which cannot be selected render no indicator and ignore clicks, but their content still slides: sliding only depends on the row being a
* start-aligned bubble, so that the bubbles stay aligned with each other.
* @param progress animated 0f..1f value driving the visuals (content slide + indicator slide-in).
* Animated once in TimelineView and shared by every row. Differs from [isSelectionModeActive]
* during the enter/exit animation.
*/
data class TimelineItemSelectionData(
val isSelectionModeActive: Boolean = false,
val isSelected: Boolean = false,
val canBeSelected: Boolean = false,
val progress: Float = 0f,
)
@@ -50,16 +50,18 @@ fun SendFailureDialogView(
)
}
val (sendTarget, _) = sendFailureDialogState.event.pendingSend() ?: null to null
ConfirmationDialog(
modifier = modifier,
title = stringResource(id = CommonStrings.common_sending_failed),
content = content,
onDismiss = onDismiss,
submitText = stringResource(id = CommonStrings.action_retry),
onSubmitClick = { onRetry(sendFailureDialogState.event) },
onSubmitClick = { sendTarget?.let { onRetry(sendFailureDialogState.event) } },
onCancelClick = onDismiss,
thirdButtonText = stringResource(id = CommonStrings.action_remove_message),
onThirdButtonClick = { onRemoveMessage(sendFailureDialogState.event) },
onThirdButtonClick = { sendTarget?.let { onRemoveMessage(sendFailureDialogState.event) } },
)
}
}
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.topbars
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.SelectionAction
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.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SelectionModeTopBar(
selectedCount: Int,
action: SelectionAction,
onCancelClick: () -> Unit,
onConfirmClick: () -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
modifier = modifier,
title = {
Text(text = pluralStringResource(CommonPlurals.common_selected_count, selectedCount, selectedCount))
},
navigationIcon = {
IconButton(
onClick = onCancelClick,
) {
Icon(imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_close))
}
},
actions = {
IconButton(
onClick = onConfirmClick,
enabled = selectedCount > 0,
) {
Icon(
resourceId = action.iconRes,
contentDescription = stringResource(action.titleRes),
)
}
},
)
}
@PreviewsDayNight
@Composable
internal fun SelectionModeTopBarPreview(
@PreviewParameter(SelectionCountPreviewParam::class) count: Int,
) = ElementPreview {
SelectionModeTopBar(
selectedCount = count,
action = SelectionAction.Forward,
onCancelClick = {},
onConfirmClick = {},
)
}
internal class SelectionCountPreviewParam : PreviewParameterProvider<Int> {
override val values = sequenceOf(0, 1, 5)
}
@@ -20,7 +20,7 @@ import kotlinx.collections.immutable.ImmutableList
class FakeMessagesNavigator(
private val onShowEventDebugInfoClickLambda: (eventId: EventId?, debugInfo: TimelineItemDebugInfo) -> Unit = { _, _ -> lambdaError() },
private val onForwardEventClickLambda: (eventId: EventId, timelineProvider: TimelineProvider) -> Unit = { _, _ -> lambdaError() },
private val onForwardEventClickLambda: (eventIds: List<EventId>, timelineProvider: TimelineProvider) -> Unit = { _, _ -> lambdaError() },
private val onReportContentClickLambda: (eventId: EventId, senderId: UserId) -> Unit = { _, _ -> lambdaError() },
private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() },
private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
@@ -35,8 +35,8 @@ class FakeMessagesNavigator(
onShowEventDebugInfoClickLambda(eventId, debugInfo)
}
override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) {
onForwardEventClickLambda(eventId, timelineProvider)
override fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
onForwardEventClickLambda(eventIds, timelineProvider)
}
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
@@ -28,8 +28,12 @@ import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMess
import io.element.android.features.messages.impl.threads.list.aThreadListItem
import io.element.android.features.messages.impl.timeline.FakeMarkAsFullyRead
import io.element.android.features.messages.impl.timeline.MarkAsFullyRead
import io.element.android.features.messages.impl.timeline.SelectionAction
import io.element.android.features.messages.impl.timeline.SelectionState
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
@@ -78,6 +82,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSen
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.A_REASON
@@ -88,7 +93,6 @@ import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.A_TRANSACTION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.core.FakeSendHandle
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
@@ -118,6 +122,7 @@ import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.element.android.tests.testutils.testWithLifecycleOwner
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -289,17 +294,37 @@ class MessagesPresenterTest {
}
@Test
fun `present - handle action forward`() = runTest {
val onForwardEventClickLambda = lambdaRecorder<EventId, TimelineProvider, Unit> { _, _ -> }
val navigator = FakeMessagesNavigator(
onForwardEventClickLambda = onForwardEventClickLambda,
fun `present - handle action forward enters selection mode when multi select is enabled`() = runTest {
val timelineEventSink = lambdaRecorder<TimelineEvent, Unit> { }
val presenter = createMessagesPresenter(
timelineEventSink = timelineEventSink,
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
)
val presenter = createMessagesPresenter(navigator = navigator)
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
onForwardEventClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID), any())
timelineEventSink.assertions().isCalledOnce()
.with(value(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - handle action forward navigates directly when multi select is disabled`() = runTest {
val onForwardEventClickLambda = lambdaRecorder<List<EventId>, TimelineProvider, Unit> { _, _ -> }
val navigator = FakeMessagesNavigator(
onForwardEventClickLambda = onForwardEventClickLambda,
)
val presenter = createMessagesPresenter(
navigator = navigator,
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to false)),
)
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
onForwardEventClickLambda.assertions().isCalledOnce().with(value(listOf(AN_EVENT_ID)), any())
}
}
@@ -620,24 +645,24 @@ class MessagesPresenterTest {
}
@Test
fun `present - handle action redact - a message which was never sent is redacted without asking`() = runTest {
fun `present - handle action redact - a message which was never sent is removed from the send queue without asking`() = runTest {
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
val liveTimeline = FakeTimeline()
val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) }
liveTimeline.redactEventLambda = redactEventLambda
val abortSendLambda = lambdaRecorder { _: EventOrTransactionId -> Result.success(true) }
val presenter = createMessagesPresenter(
timeline = liveTimeline,
timeline = FakeTimeline(abortSendResult = abortSendLambda),
coroutineDispatchers = coroutineDispatchers,
)
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
val localEcho = aMessageEvent(eventId = null, transactionId = A_TRANSACTION_ID)
val localEcho = aMessageEvent(
eventId = null,
transactionId = A_TRANSACTION_ID,
sendState = LocalEventSendState.Failed.Unknown("Error"),
)
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, localEcho))
advanceUntilIdle()
assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized)
assert(redactEventLambda)
.isCalledOnce()
.with(value(localEcho.eventOrTransactionId), value(null))
abortSendLambda.assertions().isCalledOnce().with(value(localEcho.eventOrTransactionId))
}
}
@@ -1151,29 +1176,27 @@ class MessagesPresenterTest {
@Test
fun `present - handle action retry sending`() = runTest {
val retryLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val retryLambda = lambdaRecorder { _: EventOrTransactionId -> Result.success(true) }
val messageEvent = aMessageEvent(
sendState = LocalEventSendState.Failed.Unknown("Error"),
sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) },
)
val presenter = createMessagesPresenter()
val presenter = createMessagesPresenter(timeline = FakeTimeline(retrySendResult = retryLambda))
presenter.testWithLifecycleOwner {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent))
advanceUntilIdle()
retryLambda.assertions().isCalledOnce()
retryLambda.assertions().isCalledOnce().with(value(messageEvent.eventOrTransactionId))
}
}
@Test
fun `present - handle action retry sending - failure is ignored`() = runTest {
val retryLambda = lambdaRecorder<Result<Unit>> { Result.failure(AN_EXCEPTION) }
val retryLambda = lambdaRecorder { _: EventOrTransactionId -> Result.failure<Boolean>(AN_EXCEPTION) }
val messageEvent = aMessageEvent(
sendState = LocalEventSendState.Failed.Unknown("Error"),
sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) },
)
val presenter = createMessagesPresenter()
val presenter = createMessagesPresenter(timeline = FakeTimeline(retrySendResult = retryLambda))
presenter.testWithLifecycleOwner {
skipItems(1)
val initialState = awaitItem()
@@ -1184,18 +1207,16 @@ class MessagesPresenterTest {
}
@Test
fun `present - handle action retry sending - no send handle, it should have no effect`() = runTest {
val messageEvent = aMessageEvent(
sendState = LocalEventSendState.Failed.Unknown("Error"),
sendHandleProvider = { null },
)
val presenter = createMessagesPresenter()
fun `present - handle action retry sending - no pending send state, it should have no effect`() = runTest {
val retryLambda = lambdaRecorder { _: EventOrTransactionId -> Result.success(true) }
val messageEvent = aMessageEvent(sendState = null)
val presenter = createMessagesPresenter(timeline = FakeTimeline(retrySendResult = retryLambda))
presenter.testWithLifecycleOwner {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent))
advanceUntilIdle()
// No op!
retryLambda.assertions().isNeverCalled()
}
}
@@ -1501,6 +1522,49 @@ class MessagesPresenterTest {
canPinUnpin = canPinUnpin,
)
@Test
fun `present - confirm selection action forwards the selection in timeline order`() = runTest {
val onForwardEventClickLambda = lambdaRecorder<List<EventId>, TimelineProvider, Unit> { _, _ -> }
val navigator = FakeMessagesNavigator(onForwardEventClickLambda = onForwardEventClickLambda)
val timelineEventSink = lambdaRecorder<TimelineEvent, Unit> { }
val presenter = createMessagesPresenter(
navigator = navigator,
timelineEventSink = timelineEventSink,
timelineState = aTimelineState(
// The timeline is rendered from the most recent event to the oldest one.
timelineItems = persistentListOf(
aTimelineItemEvent(eventId = AN_EVENT_ID_2),
aTimelineItemEvent(eventId = AN_EVENT_ID),
),
selectionState = SelectionState.Active(
SelectionAction.Forward,
persistentSetOf(AN_EVENT_ID_2, AN_EVENT_ID),
),
eventSink = timelineEventSink,
),
)
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvent.ConfirmSelectionAction)
onForwardEventClickLambda.assertions().isCalledOnce().with(value(listOf(AN_EVENT_ID, AN_EVENT_ID_2)), any())
timelineEventSink.assertions().isCalledOnce().with(value(TimelineEvent.ExitSelectionMode))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - confirm selection action does nothing when no selection is active`() = runTest {
val onForwardEventClickLambda = lambdaRecorder<List<EventId>, TimelineProvider, Unit> { _, _ -> }
val navigator = FakeMessagesNavigator(onForwardEventClickLambda = onForwardEventClickLambda)
val presenter = createMessagesPresenter(navigator = navigator)
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvent.ConfirmSelectionAction)
onForwardEventClickLambda.assertions().isNeverCalled()
cancelAndIgnoreRemainingEvents()
}
}
private fun TestScope.createMessagesPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
timeline: Timeline = FakeTimeline(),
@@ -1523,6 +1587,7 @@ class MessagesPresenterTest {
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
timelineEventSink: (TimelineEvent) -> Unit = {},
timelineState: TimelineState = aTimelineState(eventSink = timelineEventSink),
permalinkParser: PermalinkParser = FakePermalinkParser(),
messageComposerPresenter: Presenter<MessageComposerState> = Presenter {
aMessageComposerState(
@@ -1545,7 +1610,7 @@ class MessagesPresenterTest {
room = joinedRoom,
composerPresenter = messageComposerPresenter,
voiceMessageComposerPresenterFactory = FakeDefaultVoiceMessageComposerPresenterFactory(backgroundScope),
timelinePresenter = { aTimelineState(eventSink = timelineEventSink) },
timelinePresenter = { timelineState },
timelineProtectionPresenter = { aTimelineProtectionState() },
identityChangeStatePresenter = { anIdentityChangeState() },
linkPresenter = { aLinkState() },
@@ -606,7 +606,7 @@ class MessagesViewTest : RobolectricTest() {
timelineEventsRecorder.clear()
clickOn(CommonStrings.action_remove_message)
messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.Redact, timelineItem))
messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.AbortSending, timelineItem))
timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog)
}
@@ -43,7 +43,7 @@ internal fun aMessageEvent(
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = A_MESSAGE, isEdited = false),
inReplyTo: InReplyToDetails? = null,
threadInfo: TimelineItemThreadInfo? = null,
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
sendState: LocalEventSendState? = LocalEventSendState.Sent(AN_EVENT_ID),
debugInfoProvider: TimelineItemDebugInfoProvider = TimelineItemDebugInfoProvider { aTimelineItemDebugInfo() },
messageShieldProvider: MessageShieldProvider = MessageShieldProvider { null },
sendHandleProvider: SendHandleProvider = SendHandleProvider { FakeSendHandle() }
@@ -62,6 +62,8 @@ internal fun aMessageEvent(
reactionsState = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
localSendState = sendState,
editSendState = null,
redactionSendState = null,
inReplyTo = inReplyTo,
threadInfo = threadInfo,
origin = null,
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.timeline.model.event.aStaticLocationMode
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import org.junit.Test
class SelectionActionTest {
@Test
fun `Forward - a remote text message can be selected`() {
assertThat(SelectionAction.Forward.canApplyTo(aTimelineItemEvent())).isTrue()
}
@Test
fun `Forward - a local echo cannot be selected`() {
assertThat(SelectionAction.Forward.canApplyTo(aTimelineItemEvent().copy(eventId = null))).isFalse()
}
@Test
fun `Forward - a failed to send message cannot be selected`() {
// A message which failed to send is a local echo: it has no event id.
val item = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("failed")).copy(eventId = null)
assertThat(SelectionAction.Forward.canApplyTo(item)).isFalse()
}
@Test
fun `Forward - a state event cannot be selected`() {
val item = aTimelineItemEvent(content = aTimelineItemStateEventContent())
assertThat(SelectionAction.Forward.canApplyTo(item)).isFalse()
}
@Test
fun `Forward - a redacted event cannot be selected`() {
val item = aTimelineItemEvent(content = aTimelineItemRedactedContent())
assertThat(SelectionAction.Forward.canApplyTo(item)).isFalse()
}
@Test
fun `Forward - a sticker cannot be selected, it cannot be forwarded`() {
val item = aTimelineItemEvent(content = aTimelineItemStickerContent())
assertThat(SelectionAction.Forward.canApplyTo(item)).isFalse()
}
@Test
fun `Forward - a static location can be selected, it can be forwarded`() {
val item = aTimelineItemEvent(content = aTimelineItemLocationContent(mode = aStaticLocationMode()))
assertThat(SelectionAction.Forward.canApplyTo(item)).isTrue()
}
}
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemRedactedContent
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.AN_EVENT_ID_3
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableSet
import org.junit.Test
class SelectionStateTest {
@Test
fun `eventIdsToDeselect - selection mode being disabled has nothing to drop`() {
val items = listOf(aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemRedactedContent()))
assertThat(SelectionState.Disabled.eventIdsToDeselect(items)).isEmpty()
}
@Test
fun `eventIdsToDeselect - an empty selection has nothing to drop`() {
val items = listOf(aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemRedactedContent()))
assertThat(anActiveSelection().eventIdsToDeselect(items)).isEmpty()
}
@Test
fun `eventIdsToDeselect - a selected event the action still applies to is kept`() {
val items = listOf(aTimelineItemEvent(eventId = AN_EVENT_ID))
assertThat(anActiveSelection(listOf(AN_EVENT_ID)).eventIdsToDeselect(items)).isEmpty()
}
@Test
fun `eventIdsToDeselect - a selected event which has been deleted is dropped`() {
val items = listOf(
aTimelineItemEvent(eventId = AN_EVENT_ID_2, content = aTimelineItemRedactedContent()),
aTimelineItemEvent(eventId = AN_EVENT_ID),
)
assertThat(anActiveSelection(listOf(AN_EVENT_ID, AN_EVENT_ID_2)).eventIdsToDeselect(items)).containsExactly(AN_EVENT_ID_2)
}
@Test
fun `eventIdsToDeselect - a selected event which is not in the timeline is ignored`() {
val items = listOf(aTimelineItemEvent(eventId = AN_EVENT_ID))
// AN_EVENT_ID_3 may simply not be loaded anymore, so it is not dropped here.
assertThat(anActiveSelection(listOf(AN_EVENT_ID, AN_EVENT_ID_3)).eventIdsToDeselect(items)).isEmpty()
}
@Test
fun `eventIdsToDeselect - a deleted selected event folded into a group is dropped`() {
val items = listOf(
TimelineItem.GroupedEvents(
id = UniqueId("group"),
events = persistentListOf(
aTimelineItemEvent(eventId = AN_EVENT_ID_2, content = aTimelineItemRedactedContent()),
aTimelineItemEvent(eventId = AN_EVENT_ID_3, content = aTimelineItemRedactedContent()),
),
aggregatedReadReceipts = persistentListOf(),
),
aTimelineItemEvent(eventId = AN_EVENT_ID),
)
assertThat(anActiveSelection(listOf(AN_EVENT_ID, AN_EVENT_ID_2)).eventIdsToDeselect(items)).containsExactly(AN_EVENT_ID_2)
}
@Test
fun `deselect - removes the given events and keeps selection mode active`() {
val selection = anActiveSelection(listOf(AN_EVENT_ID, AN_EVENT_ID_2))
assertThat(selection.deselect(setOf(AN_EVENT_ID))).isEqualTo(anActiveSelection(listOf(AN_EVENT_ID_2)))
assertThat(selection.deselect(setOf(AN_EVENT_ID, AN_EVENT_ID_2))).isEqualTo(anActiveSelection())
}
@Test
fun `deselect - does nothing when selection mode is disabled`() {
assertThat(SelectionState.Disabled.deselect(setOf(AN_EVENT_ID))).isEqualTo(SelectionState.Disabled)
}
private fun anActiveSelection(eventIds: List<EventId> = emptyList()) = SelectionState.Active(
action = SelectionAction.Forward,
selectedEventIds = eventIds.toImmutableSet(),
)
}
@@ -38,6 +38,7 @@ import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.androidutils.toast.FakeToastHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
@@ -91,6 +92,7 @@ import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
@@ -1861,6 +1863,152 @@ class TimelinePresenterTest {
}
}
@Test
fun `present - entering selection mode selects the target event`() = runTest {
val timelineItems = MutableStateFlow(
listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())))
)
val presenter = createTimelinePresenter(
timeline = FakeTimeline(timelineItems = timelineItems),
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
)
presenter.test {
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled)
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
val state = consumeItemsUntilPredicate { it.isSelectionModeActive }.last()
assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID)))
assertThat(state.selectedCount).isEqualTo(1)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - entering selection mode is ignored when the flag is off`() = runTest {
val featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to false))
val presenter = createTimelinePresenter(featureFlagService = featureFlagService)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
expectNoEvents()
assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - toggling the last selected event keeps selection mode active`() = runTest {
val timelineItems = MutableStateFlow(
listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())))
)
val presenter = createTimelinePresenter(
timeline = FakeTimeline(timelineItems = timelineItems),
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
)
presenter.test {
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
consumeItemsUntilPredicate { it.isSelectionModeActive }
initialState.eventSink(TimelineEvent.ToggleSelection(AN_EVENT_ID))
// Selection mode is only exited explicitly, so an empty selection keeps it active.
val state = consumeItemsUntilPredicate { it.selectedCount == 0 }.last()
assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf()))
assertThat(state.isSelectionModeActive).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - exiting selection mode clears the selection`() = runTest {
val timelineItems = MutableStateFlow(
listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())))
)
val presenter = createTimelinePresenter(
timeline = FakeTimeline(timelineItems = timelineItems),
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
)
presenter.test {
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
consumeItemsUntilPredicate { it.isSelectionModeActive }
initialState.eventSink(TimelineEvent.ExitSelectionMode)
val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last()
assertThat(state.selectionState).isEqualTo(SelectionState.Disabled)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - turning the flag off clears the active selection`() = runTest {
val timelineItems = MutableStateFlow(
listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())))
)
val featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true))
val presenter = createTimelinePresenter(
timeline = FakeTimeline(timelineItems = timelineItems),
featureFlagService = featureFlagService,
)
presenter.test {
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
consumeItemsUntilPredicate { it.isSelectionModeActive }
featureFlagService.setFeatureEnabled(FeatureFlags.MessageMultiSelect, false)
val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last()
assertThat(state.selectionState).isEqualTo(SelectionState.Disabled)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - a selected event which cannot be forwarded anymore is deselected`() = runTest {
val timelineItems = MutableStateFlow(
listOf(
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())),
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(eventId = AN_EVENT_ID_2, content = aMessageContent())),
)
)
val presenter = createTimelinePresenter(
timeline = FakeTimeline(timelineItems = timelineItems),
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
)
presenter.test {
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
initialState.eventSink(TimelineEvent.ToggleSelection(AN_EVENT_ID_2))
consumeItemsUntilPredicate { it.selectedCount == 2 }
// The first message is deleted, so it cannot be forwarded anymore.
timelineItems.value = listOf(
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aRedactedContent())),
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(eventId = AN_EVENT_ID_2, content = aMessageContent())),
)
val state = consumeItemsUntilPredicate { it.selectedCount == 1 }.last()
assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID_2)))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - the selection survives a timeline which no longer contains the selected event`() = runTest {
val timelineItems = MutableStateFlow(
listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())))
)
val presenter = createTimelinePresenter(
timeline = FakeTimeline(timelineItems = timelineItems),
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
)
presenter.test {
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
consumeItemsUntilPredicate { it.isSelectionModeActive }
// The timeline no longer holds the selected event, for instance because it has been reset or because it switched to a detached timeline.
timelineItems.value = emptyList()
val state = consumeItemsUntilPredicate { !it.hasAnyEvent }.last()
// The selection is the user intent: it is only validated when the action is confirmed, so that a transient timeline update cannot drop it.
assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID)))
cancelAndIgnoreRemainingEvents()
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
return awaitItem()
}
@@ -1958,6 +2106,7 @@ class TimelinePresenterTest {
typingNotificationPresenter = { aTypingNotificationState() },
roomCallStatePresenter = { aStandByCallState() },
featureFlagService = featureFlagService,
snackbarDispatcher = SnackbarDispatcher(),
analyticsService = FakeAnalyticsService(),
liveLocationShareManager = liveLocationShareManager,
markAsFullyRead = markAsFullyRead,
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemRedactedContent
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.AN_EVENT_ID_3
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import org.junit.Test
class TimelineStateTest {
@Test
fun `selectedEventIdsInTimelineOrder - when selection is disabled, the list is empty`() {
val state = aTimelineState(
timelineItems = persistentListOf(aTimelineItemEvent(eventId = AN_EVENT_ID)),
selectionState = SelectionState.Disabled,
)
assertThat(state.selectedEventIdsInTimelineOrder()).isEmpty()
}
@Test
fun `selectedEventIdsInTimelineOrder - the events are sorted from the oldest to the most recent one`() {
// The timeline items are rendered from the most recent to the oldest event.
val state = aTimelineState(
timelineItems = persistentListOf(
aTimelineItemEvent(eventId = AN_EVENT_ID_3),
aTimelineItemEvent(eventId = AN_EVENT_ID_2),
aTimelineItemEvent(eventId = AN_EVENT_ID),
),
// The events have been tapped in a random order.
selectionState = SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID_2, AN_EVENT_ID, AN_EVENT_ID_3)),
)
assertThat(state.selectedEventIdsInTimelineOrder()).containsExactly(AN_EVENT_ID, AN_EVENT_ID_2, AN_EVENT_ID_3).inOrder()
}
@Test
fun `selectedEventIdsInTimelineOrder - the events which are not in the timeline are ignored`() {
val state = aTimelineState(
timelineItems = persistentListOf(
aTimelineItemEvent(eventId = AN_EVENT_ID_2),
aTimelineItemEvent(eventId = AN_EVENT_ID),
),
selectionState = SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID_2, AN_EVENT_ID_3)),
)
assertThat(state.selectedEventIdsInTimelineOrder()).containsExactly(AN_EVENT_ID_2)
}
@Test
fun `canSelect - returns false when selection mode is disabled`() {
val event = aTimelineItemEvent(eventId = AN_EVENT_ID)
val state = aTimelineState(
timelineItems = persistentListOf(event),
selectionState = SelectionState.Disabled,
)
assertThat(state.canSelect(event)).isFalse()
}
@Test
fun `canSelect - follows the action predicate when selection mode is active`() {
val forwardable = aTimelineItemEvent(eventId = AN_EVENT_ID)
val notForwardable = aTimelineItemEvent(eventId = AN_EVENT_ID_2, content = aTimelineItemRedactedContent())
val state = aTimelineState(
timelineItems = persistentListOf(forwardable, notForwardable),
selectionState = SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID)),
)
assertThat(state.canSelect(forwardable)).isTrue()
assertThat(state.canSelect(notForwardable)).isFalse()
}
}
@@ -43,6 +43,8 @@ class TimelineItemGrouperTest {
reactionsState = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
editSendState = null,
redactionSendState = null,
isEditable = false,
canBeRepliedTo = false,
inReplyTo = null,
@@ -83,6 +83,8 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
isOwn = false,
isRemote = false,
localSendState = null,
editSendState = null,
redactionSendState = null,
reactions = persistentListOf(),
receipts = persistentListOf(),
sender = A_USER_ID,
@@ -89,6 +89,10 @@ class SecurityAndPrivacyPresenter(
value = sessionEnterpriseService.isEncryptionDisabledByHomeserver()
}
val arePublicRoomsDisabledByHomeserver by produceState(false) {
value = sessionEnterpriseService.arePublicRoomsDisabledByHomeserver()
}
val savedIsVisibleInRoomDirectory = remember { mutableStateOf<AsyncData<Boolean>>(AsyncData.Uninitialized) }
LaunchedEffect(Unit) {
isRoomVisibleInRoomDirectory(savedIsVisibleInRoomDirectory)
@@ -254,6 +258,7 @@ class SecurityAndPrivacyPresenter(
selectableJoinedSpaces = selectableJoinedSpaces,
spaceSelectionMode = spaceSelectionMode,
isEncryptionDisabledByHomeserver = isEncryptionDisabledByHomeserver,
arePublicRoomsDisabledByHomeserver = arePublicRoomsDisabledByHomeserver,
eventSink = ::handleEvent,
)
@@ -28,6 +28,7 @@ data class SecurityAndPrivacyState(
val editedSettings: SecurityAndPrivacySettings,
val homeserverName: String,
val isEncryptionDisabledByHomeserver: Boolean,
private val arePublicRoomsDisabledByHomeserver: Boolean,
val showEnableEncryptionConfirmation: Boolean,
private val isKnockEnabled: Boolean,
val saveAction: AsyncAction<Unit>,
@@ -39,6 +40,11 @@ data class SecurityAndPrivacyState(
) {
val isSpaceMemberSelectable = spaceSelectionMode != SpaceSelectionMode.None
// Show Anyone option when:
// - Anyone is the current saved value, OR
// - The homeserver allows public rooms
val showAnyoneOption = savedSettings.roomAccess == SecurityAndPrivacyRoomAccess.Anyone || !arePublicRoomsDisabledByHomeserver
// Show SpaceMember option in two cases:
// - SpaceMember is the current saved value
// - SpaceMember option is selectable (ie. the FF is enabled and there is at least one space to select)
@@ -37,6 +37,10 @@ open class SecurityAndPrivacyStatePreviewParam : PreviewParameterProvider<Securi
showEncryptionConfirmation = true,
isSpace = false,
),
aSecurityAndPrivacyState(
arePublicRoomsDisabledByHomeserver = true,
isSpace = false,
),
)
}
@@ -139,6 +143,7 @@ fun aSecurityAndPrivacyState(
selectableJoinedSpaces: Set<SpaceRoom> = emptySet(),
spaceSelectionMode: SpaceSelectionMode = SpaceSelectionMode.None,
isEncryptionDisabledByHomeserver: Boolean = false,
arePublicRoomsDisabledByHomeserver: Boolean = false,
eventSink: (SecurityAndPrivacyEvent) -> Unit = {}
) = SecurityAndPrivacyState(
editedSettings = editedSettings,
@@ -152,5 +157,6 @@ fun aSecurityAndPrivacyState(
selectableJoinedSpaces = selectableJoinedSpaces.toImmutableSet(),
spaceSelectionMode = spaceSelectionMode,
isEncryptionDisabledByHomeserver = isEncryptionDisabledByHomeserver,
arePublicRoomsDisabledByHomeserver = arePublicRoomsDisabledByHomeserver,
eventSink = eventSink,
)
@@ -232,13 +232,15 @@ private fun RoomAccessSection(
title = stringResource(R.string.screen_security_and_privacy_room_access_section_header),
modifier = modifier,
) {
ListItem(
content = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_title)) },
supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_description)) },
trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.Anyone),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Public())),
onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.Anyone) },
)
if (state.showAnyoneOption) {
ListItem(
content = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_title)) },
supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_description)) },
trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.Anyone),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Public())),
onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.Anyone) },
)
}
if (state.showSpaceMemberOption) {
ListItem(
content = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_title)) },
@@ -34,6 +34,8 @@ import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.consumeItemsUntilTimeout
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -1048,7 +1050,10 @@ class SecurityAndPrivacyPresenterTest {
@Test
fun `present - showEncryptionSection is false if the HS disabled encryption`() = runTest {
val sessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { true })
val sessionEnterpriseService = FakeSessionEnterpriseService(
isEncryptionDisabledResult = { true },
arePublicRoomsDisabledResult = { false },
)
val presenter = createSecurityAndPrivacyPresenter(
sessionEnterpriseService = sessionEnterpriseService,
)
@@ -1060,6 +1065,42 @@ class SecurityAndPrivacyPresenterTest {
}
}
@Test
fun `present - showAnyoneOption is false if the HS disabled public rooms`() = runTest {
val presenter = createSecurityAndPrivacyPresenter(
sessionEnterpriseService = FakeSessionEnterpriseService(
isEncryptionDisabledResult = { false },
arePublicRoomsDisabledResult = { true },
),
)
presenter.test {
consumeItemsUntilPredicate { !it.showAnyoneOption }
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - showAnyoneOption is true if the HS disabled public rooms but the room is already public`() = runTest {
val presenter = createSecurityAndPrivacyPresenter(
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
getRoomVisibilityResult = { Result.success(RoomVisibility.Public) },
initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Public)
),
),
sessionEnterpriseService = FakeSessionEnterpriseService(
isEncryptionDisabledResult = { false },
arePublicRoomsDisabledResult = { true },
),
)
presenter.test {
val states = consumeItemsUntilTimeout()
assertThat(states).isNotEmpty()
assertThat(states.all { it.showAnyoneOption }).isTrue()
}
}
private fun roomPermissions(
canChangeRoomAccess: Boolean = true,
canChangeHistoryVisibility: Boolean = true,
@@ -1100,6 +1141,7 @@ class SecurityAndPrivacyPresenterTest {
spaceSelectionStateHolder: SpaceSelectionStateHolder = SpaceSelectionStateHolder(),
sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService(
isEncryptionDisabledResult = { false },
arePublicRoomsDisabledResult = { false },
)
): SecurityAndPrivacyPresenter {
return SecurityAndPrivacyPresenter(
+5 -5
View File
@@ -44,7 +44,7 @@ appyx = "1.7.1"
sqldelight = "2.4.0"
wysiwyg = "2.42.1"
telephoto = "0.19.0"
maplibre-compose = "0.18.0"
maplibre-compose = "0.19.0"
# Dependency analysis
dependencyAnalysis = "3.19.2"
@@ -64,7 +64,7 @@ ktlint = "1.8.0"
androidx-test-ext-junit = "1.3.0"
kover = "0.9.9"
# The native MatrixRTC call component, https://github.com/element-hq/element-call-android
element_call = "0.1.0-rc.6"
element_call = "0.1.0-rc.7"
[libraries]
# Project
@@ -208,7 +208,7 @@ matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose",
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqlcipher = "net.zetetic:sqlcipher-android:4.19.0"
sqlcipher = "net.zetetic:sqlcipher-android:4.19.1"
sqlite = "androidx.sqlite:sqlite-ktx:2.7.1"
unifiedpush = "org.unifiedpush.android:connector:3.3.5"
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
@@ -227,7 +227,7 @@ color_picker = "io.mhssn:colorpicker:1.0.0"
# Analytics
posthog = "com.posthog:posthog-android:3.60.7"
sentry = "io.sentry:sentry-android:8.57.0"
sentry = "io.sentry:sentry-android:8.59.0"
# main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.38.0"
@@ -239,7 +239,7 @@ sigpwned_emoji4j = "com.sigpwned:emoji4j-core:16.0.0"
metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro" }
# Element Call
element_call_embedded = "io.element.android:element-call-embedded:0.26.0"
element_call_embedded = "io.element.android:element-call-embedded:0.27.0"
# The native MatrixRTC call component. Versions come from the BOM, so every artifact moves together.
element_call_bom = { module = "io.element.android:element-call-bom", version.ref = "element_call" }
element_call_api = { module = "io.element.android:element-call-api" }
Binary file not shown.
+2 -2
View File
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
@@ -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>
@@ -66,6 +66,7 @@ class KonsistClassNameTest {
"EditableAvatarViewUriPreviewParam",
"LoginModeViewErrorPreviewParam",
"OverlapRatioPreviewParam",
"SelectionCountPreviewParam",
"TextFileContentPreviewParam",
"HtmlMessageContentPreviewParam",
)
@@ -110,6 +110,7 @@ class KonsistPreviewTest {
"MessageComposerViewVoicePreview",
"MessagesReactionButtonAddPreview",
"MessagesReactionButtonExtraPreview",
"MessagesViewSelectionModePreview",
"MessagesViewWithIdentityChangePreview",
"PendingMemberRowWithLongNamePreview",
"PinUnlockViewInAppPreview",
@@ -133,6 +134,7 @@ class KonsistPreviewTest {
"SasEmojisPreview",
"SecureBackupSetupViewChangePreview",
"SelectedUserCannotRemovePreview",
"SelectionIndicatorSelectedPreview",
"SpaceMembersViewNoHeroesPreview",
"SyncStateViewServerUnreachablePreview",
"TextComposerAddCaptionPreview",
@@ -179,6 +181,7 @@ class KonsistPreviewTest {
"TimelineItemVoiceViewScanningContentPreview",
"TimelineItemVoiceViewUnifiedPreview",
"TimelineViewMessageShieldPreview",
"TimelineViewSelectionModePreview",
"TimelineViewWithReadMarkerBothIndicatorsPreview",
"TimelineViewWithReadMarkerJumpToUnreadIndicatorOnlyPreview",
"UserAvatarColorsPreview",

Some files were not shown because too many files have changed in this diff Show More