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) { fun launchIn(coroutineScope: CoroutineScope) {
combine( combine(
syncService.syncState, syncService.syncState,
matrixClient.sendQueueDisabledFlow(), matrixClient.sendQueueDisabledFlow().onEach { Timber.tag("SendQueues").d("Send queue disabled for room: $it") },
) { syncState, _ -> syncState } ) { syncState, _ -> syncState }
.debounce(SEND_QUEUES_RETRY_DELAY_MILLIS) .debounce(SEND_QUEUES_RETRY_DELAY_MILLIS)
.onEach { syncState -> .onEach { syncState ->
@@ -201,7 +201,7 @@ class JoinedRoomLoadedFlowNode(
} else { } else {
(graph as TimelineBindings).timelineProvider (graph as TimelineBindings).timelineProvider
} }
val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider) val params = ForwardEntryPoint.Params(listOf(navTarget.eventId), timelineProvider)
val callback = object : ForwardEntryPoint.Callback { val callback = object : ForwardEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) { override fun onDone(roomIds: List<RoomId>) {
backstack.pop() backstack.pop()
@@ -16,6 +16,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -92,6 +93,9 @@ class ConfigureRoomPresenter(
val isKnockFeatureEnabled by remember { val isKnockFeatureEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock) featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
}.collectAsState(initial = false) }.collectAsState(initial = false)
val arePublicRoomsDisabled by produceState(false) {
value = sessionEnterpriseService.arePublicRoomsDisabledByHomeserver()
}
val roomAddressValidity = remember { val roomAddressValidity = remember {
mutableStateOf<RoomAddressValidity>(RoomAddressValidity.Unknown) mutableStateOf<RoomAddressValidity>(RoomAddressValidity.Unknown)
} }
@@ -145,12 +149,13 @@ class ConfigureRoomPresenter(
// 1. If we are creating a space. // 1. If we are creating a space.
// 2. If it has a parent space. // 2. If it has a parent space.
// 3. If knocking is enabled. // 3. If knocking is enabled.
// 4. If the homeserver forbids public rooms.
val parentSpace = createRoomConfig.parentSpace val parentSpace = createRoomConfig.parentSpace
val availableJoinRules = remember(parentSpace, isSpace, isKnockFeatureEnabled) { val availableJoinRules = remember(parentSpace, isSpace, isKnockFeatureEnabled, arePublicRoomsDisabled) {
when { when {
isSpace && parentSpace != null -> TODO("Adding a space to a parent space is not supported yet! How did you get here?") 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( parentSpace == null || parentSpace.joinRule == JoinRule.Public -> listOfNotNull(
JoinRuleItem.PublicVisibility.Public, JoinRuleItem.PublicVisibility.Public.takeIf { !arePublicRoomsDisabled },
JoinRuleItem.PublicVisibility.AskToJoin.takeIf { !isSpace && isKnockFeatureEnabled }, JoinRuleItem.PublicVisibility.AskToJoin.takeIf { !isSpace && isKnockFeatureEnabled },
JoinRuleItem.PrivateVisibility.Private, JoinRuleItem.PrivateVisibility.Private,
).toImmutableList() ).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.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule 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.lambdaRecorder
import io.element.android.tests.testutils.lambda.matching import io.element.android.tests.testutils.lambda.matching
import io.element.android.tests.testutils.robolectric.RobolectricTest import io.element.android.tests.testutils.robolectric.RobolectricTest
@@ -522,7 +523,10 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
@Test @Test
fun `present - creating a private room when encryption is disabled by the HS creates it unencrypted`() = runTest { 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 createRoomLambda = lambdaRecorder<CreateRoomParameters, Result<RoomId>> { Result.success(A_ROOM_ID) }
val matrixClient = createMatrixClient().apply { val matrixClient = createMatrixClient().apply {
createRoomResult = createRoomLambda 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 { private suspend fun TurbineTestContext<ConfigureRoomState>.initialState(): ConfigureRoomState {
skipItems(1) skipItems(1)
return awaitItem() return awaitItem()
@@ -579,7 +615,10 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
isKnockFeatureEnabled: Boolean = true, isKnockFeatureEnabled: Boolean = true,
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { false }), sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService(
isEncryptionDisabledResult = { false },
arePublicRoomsDisabledResult = { false },
),
) = ConfigureRoomPresenter( ) = ConfigureRoomPresenter(
isSpace = isSpace, isSpace = isSpace,
initialParentSpaceId = initialParenSpaceId, 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. */ /** Whether the homeserver forbids creating encrypted rooms, in which case the app must not offer the option. */
suspend fun isEncryptionDisabledByHomeserver(): Boolean 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 tweakMasUrl(url: String): String = url
override suspend fun isElementCallAvailable(): Boolean = true override suspend fun isElementCallAvailable(): Boolean = true
override suspend fun isEncryptionDisabledByHomeserver(): Boolean = false 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 isElementCallAvailableResult: () -> Boolean = { lambdaError() },
private val tweakMasUrlResult: (String) -> String = { lambdaError() }, private val tweakMasUrlResult: (String) -> String = { lambdaError() },
private val isEncryptionDisabledResult: () -> Boolean = { lambdaError() }, private val isEncryptionDisabledResult: () -> Boolean = { lambdaError() },
private val arePublicRoomsDisabledResult: () -> Boolean = { lambdaError() },
) : SessionEnterpriseService { ) : SessionEnterpriseService {
override suspend fun init() { override suspend fun init() {
} }
@@ -31,4 +32,8 @@ class FakeSessionEnterpriseService(
override suspend fun isEncryptionDisabledByHomeserver(): Boolean { override suspend fun isEncryptionDisabledByHomeserver(): Boolean {
return isEncryptionDisabledResult() return isEncryptionDisabledResult()
} }
override suspend fun arePublicRoomsDisabledByHomeserver(): Boolean {
return arePublicRoomsDisabledResult()
}
} }
@@ -23,7 +23,7 @@ interface ForwardEntryPoint : FeatureEntryPoint {
} }
data class Params( data class Params(
val eventId: EventId, val eventIds: List<EventId>,
val timelineProvider: TimelineProvider, val timelineProvider: TimelineProvider,
) : NodeInputs ) : NodeInputs
@@ -27,7 +27,7 @@ class DefaultForwardEntryPoint : ForwardEntryPoint {
buildContext = buildContext, buildContext = buildContext,
plugins = listOf( plugins = listOf(
ForwardMessagesNode.Inputs( ForwardMessagesNode.Inputs(
eventId = params.eventId, eventIds = params.eventIds,
timelineProvider = params.timelineProvider, timelineProvider = params.timelineProvider,
), ),
callback, callback,
@@ -54,13 +54,13 @@ class ForwardMessagesNode(
object NavTarget : Parcelable object NavTarget : Parcelable
data class Inputs( data class Inputs(
val eventId: EventId, val eventIds: List<EventId>,
val timelineProvider: TimelineProvider, val timelineProvider: TimelineProvider,
) : NodeInputs ) : NodeInputs
private val inputs = inputs<Inputs>() private val inputs = inputs<Inputs>()
private val callback: ForwardEntryPoint.Callback = callback() 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 { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
val callback = object : RoomSelectEntryPoint.Callback { val callback = object : RoomSelectEntryPoint.Callback {
@@ -28,22 +28,20 @@ import timber.log.Timber
@AssistedInject @AssistedInject
class ForwardMessagesPresenter( class ForwardMessagesPresenter(
@Assisted eventId: String, @Assisted private val eventIds: List<EventId>,
@Assisted private val timelineProvider: TimelineProvider, @Assisted private val timelineProvider: TimelineProvider,
@SessionCoroutineScope @SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
) : Presenter<ForwardMessagesState> { ) : Presenter<ForwardMessagesState> {
private val eventId: EventId = EventId(eventId)
@AssistedFactory @AssistedFactory
fun interface Factory { 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) private val forwardingActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized)
fun onRoomSelected(roomIds: List<RoomId>) { fun onRoomSelected(roomIds: List<RoomId>) {
sessionCoroutineScope.forwardEvent(eventId, roomIds) sessionCoroutineScope.forwardEvents(eventIds, roomIds)
} }
@Composable @Composable
@@ -60,14 +58,14 @@ class ForwardMessagesPresenter(
) )
} }
private fun CoroutineScope.forwardEvent( private fun CoroutineScope.forwardEvents(
eventId: EventId, eventIds: List<EventId>,
roomIds: List<RoomId>, roomIds: List<RoomId>,
) = launch { ) = launch {
suspend { suspend {
timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds) timelineProvider.getActiveTimeline().forwardEvents(eventIds, roomIds)
.onFailure { .onFailure {
Timber.e(it, "Error while forwarding event") Timber.e(it, "Error while forwarding events")
} }
.getOrThrow() .getOrThrow()
roomIds roomIds
@@ -47,7 +47,7 @@ class DefaultForwardEntryPointTest {
override fun onDone(roomIds: List<RoomId>) = lambdaError() override fun onDone(roomIds: List<RoomId>) = lambdaError()
} }
val params = ForwardEntryPoint.Params( val params = ForwardEntryPoint.Params(
eventId = AN_EVENT_ID, eventIds = listOf(AN_EVENT_ID),
timelineProvider = FakeTimelineProvider(), timelineProvider = FakeTimelineProvider(),
) )
val result = entryPoint.createNode( val result = entryPoint.createNode(
@@ -59,7 +59,7 @@ class DefaultForwardEntryPointTest {
assertThat(result).isInstanceOf(ForwardMessagesNode::class.java) assertThat(result).isInstanceOf(ForwardMessagesNode::class.java)
assertThat(result.plugins).contains( assertThat(result.plugins).contains(
ForwardMessagesNode.Inputs( ForwardMessagesNode.Inputs(
eventId = params.eventId, eventIds = params.eventIds,
timelineProvider = params.timelineProvider, timelineProvider = params.timelineProvider,
) )
) )
@@ -44,11 +44,11 @@ class ForwardMessagesPresenterTest {
@Test @Test
fun `present - forward successful`() = runTest { fun `present - forward successful`() = runTest {
val forwardEventLambda = lambdaRecorder { _: EventId, _: List<RoomId> -> val forwardEventsLambda = lambdaRecorder { _: List<EventId>, _: List<RoomId> ->
Result.success(Unit) Result.success(Unit)
} }
val timeline = FakeTimeline().apply { val timeline = FakeTimeline().apply {
this.forwardEventLambda = forwardEventLambda this.forwardEventsLambda = forwardEventsLambda
} }
val room = FakeJoinedRoom(liveTimeline = timeline) val room = FakeJoinedRoom(liveTimeline = timeline)
val presenter = createForwardMessagesPresenter(fakeRoom = room) val presenter = createForwardMessagesPresenter(fakeRoom = room)
@@ -62,17 +62,17 @@ class ForwardMessagesPresenterTest {
assertThat(forwardingState.forwardAction.isLoading()).isTrue() assertThat(forwardingState.forwardAction.isLoading()).isTrue()
val successfulForwardState = awaitItem() val successfulForwardState = awaitItem()
assertThat(successfulForwardState.forwardAction).isEqualTo(AsyncAction.Success(listOf(summary.roomId))) assertThat(successfulForwardState.forwardAction).isEqualTo(AsyncAction.Success(listOf(summary.roomId)))
forwardEventLambda.assertions().isCalledOnce() forwardEventsLambda.assertions().isCalledOnce()
} }
} }
@Test @Test
fun `present - select a room and forward failed, then clear`() = runTest { 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")) Result.failure<Unit>(IllegalStateException("error"))
} }
val timeline = FakeTimeline().apply { val timeline = FakeTimeline().apply {
this.forwardEventLambda = forwardEventLambda this.forwardEventsLambda = forwardEventsLambda
} }
val room = FakeJoinedRoom(liveTimeline = timeline) val room = FakeJoinedRoom(liveTimeline = timeline)
val presenter = createForwardMessagesPresenter(fakeRoom = room) val presenter = createForwardMessagesPresenter(fakeRoom = room)
@@ -88,16 +88,16 @@ class ForwardMessagesPresenterTest {
// Then clear error // Then clear error
failedForwardState.eventSink(ForwardMessagesEvent.ClearError) failedForwardState.eventSink(ForwardMessagesEvent.ClearError)
assertThat(awaitItem().forwardAction.isUninitialized()).isTrue() assertThat(awaitItem().forwardAction.isUninitialized()).isTrue()
forwardEventLambda.assertions().isCalledOnce() forwardEventsLambda.assertions().isCalledOnce()
} }
} }
} }
fun TestScope.createForwardMessagesPresenter( fun TestScope.createForwardMessagesPresenter(
eventId: EventId = AN_EVENT_ID, eventIds: List<EventId> = listOf(AN_EVENT_ID),
fakeRoom: FakeJoinedRoom = FakeJoinedRoom(), fakeRoom: FakeJoinedRoom = FakeJoinedRoom(),
) = ForwardMessagesPresenter( ) = ForwardMessagesPresenter(
eventId = eventId.value, eventIds = eventIds,
timelineProvider = LiveTimelineProvider(fakeRoom), timelineProvider = LiveTimelineProvider(fakeRoom),
sessionCoroutineScope = this, 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.roomListRoomMessage
import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate
import io.element.android.libraries.designsystem.theme.roomListRoomName 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.notification.CallIntent
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.DisplayedStatus
@@ -244,7 +243,7 @@ private fun NameAndTimestampRow(
text = timestamp ?: "", text = timestamp ?: "",
style = ElementTheme.typography.fontBodySmMedium, style = ElementTheme.typography.fontBodySmMedium,
color = if (isHighlighted) { color = if (isHighlighted) {
ElementTheme.colors.unreadIndicator ElementTheme.colors.textActionAccent
} else { } else {
ElementTheme.colors.roomListRoomMessageDate ElementTheme.colors.roomListRoomMessageDate
}, },
@@ -347,13 +346,12 @@ private fun MessagePreviewAndIndicatorRow(
// Call and unread // Call and unread
Row( Row(
modifier = Modifier modifier = Modifier
.height(16.dp)
// Used to force this line to be read aloud earlier than the latest event when using Talkback // Used to force this line to be read aloud earlier than the latest event when using Talkback
.zIndex(-1f), .zIndex(-1f),
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically, 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) { if (room.hasRoomCall) {
OnGoingCallIcon( OnGoingCallIcon(
color = tint, color = tint,
@@ -422,10 +420,7 @@ private fun InviteNameAndIndicatorRow(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
if (!isInviteSeen) { if (!isInviteSeen) {
UnreadIndicatorAtom( UnreadIndicatorAtom(count = 0)
color = ElementTheme.colors.unreadIndicator,
count = 0,
)
} }
} }
} }
@@ -459,7 +454,7 @@ private fun MentionIndicatorAtom() {
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
contentDescription = stringResource(CommonStrings.a11y_notifications_new_mentions), contentDescription = stringResource(CommonStrings.a11y_notifications_new_mentions),
imageVector = CompoundIcons.Mention(), imageVector = CompoundIcons.Mention(),
tint = ElementTheme.colors.unreadIndicator, tint = ElementTheme.colors.iconAccentPrimary,
) )
} }
@@ -23,6 +23,7 @@ sealed interface MessagesEvent {
data object StopLiveLocationShare : MessagesEvent data object StopLiveLocationShare : MessagesEvent
data object ShowLiveLocationShare : MessagesEvent data object ShowLiveLocationShare : MessagesEvent
data object MarkAsFullyReadAndExit : MessagesEvent data object MarkAsFullyReadAndExit : MessagesEvent
data object ConfirmSelectionAction : MessagesEvent
} }
enum class InviteDialogAction { enum class InviteDialogAction {
@@ -181,7 +181,7 @@ class MessagesFlowNode(
@Parcelize @Parcelize
data class ForwardEvent( data class ForwardEvent(
val eventId: EventId, val eventIds: List<EventId>,
@IgnoredOnParcel @IgnoredOnParcel
val timelineProvider: TimelineProvider? = null, val timelineProvider: TimelineProvider? = null,
) : NavTarget ) : NavTarget
@@ -306,8 +306,8 @@ class MessagesFlowNode(
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo)) backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
} }
override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) { override fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
backstack.push(NavTarget.ForwardEvent(eventId, timelineProvider)) backstack.push(NavTarget.ForwardEvent(eventIds, timelineProvider))
} }
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
@@ -464,7 +464,7 @@ class MessagesFlowNode(
is NavTarget.ForwardEvent -> { is NavTarget.ForwardEvent -> {
// If no timeline provider is received, assume the live timeline should be used // If no timeline provider is received, assume the live timeline should be used
val timelineProvider = navTarget.timelineProvider ?: timelineController val timelineProvider = navTarget.timelineProvider ?: timelineController
val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider) val params = ForwardEntryPoint.Params(navTarget.eventIds, timelineProvider)
val callback = object : ForwardEntryPoint.Callback { val callback = object : ForwardEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) { override fun onDone(roomIds: List<RoomId>) {
backstack.pop() backstack.pop()
@@ -551,7 +551,7 @@ class MessagesFlowNode(
} }
override fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider) { 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) { override fun navigateToThread(threadRootId: ThreadId) {
@@ -617,8 +617,8 @@ class MessagesFlowNode(
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo)) backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
} }
override fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider) { override fun handleForwardEventClick(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
backstack.push(NavTarget.ForwardEvent(eventId, timelineProvider)) backstack.push(NavTarget.ForwardEvent(eventIds, timelineProvider))
} }
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
@@ -19,7 +19,7 @@ import kotlinx.collections.immutable.ImmutableList
interface MessagesNavigator { interface MessagesNavigator {
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) 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 navigateToReportMessage(eventId: EventId, senderId: UserId)
fun navigateToEditPoll(eventId: EventId) fun navigateToEditPoll(eventId: EventId)
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
@@ -138,7 +138,7 @@ class MessagesNode(
fun navigateToRoomMemberDetails(userId: UserId) fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData) fun handlePermalinkClick(data: PermalinkData)
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) 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 navigateToReportMessage(eventId: EventId, senderId: UserId)
fun navigateToSendLocation() fun navigateToSendLocation()
fun navigateToCreatePoll() fun navigateToCreatePoll()
@@ -221,8 +221,8 @@ class MessagesNode(
callback.navigateToEventDebugInfo(eventId, debugInfo) callback.navigateToEventDebugInfo(eventId, debugInfo)
} }
override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) { override fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
callback.forwardEvent(eventId, timelineProvider) callback.forwardEvents(eventIds, timelineProvider)
} }
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { 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.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState 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.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.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvent 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.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.captionOrNull
import io.element.android.features.messages.impl.timeline.model.event.htmlCaptionOrNull 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.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.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter
import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent 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.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState 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.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.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.matrix.ui.messages.reply.map
@@ -257,6 +261,17 @@ class MessagesPresenter(
redactEventAction = redactEventAction, 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 -> { is MessagesEvent.ConfirmRedact -> {
val confirming = redactEventAction.value as? MessagesState.ConfirmingRedaction val confirming = redactEventAction.value as? MessagesState.ConfirmingRedaction
redactEventAction.value = AsyncAction.Uninitialized redactEventAction.value = AsyncAction.Uninitialized
@@ -413,21 +428,27 @@ class MessagesPresenter(
} }
} }
TimelineItemAction.ViewSource -> handleShowDebugInfoAction(targetEvent) 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.ReportContent -> handleReportAction(targetEvent)
TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent, timelineState) TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent, timelineState)
TimelineItemAction.Pin -> handlePinAction(targetEvent) TimelineItemAction.Pin -> handlePinAction(targetEvent)
TimelineItemAction.Unpin -> handleUnpinAction(targetEvent) TimelineItemAction.Unpin -> handleUnpinAction(targetEvent)
TimelineItemAction.ViewInTimeline -> Unit TimelineItemAction.ViewInTimeline -> Unit
TimelineItemAction.RetrySending -> handleRetrySending(targetEvent) TimelineItemAction.RetrySending -> handleRetrySending(targetEvent)
TimelineItemAction.AbortSending -> handleActionAbortSend(targetEvent)
} }
} }
private suspend fun handleRetrySending(targetEvent: TimelineItem.Event) { private suspend fun handleRetrySending(targetEvent: TimelineItem.Event) {
val sendHandle = targetEvent.sendhandle ?: return Unit.also { val (sendTarget, _) = targetEvent.pendingSend() ?: return
Timber.w("No send handle for event ${targetEvent.eventOrTransactionId}") timelineController.retrySend(targetEvent.eventOrTransactionId, sendTarget)
}
sendHandle.retry()
.onSuccess { .onSuccess {
Timber.d("Succeed to add the message back to the send queue") 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>>) { private suspend fun handleActionRedact(event: TimelineItem.Event, redactEventAction: MutableState<AsyncAction<Unit>>) {
val eventId = event.eventId val eventId = event.eventId
if (eventId == null) { if (eventId == null) {
// The message was never sent, so there is nobody to give a reason to. // The message (or its edit/redaction) was never sent, just remove it from the send queue.
redact(event.eventOrTransactionId, reason = null) handleActionAbortSend(event)
} else { } else {
redactEventAction.value = MessagesState.ConfirmingRedaction(eventId) 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?) { private suspend fun redact(eventOrTransactionId: EventOrTransactionId, reason: String?) {
timelineController.invokeOnCurrentTimeline { timelineController.invokeOnCurrentTimeline {
redactEvent(eventOrTransactionId = eventOrTransactionId, reason = reason) redactEvent(eventOrTransactionId = eventOrTransactionId, reason = reason)
@@ -612,11 +638,6 @@ class MessagesPresenter(
navigator.navigateToEventDebugInfo(event.eventId, event.debugInfo) 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) { private fun handleReportAction(event: TimelineItem.Event) {
if (event.eventId == null) return if (event.eventId == null) return
navigator.navigateToReportMessage(event.eventId, event.senderId) navigator.navigateToReportMessage(event.eventId, event.senderId)
@@ -8,6 +8,7 @@
package io.element.android.features.messages.impl package io.element.android.features.messages.impl
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically 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.PinnedMessagesBannerView
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerViewDefaults 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.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.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelineView 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.aGroupedEvents
import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator 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.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.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.CallMenuItem
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent 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.ReactionSummaryEvent
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView 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.ReadReceiptBottomSheet
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvent 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.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition 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.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent 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.timeline.sendfailure.SendFailureDialogView
import io.element.android.features.messages.impl.topbars.MessagesViewTopBar 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.topbars.ThreadTopBar
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog import io.element.android.features.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.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableSet
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@@ -178,6 +187,18 @@ fun MessagesView(
block() 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) { fun onContentClick(event: TimelineItem.Event) {
Timber.v("onMessageClick= ${event.id}") Timber.v("onMessageClick= ${event.id}")
val eventId = event.eventId val eventId = event.eventId
@@ -234,7 +255,21 @@ fun MessagesView(
Scaffold( Scaffold(
contentWindowInsets = scaffoldScrollableContentInsets, contentWindowInsets = scaffoldScrollableContentInsets,
topBar = { topBar = {
if (state.timelineState.timelineMode is Timeline.Mode.Thread) { 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( ThreadTopBar(
roomName = state.roomName, roomName = state.roomName,
roomAvatarData = state.roomAvatar, roomAvatarData = state.roomAvatar,
@@ -242,7 +277,8 @@ fun MessagesView(
isTombstoned = state.isTombstoned, isTombstoned = state.isTombstoned,
onBackClick = onBackClick, onBackClick = onBackClick,
) )
} else { }
else -> {
MessagesViewTopBar( MessagesViewTopBar(
roomName = state.roomName, roomName = state.roomName,
roomAvatar = state.roomAvatar, roomAvatar = state.roomAvatar,
@@ -263,6 +299,7 @@ fun MessagesView(
} }
) )
} }
}
}, },
content = { padding -> content = { padding ->
Box( Box(
@@ -449,7 +486,7 @@ fun MessagesView(
onRemoveMessage = { event -> onRemoveMessage = { event ->
state.eventSink( state.eventSink(
MessagesEvent.HandleAction( MessagesEvent.HandleAction(
action = TimelineItemAction.Redact, action = TimelineItemAction.AbortSending,
event = event, 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 @Preview
@Composable @Composable
internal fun MessagesViewA11yPreview() = ElementPreview { internal fun MessagesViewA11yPreview() = ElementPreview {
@@ -37,6 +37,7 @@ enum class TimelineItemAction(
Pin(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin), Pin(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin),
Unpin(CommonStrings.action_unpin, CompoundDrawables.ic_compound_unpin), 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), 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.ViewSource,
TimelineItemAction.ReportContent, TimelineItemAction.ReportContent,
TimelineItemAction.Redact, 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.RetrySending,
TimelineItemAction.AbortSending,
) )
override fun compare(o1: TimelineItemAction, o2: TimelineItemAction): Int { 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.ReactionSummaryEvent
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView 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.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.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.poll.api.pollcontent.PollTitleView import io.element.android.features.poll.api.pollcontent.PollTitleView
@@ -289,6 +290,7 @@ private fun PinnedMessagesListLoaded(
onContentLayoutChange = onContentLayoutChange onContentLayoutChange = onContentLayoutChange
) )
}, },
selectionData = TimelineItemSelectionData(),
) )
} }
} }
@@ -143,7 +143,7 @@ class ThreadedMessagesNode(
fun navigateToRoomMemberDetails(userId: UserId) fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData) fun handlePermalinkClick(data: PermalinkData)
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) 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 navigateToReportMessage(eventId: EventId, senderId: UserId)
fun navigateToSendLocation() fun navigateToSendLocation()
fun navigateToCreatePoll() fun navigateToCreatePoll()
@@ -227,8 +227,8 @@ class ThreadedMessagesNode(
callback.navigateToEventDebugInfo(eventId, debugInfo) callback.navigateToEventDebugInfo(eventId, debugInfo)
} }
override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) { override fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
callback.handleForwardEventClick(eventId, timelineProvider) callback.handleForwardEventClick(eventIds, timelineProvider)
} }
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
@@ -264,13 +264,14 @@ private fun ThreadListItemRow(
) )
} }
if (hasUnreadNotifications) {
UnreadIndicatorAtom( UnreadIndicatorAtom(
size = 14.dp, dotIndicatorSize = 14.dp,
isVisible = hasUnreadNotifications,
count = 0, count = 0,
) )
} }
} }
}
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -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.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.Timeline 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.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.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -32,6 +34,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.getAndUpdate 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 -> private val currentTimelineFlow = combine(liveTimelineFlow, detachedTimelineFlow) { live, detached ->
when { when {
detached.isPresent -> detached.get() detached.isPresent -> detached.get()
@@ -28,7 +28,9 @@ sealed interface TimelineEvent {
data object HideShieldDialog : TimelineEvent data object HideShieldDialog : TimelineEvent
data object MarkAllAsRead : 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 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.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers 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.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags 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 io.element.android.services.analyticsproviders.api.AnalyticsUserData
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -103,6 +109,7 @@ class TimelinePresenter(
private val typingNotificationPresenter: Presenter<TypingNotificationState>, private val typingNotificationPresenter: Presenter<TypingNotificationState>,
private val roomCallStatePresenter: Presenter<RoomCallState>, private val roomCallStatePresenter: Presenter<RoomCallState>,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val snackbarDispatcher: SnackbarDispatcher,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val liveLocationShareManager: ActiveLiveLocationShareManager, private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val markAsFullyRead: MarkAsFullyRead, private val markAsFullyRead: MarkAsFullyRead,
@@ -172,6 +179,18 @@ class TimelinePresenter(
value = featureFlagService.isFeatureEnabled(FeatureFlags.NewTimelineEventRenderer) 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() val timelineProtectionState = timelineProtectionPresenter.present()
fun handleEvent(event: TimelineEvent) { fun handleEvent(event: TimelineEvent) {
@@ -252,6 +271,26 @@ class TimelinePresenter(
is TimelineEvent.JumpToLive -> { is TimelineEvent.JumpToLive -> {
timelineController.focusOnLive() 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.HideShieldDialog -> messageShieldDialogData.value = null
TimelineEvent.MarkAllAsRead -> sessionCoroutineScope.launch { TimelineEvent.MarkAllAsRead -> sessionCoroutineScope.launch {
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse { val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
@@ -263,7 +302,9 @@ class TimelinePresenter(
} }
is TimelineEvent.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData is TimelineEvent.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData
is TimelineEvent.ShowSendFailureDialog -> { 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) { when (sendStateFailure) {
is LocalEventSendState.Failed.VerifiedUser -> { is LocalEventSendState.Failed.VerifiedUser -> {
// defer to the resolveVerifiedUserSendFailureState to handle this case, // defer to the resolveVerifiedUserSendFailureState to handle this case,
@@ -341,6 +382,14 @@ class TimelinePresenter(
} }
.launchIn(this) .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( combine(
timelineController.timelineItems(), timelineController.timelineItems(),
room.membersStateFlow, room.membersStateFlow,
@@ -477,6 +526,7 @@ class TimelinePresenter(
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread, displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread.value, jumpToUnread = jumpToUnread.value,
selectionState = selectionState,
useNewTimelineEventRenderer = useNewTimelineEventRenderer, useNewTimelineEventRenderer = useNewTimelineEventRenderer,
eventSink = ::handleEvent, 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.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlin.time.Duration import kotlin.time.Duration
data class TimelineState( data class TimelineState(
@@ -38,6 +40,7 @@ data class TimelineState(
val displayThreadSummaries: Boolean, val displayThreadSummaries: Boolean,
val displayJumpToUnread: Boolean, val displayJumpToUnread: Boolean,
val jumpToUnread: JumpToUnreadState, val jumpToUnread: JumpToUnreadState,
val selectionState: SelectionState,
// When true, formatted message bodies are rendered with the native Compose renderer. // When true, formatted message bodies are rendered with the native Compose renderer.
val useNewTimelineEventRenderer: Boolean, val useNewTimelineEventRenderer: Boolean,
val eventSink: (TimelineEvent) -> Unit, 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. */ /** The read marker event is older than the loaded window — load it via focused-event navigation. */
data class OutOfWindow(val eventId: EventId) : JumpToUnreadState 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, displayJumpToUnread: Boolean = false,
jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden, jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden,
newEventState: NewEventState = NewEventState.None, newEventState: NewEventState = NewEventState.None,
selectionState: SelectionState = SelectionState.Disabled,
useNewTimelineEventRenderer: Boolean = false, useNewTimelineEventRenderer: Boolean = false,
eventSink: (TimelineEvent) -> Unit = {}, eventSink: (TimelineEvent) -> Unit = {},
): TimelineState { ): TimelineState {
@@ -85,6 +86,7 @@ fun aTimelineState(
displayThreadSummaries = displayThreadSummaries, displayThreadSummaries = displayThreadSummaries,
displayJumpToUnread = displayJumpToUnread, displayJumpToUnread = displayJumpToUnread,
jumpToUnread = jumpToUnread, jumpToUnread = jumpToUnread,
selectionState = selectionState,
useNewTimelineEventRenderer = useNewTimelineEventRenderer, useNewTimelineEventRenderer = useNewTimelineEventRenderer,
eventSink = eventSink, eventSink = eventSink,
) )
@@ -157,6 +159,8 @@ internal fun aTimelineItemEvent(
content: TimelineItemEventContent = aTimelineItemTextContent(), content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
sendState: LocalEventSendState? = null, sendState: LocalEventSendState? = null,
editSendState: LocalEventSendState? = null,
redactionSendState: LocalEventSendState? = null,
inReplyTo: InReplyToDetails? = null, inReplyTo: InReplyToDetails? = null,
threadInfo: TimelineItemThreadInfo? = null, threadInfo: TimelineItemThreadInfo? = null,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(), debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
@@ -183,6 +187,8 @@ internal fun aTimelineItemEvent(
), ),
groupPosition = groupPosition, groupPosition = groupPosition,
localSendState = sendState, localSendState = sendState,
editSendState = editSendState,
redactionSendState = redactionSendState,
inReplyTo = inReplyTo, inReplyTo = inReplyTo,
threadInfo = threadInfo, threadInfo = threadInfo,
origin = null, origin = null,
@@ -11,6 +11,7 @@ package io.element.android.features.messages.impl.timeline
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut 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.focus.FocusRequestStateView
import io.element.android.features.messages.impl.timeline.model.NewEventState 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.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.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentPreviewParam 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.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom 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.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -181,6 +185,18 @@ fun TimelineView(
state.eventSink(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS)) 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 // Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
AnimatedVisibility(visible = true, enter = fadeIn()) { AnimatedVisibility(visible = true, enter = fadeIn()) {
val composeLocalTimelineEventRendererConfig = remember(state.useNewTimelineEventRenderer, state.timelineRoomInfo.currentUserId) { val composeLocalTimelineEventRendererConfig = remember(state.useNewTimelineEventRenderer, state.timelineRoomInfo.currentUserId) {
@@ -213,12 +229,22 @@ fun TimelineView(
isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()), isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()),
focusedEventId = state.focusedEventId, focusedEventId = state.focusedEventId,
displayThreadSummaries = state.displayThreadSummaries, displayThreadSummaries = state.displayThreadSummaries,
selectionData = TimelineItemSelectionData(
isSelectionModeActive = isSelectionModeActive,
progress = selectionProgress,
isSelected = state.isSelected(timelineItem),
canBeSelected = state.canSelect(timelineItem),
),
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick, onLinkClick = onLinkClick,
onLinkLongClick = ::onLinkLongClick, onLinkLongClick = ::onLinkLongClick,
onContentClick = onContentClick, onContentClick = { event ->
if (state.canSelect(event)) onToggleSelection(event) else onContentClick(event)
},
onGalleryItemClick = onGalleryItemClick, onGalleryItemClick = onGalleryItemClick,
onLongClick = onMessageLongClick, onLongClick = { event ->
if (state.canSelect(event)) onToggleSelection(event) else onMessageLongClick(event)
},
inReplyToClick = ::inReplyToClick, inReplyToClick = ::inReplyToClick,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
@@ -571,7 +597,6 @@ private fun JumpToPositionButton(
modifier = Modifier modifier = Modifier
.align(dotAlignment) .align(dotAlignment)
.offset { IntOffset(x = 0, y = dotYOffset.roundToPx()) }, .offset { IntOffset(x = 0, y = dotYOffset.roundToPx()) },
color = ElementTheme.colors.iconSuccessPrimary,
border = BorderStroke(2.dp, ElementTheme.colors.bgCanvasDefault), border = BorderStroke(2.dp, ElementTheme.colors.bgCanvasDefault),
count = 0, 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 // 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` // 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. // 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.drawOutline
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.layer.CompositingStrategy 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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp 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.testtags.testTag
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.libraries.ui.utils.graphics.drawInLayer import io.element.android.libraries.ui.utils.graphics.drawInLayer
import kotlin.math.min
private val BUBBLE_RADIUS = 12.dp private val BUBBLE_RADIUS = 12.dp
private val avatarRadius = AvatarSize.TimelineSender.dp / 2 private val avatarRadius = AvatarSize.TimelineSender.dp / 2
private val MIN_BUBBLE_WIDTH = 80.dp 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 @Composable
fun MessageEventBubble( fun MessageEventBubble(
state: BubbleState, state: BubbleState,
@@ -93,6 +103,12 @@ fun MessageEventBubble(
val updatedBorderColor by rememberUpdatedState(borderColor) val updatedBorderColor by rememberUpdatedState(borderColor)
BoxWithConstraints( BoxWithConstraints(
modifier = modifier 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 { .drawWithCache {
// Calculate the outline of the background and cache it // Calculate the outline of the background and cache it
val outline = bubbleShape.createOutline(size, layoutDirection, this) 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 shield = event.messageShield
val isVerifiedUserSendFailure = event.localSendState is LocalEventSendState.Failed.VerifiedUser val isVerifiedUserSendFailure = event.localSendState is LocalEventSendState.Failed.VerifiedUser
val onClickLabel = when { val onClickLabel = when {
hasError -> stringResource(CommonStrings.a11y_view_details)
shield != null -> stringResource(CommonStrings.a11y_view_details) shield != null -> stringResource(CommonStrings.a11y_view_details)
isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu) isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu)
hasError -> stringResource(CommonStrings.a11y_view_details)
else -> null else -> null
} }
val clickableModifier = remember(event) { val clickableModifier = remember(event) {
when { when {
hasError -> {
Modifier.clickable(
onClickLabel = onClickLabel,
) {
eventSink(TimelineEvent.ShowSendFailureDialog(event))
}
}
shield != null -> { shield != null -> {
Modifier.clickable( Modifier.clickable(
onClickLabel = onClickLabel, onClickLabel = onClickLabel,
@@ -69,12 +76,6 @@ fun TimelineEventTimestampView(
eventSink(TimelineEvent.ShowShieldDialog(shield)) eventSink(TimelineEvent.ShowShieldDialog(shield))
} }
} }
hasError -> Modifier
.clickable(
onClickLabel = onClickLabel,
) {
eventSink(TimelineEvent.ShowSendFailureDialog(event))
}
else -> Modifier else -> Modifier
} }
} }
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.absoluteOffset import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.TextAlign
import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
@@ -180,6 +182,9 @@ fun TimelineItemEventRow(
onSwipeToReply: () -> Unit, onSwipeToReply: () -> Unit,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
modifier: Modifier = Modifier, 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 -> eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange ->
// Only pass down a custom clickable lambda if the content can be clicked separately // Only pass down a custom clickable lambda if the content can be clicked separately
val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable } val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable }
@@ -319,14 +324,18 @@ fun TimelineItemEventRow(
} }
// Read receipts / Send state // Read receipts / Send state
val sendState = event.pendingSend()?.second
TimelineItemReadReceiptView( TimelineItemReadReceiptView(
state = ReadReceiptViewState( state = ReadReceiptViewState(
sendState = event.localSendState, sendState = sendState,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts, receipts = event.readReceiptState.receipts,
), ),
onReadReceiptsClick = { onReadReceiptClick(event) }, 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.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.groups.isRedactedMessagesGroup 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.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.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -190,6 +191,7 @@ private fun TimelineItemGroupedEventsRowContent(
onSwipeToReply = {}, onSwipeToReply = {},
eventSink = eventSink, eventSink = eventSink,
eventContentView = eventContentView, eventContentView = eventContentView,
selectionData = TimelineItemSelectionData(),
) )
} }
} }
@@ -8,28 +8,39 @@
package io.element.android.features.messages.impl.timeline.components package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush 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.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.TimelineEvent 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.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView 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.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.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
@@ -60,6 +71,7 @@ internal fun TimelineItemRow(
timelineProtectionState: TimelineProtectionState, timelineProtectionState: TimelineProtectionState,
focusedEventId: EventId?, focusedEventId: EventId?,
displayThreadSummaries: Boolean, displayThreadSummaries: Boolean,
selectionData: TimelineItemSelectionData,
onUserDataClick: (MatrixUser) -> Unit, onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit, onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit,
@@ -92,17 +104,148 @@ internal fun TimelineItemRow(
) )
}, },
) { ) {
val backgroundModifier = if (timelineItem.isEvent(focusedEventId)) { val isSelectionModeActive = selectionData.isSelectionModeActive
val focusedEventOffset = if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) { 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 14.dp
} else { } else {
2.dp 2.dp
} }
Modifier.focusedEvent(focusedEventOffset) Modifier.focusedEvent(focusedEventOffset)
} else { }
else -> {
Modifier 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) { when (timelineItem) {
is TimelineItem.Virtual -> { is TimelineItem.Virtual -> {
TimelineItemVirtualRow( TimelineItemVirtualRow(
@@ -197,6 +340,7 @@ internal fun TimelineItemRow(
onSwipeToReply = { onSwipeToReply(timelineItem) }, onSwipeToReply = { onSwipeToReply(timelineItem) },
onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) }, onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) },
eventSink = eventSink, eventSink = eventSink,
selectionContentOffset = selectionContentOffset,
eventContentView = { contentModifier, onContentLayoutChange -> eventContentView = { contentModifier, onContentLayoutChange ->
eventContentView(timelineItem, contentModifier, onContentLayoutChange) eventContentView(timelineItem, contentModifier, onContentLayoutChange)
}, },
@@ -20,9 +20,9 @@ import kotlinx.collections.immutable.toImmutableList
class ReadReceiptViewStatePreviewParam : PreviewParameterProvider<ReadReceiptViewState> { class ReadReceiptViewStatePreviewParam : PreviewParameterProvider<ReadReceiptViewState> {
override val values: Sequence<ReadReceiptViewState> override val values: Sequence<ReadReceiptViewState>
get() = sequenceOf( get() = sequenceOf(
aReadReceiptViewState(), aReadReceiptViewState(sendState = null, isLastOutgoingMessage = true),
aReadReceiptViewState(sendState = LocalEventSendState.Sending.Event), aReadReceiptViewState(sendState = LocalEventSendState.Sending.Event, isLastOutgoingMessage = true),
aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId"))), aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId")), isLastOutgoingMessage = true),
aReadReceiptViewState( aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")), sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = List(1) { aReadReceiptData(it) }, receipts = List(1) { aReadReceiptData(it) },
@@ -43,12 +43,15 @@ class ReadReceiptViewStatePreviewParam : PreviewParameterProvider<ReadReceiptVie
sendState = LocalEventSendState.Sent(EventId("\$eventId")), sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = List(5) { aReadReceiptData(it) }, 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( internal fun aReadReceiptViewState(
sendState: LocalEventSendState? = null, sendState: LocalEventSendState? = null,
isLastOutgoingMessage: Boolean = true, isLastOutgoingMessage: Boolean = false,
receipts: List<ReadReceiptData> = emptyList(), receipts: List<ReadReceiptData> = emptyList(),
) = ReadReceiptViewState( ) = ReadReceiptViewState(
sendState = sendState, sendState = sendState,
@@ -57,17 +57,44 @@ fun TimelineItemReadReceiptView(
onReadReceiptsClick: () -> Unit, onReadReceiptsClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (state.receipts.isNotEmpty()) { val content: @Composable (() -> Unit)? = when (state.sendState) {
ReadReceiptsRow(modifier = modifier) { // If null, assume it's Sent and act the same way
null if state.isLastOutgoingMessage && state.receipts.isEmpty() -> {
@Composable { SentIcon() }
}
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( ReadReceiptsAvatars(
receipts = state.receipts, receipts = state.receipts,
onClick = onReadReceiptsClick, onClick = onReadReceiptsClick,
) )
} }
} else { } else {
when (state.sendState) { null
is LocalEventSendState.Sending -> { }
ReadReceiptsRow(modifier) { }
// Only show the row if there is content to display
content?.let {
ReadReceiptsRow(
modifier = modifier,
content = it,
)
}
}
@Composable
private fun SendingIcon() {
Icon( Icon(
modifier = Modifier.padding(2.dp), modifier = Modifier.padding(2.dp),
imageVector = CompoundIcons.Circle(), imageVector = CompoundIcons.Circle(),
@@ -75,14 +102,9 @@ fun TimelineItemReadReceiptView(
tint = ElementTheme.colors.iconSecondary tint = ElementTheme.colors.iconSecondary
) )
} }
}
is LocalEventSendState.Failed -> { @Composable
// Error? The timestamp is already displayed in red private fun SentIcon() {
}
null,
is LocalEventSendState.Sent -> {
if (state.isLastOutgoingMessage) {
ReadReceiptsRow(modifier = modifier) {
Icon( Icon(
modifier = Modifier.padding(2.dp), modifier = Modifier.padding(2.dp),
imageVector = CompoundIcons.CheckCircle(), imageVector = CompoundIcons.CheckCircle(),
@@ -90,11 +112,6 @@ fun TimelineItemReadReceiptView(
tint = ElementTheme.colors.iconSecondary tint = ElementTheme.colors.iconSecondary
) )
} }
}
}
}
}
}
@Composable @Composable
private fun ReadReceiptsRow( private fun ReadReceiptsRow(
@@ -19,7 +19,7 @@ class ReadReceiptBottomSheetStatePreviewParam : PreviewParameterProvider<ReadRec
// Reuse the provider ReadReceiptViewStatePreviewParam // Reuse the provider ReadReceiptViewStatePreviewParam
private val readReceiptViewStateProvider = ReadReceiptViewStatePreviewParam() private val readReceiptViewStateProvider = ReadReceiptViewStatePreviewParam()
override val values: Sequence<ReadReceiptBottomSheetState> = readReceiptViewStateProvider.values override val values: Sequence<ReadReceiptBottomSheetState> = readReceiptViewStateProvider.values
.filter { it.sendState is LocalEventSendState.Sent } .filter { it.sendState is LocalEventSendState.Sent && it.receipts.isNotEmpty() }
.map { readReceiptViewState -> .map { readReceiptViewState ->
ReadReceiptBottomSheetState( ReadReceiptBottomSheetState(
selectedEvent = aTimelineItemEvent( selectedEvent = aTimelineItemEvent(
@@ -123,6 +123,8 @@ class TimelineItemEventFactory(
reactionsState = currentTimelineItem.computeReactionsState(), reactionsState = currentTimelineItem.computeReactionsState(),
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers, renderReadReceipts), readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers, renderReadReceipts),
localSendState = currentTimelineItem.event.localSendState, localSendState = currentTimelineItem.event.localSendState,
editSendState = currentTimelineItem.event.editSendState,
redactionSendState = currentTimelineItem.event.redactionSendState,
inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser), inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser),
threadInfo = mappedThreadInfo, threadInfo = mappedThreadInfo,
origin = currentTimelineItem.event.origin, 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.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId 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.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.ThreadSummary
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
@@ -92,6 +93,8 @@ sealed interface TimelineItem {
val reactionsState: TimelineItemReactions, val reactionsState: TimelineItemReactions,
val readReceiptState: TimelineItemReadReceipts, val readReceiptState: TimelineItemReadReceipts,
val localSendState: LocalEventSendState?, val localSendState: LocalEventSendState?,
private val editSendState: LocalEventSendState?,
private val redactionSendState: LocalEventSendState?,
val inReplyTo: InReplyToDetails?, val inReplyTo: InReplyToDetails?,
val threadInfo: TimelineItemThreadInfo?, val threadInfo: TimelineItemThreadInfo?,
val origin: TimelineItemEventOrigin?, val origin: TimelineItemEventOrigin?,
@@ -110,7 +113,7 @@ sealed interface TimelineItem {
val safeSenderName: String = senderProfile.getDisambiguatedDisplayName(senderId) 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 val isTextMessage: Boolean = content is TimelineItemTextBasedContent
@@ -143,6 +146,18 @@ sealed interface TimelineItem {
get() = timelineItemDebugInfoProvider() get() = timelineItemDebugInfoProvider()
val sendhandle: SendHandle? get() = sendHandleProvider() 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( 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( ConfirmationDialog(
modifier = modifier, modifier = modifier,
title = stringResource(id = CommonStrings.common_sending_failed), title = stringResource(id = CommonStrings.common_sending_failed),
content = content, content = content,
onDismiss = onDismiss, onDismiss = onDismiss,
submitText = stringResource(id = CommonStrings.action_retry), submitText = stringResource(id = CommonStrings.action_retry),
onSubmitClick = { onRetry(sendFailureDialogState.event) }, onSubmitClick = { sendTarget?.let { onRetry(sendFailureDialogState.event) } },
onCancelClick = onDismiss, onCancelClick = onDismiss,
thirdButtonText = stringResource(id = CommonStrings.action_remove_message), 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( class FakeMessagesNavigator(
private val onShowEventDebugInfoClickLambda: (eventId: EventId?, debugInfo: TimelineItemDebugInfo) -> Unit = { _, _ -> lambdaError() }, 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 onReportContentClickLambda: (eventId: EventId, senderId: UserId) -> Unit = { _, _ -> lambdaError() },
private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() }, private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() },
private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() }, private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
@@ -35,8 +35,8 @@ class FakeMessagesNavigator(
onShowEventDebugInfoClickLambda(eventId, debugInfo) onShowEventDebugInfoClickLambda(eventId, debugInfo)
} }
override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) { override fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
onForwardEventClickLambda(eventId, timelineProvider) onForwardEventClickLambda(eventIds, timelineProvider)
} }
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { 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.threads.list.aThreadListItem
import io.element.android.features.messages.impl.timeline.FakeMarkAsFullyRead 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.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.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvent 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.aTimelineState
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent 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.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.test.AN_AVATAR_URL 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
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.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_CAPTION import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.A_REASON 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_TRANSACTION_ID
import io.element.android.libraries.matrix.test.A_USER_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.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.core.aBuildMeta
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser 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.testCoroutineDispatchers
import io.element.android.tests.testutils.testWithLifecycleOwner import io.element.android.tests.testutils.testWithLifecycleOwner
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -289,17 +294,37 @@ class MessagesPresenterTest {
} }
@Test @Test
fun `present - handle action forward`() = runTest { fun `present - handle action forward enters selection mode when multi select is enabled`() = runTest {
val onForwardEventClickLambda = lambdaRecorder<EventId, TimelineProvider, Unit> { _, _ -> } val timelineEventSink = lambdaRecorder<TimelineEvent, Unit> { }
val navigator = FakeMessagesNavigator( val presenter = createMessagesPresenter(
onForwardEventClickLambda = onForwardEventClickLambda, timelineEventSink = timelineEventSink,
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
) )
val presenter = createMessagesPresenter(navigator = navigator)
presenter.testWithLifecycleOwner { presenter.testWithLifecycleOwner {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Forward, aMessageEvent())) initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) 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 @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 coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
val liveTimeline = FakeTimeline() val abortSendLambda = lambdaRecorder { _: EventOrTransactionId -> Result.success(true) }
val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) }
liveTimeline.redactEventLambda = redactEventLambda
val presenter = createMessagesPresenter( val presenter = createMessagesPresenter(
timeline = liveTimeline, timeline = FakeTimeline(abortSendResult = abortSendLambda),
coroutineDispatchers = coroutineDispatchers, coroutineDispatchers = coroutineDispatchers,
) )
presenter.testWithLifecycleOwner { presenter.testWithLifecycleOwner {
val initialState = awaitItem() 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)) initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, localEcho))
advanceUntilIdle() advanceUntilIdle()
assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized) assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized)
assert(redactEventLambda) abortSendLambda.assertions().isCalledOnce().with(value(localEcho.eventOrTransactionId))
.isCalledOnce()
.with(value(localEcho.eventOrTransactionId), value(null))
} }
} }
@@ -1151,29 +1176,27 @@ class MessagesPresenterTest {
@Test @Test
fun `present - handle action retry sending`() = runTest { 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( val messageEvent = aMessageEvent(
sendState = LocalEventSendState.Failed.Unknown("Error"), sendState = LocalEventSendState.Failed.Unknown("Error"),
sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) },
) )
val presenter = createMessagesPresenter() val presenter = createMessagesPresenter(timeline = FakeTimeline(retrySendResult = retryLambda))
presenter.testWithLifecycleOwner { presenter.testWithLifecycleOwner {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent)) initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent))
advanceUntilIdle() advanceUntilIdle()
retryLambda.assertions().isCalledOnce() retryLambda.assertions().isCalledOnce().with(value(messageEvent.eventOrTransactionId))
} }
} }
@Test @Test
fun `present - handle action retry sending - failure is ignored`() = runTest { 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( val messageEvent = aMessageEvent(
sendState = LocalEventSendState.Failed.Unknown("Error"), sendState = LocalEventSendState.Failed.Unknown("Error"),
sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) },
) )
val presenter = createMessagesPresenter() val presenter = createMessagesPresenter(timeline = FakeTimeline(retrySendResult = retryLambda))
presenter.testWithLifecycleOwner { presenter.testWithLifecycleOwner {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
@@ -1184,18 +1207,16 @@ class MessagesPresenterTest {
} }
@Test @Test
fun `present - handle action retry sending - no send handle, it should have no effect`() = runTest { fun `present - handle action retry sending - no pending send state, it should have no effect`() = runTest {
val messageEvent = aMessageEvent( val retryLambda = lambdaRecorder { _: EventOrTransactionId -> Result.success(true) }
sendState = LocalEventSendState.Failed.Unknown("Error"), val messageEvent = aMessageEvent(sendState = null)
sendHandleProvider = { null }, val presenter = createMessagesPresenter(timeline = FakeTimeline(retrySendResult = retryLambda))
)
val presenter = createMessagesPresenter()
presenter.testWithLifecycleOwner { presenter.testWithLifecycleOwner {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent)) initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent))
advanceUntilIdle() advanceUntilIdle()
// No op! retryLambda.assertions().isNeverCalled()
} }
} }
@@ -1501,6 +1522,49 @@ class MessagesPresenterTest {
canPinUnpin = canPinUnpin, 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( private fun TestScope.createMessagesPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
timeline: Timeline = FakeTimeline(), timeline: Timeline = FakeTimeline(),
@@ -1523,6 +1587,7 @@ class MessagesPresenterTest {
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
timelineEventSink: (TimelineEvent) -> Unit = {}, timelineEventSink: (TimelineEvent) -> Unit = {},
timelineState: TimelineState = aTimelineState(eventSink = timelineEventSink),
permalinkParser: PermalinkParser = FakePermalinkParser(), permalinkParser: PermalinkParser = FakePermalinkParser(),
messageComposerPresenter: Presenter<MessageComposerState> = Presenter { messageComposerPresenter: Presenter<MessageComposerState> = Presenter {
aMessageComposerState( aMessageComposerState(
@@ -1545,7 +1610,7 @@ class MessagesPresenterTest {
room = joinedRoom, room = joinedRoom,
composerPresenter = messageComposerPresenter, composerPresenter = messageComposerPresenter,
voiceMessageComposerPresenterFactory = FakeDefaultVoiceMessageComposerPresenterFactory(backgroundScope), voiceMessageComposerPresenterFactory = FakeDefaultVoiceMessageComposerPresenterFactory(backgroundScope),
timelinePresenter = { aTimelineState(eventSink = timelineEventSink) }, timelinePresenter = { timelineState },
timelineProtectionPresenter = { aTimelineProtectionState() }, timelineProtectionPresenter = { aTimelineProtectionState() },
identityChangeStatePresenter = { anIdentityChangeState() }, identityChangeStatePresenter = { anIdentityChangeState() },
linkPresenter = { aLinkState() }, linkPresenter = { aLinkState() },
@@ -606,7 +606,7 @@ class MessagesViewTest : RobolectricTest() {
timelineEventsRecorder.clear() timelineEventsRecorder.clear()
clickOn(CommonStrings.action_remove_message) clickOn(CommonStrings.action_remove_message)
messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.Redact, timelineItem)) messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.AbortSending, timelineItem))
timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog) timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog)
} }
@@ -43,7 +43,7 @@ internal fun aMessageEvent(
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = A_MESSAGE, isEdited = false), content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = A_MESSAGE, isEdited = false),
inReplyTo: InReplyToDetails? = null, inReplyTo: InReplyToDetails? = null,
threadInfo: TimelineItemThreadInfo? = null, threadInfo: TimelineItemThreadInfo? = null,
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID), sendState: LocalEventSendState? = LocalEventSendState.Sent(AN_EVENT_ID),
debugInfoProvider: TimelineItemDebugInfoProvider = TimelineItemDebugInfoProvider { aTimelineItemDebugInfo() }, debugInfoProvider: TimelineItemDebugInfoProvider = TimelineItemDebugInfoProvider { aTimelineItemDebugInfo() },
messageShieldProvider: MessageShieldProvider = MessageShieldProvider { null }, messageShieldProvider: MessageShieldProvider = MessageShieldProvider { null },
sendHandleProvider: SendHandleProvider = SendHandleProvider { FakeSendHandle() } sendHandleProvider: SendHandleProvider = SendHandleProvider { FakeSendHandle() }
@@ -62,6 +62,8 @@ internal fun aMessageEvent(
reactionsState = aTimelineItemReactions(count = 0), reactionsState = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()), readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
localSendState = sendState, localSendState = sendState,
editSendState = null,
redactionSendState = null,
inReplyTo = inReplyTo, inReplyTo = inReplyTo,
threadInfo = threadInfo, threadInfo = threadInfo,
origin = null, 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.FakeToastHelper
import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.Presenter 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.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId 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.test
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay 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 { private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
return awaitItem() return awaitItem()
} }
@@ -1958,6 +2106,7 @@ class TimelinePresenterTest {
typingNotificationPresenter = { aTypingNotificationState() }, typingNotificationPresenter = { aTypingNotificationState() },
roomCallStatePresenter = { aStandByCallState() }, roomCallStatePresenter = { aStandByCallState() },
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
snackbarDispatcher = SnackbarDispatcher(),
analyticsService = FakeAnalyticsService(), analyticsService = FakeAnalyticsService(),
liveLocationShareManager = liveLocationShareManager, liveLocationShareManager = liveLocationShareManager,
markAsFullyRead = markAsFullyRead, 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), reactionsState = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()), readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
localSendState = LocalEventSendState.Sent(AN_EVENT_ID), localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
editSendState = null,
redactionSendState = null,
isEditable = false, isEditable = false,
canBeRepliedTo = false, canBeRepliedTo = false,
inReplyTo = null, inReplyTo = null,
@@ -83,6 +83,8 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
isOwn = false, isOwn = false,
isRemote = false, isRemote = false,
localSendState = null, localSendState = null,
editSendState = null,
redactionSendState = null,
reactions = persistentListOf(), reactions = persistentListOf(),
receipts = persistentListOf(), receipts = persistentListOf(),
sender = A_USER_ID, sender = A_USER_ID,
@@ -89,6 +89,10 @@ class SecurityAndPrivacyPresenter(
value = sessionEnterpriseService.isEncryptionDisabledByHomeserver() value = sessionEnterpriseService.isEncryptionDisabledByHomeserver()
} }
val arePublicRoomsDisabledByHomeserver by produceState(false) {
value = sessionEnterpriseService.arePublicRoomsDisabledByHomeserver()
}
val savedIsVisibleInRoomDirectory = remember { mutableStateOf<AsyncData<Boolean>>(AsyncData.Uninitialized) } val savedIsVisibleInRoomDirectory = remember { mutableStateOf<AsyncData<Boolean>>(AsyncData.Uninitialized) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
isRoomVisibleInRoomDirectory(savedIsVisibleInRoomDirectory) isRoomVisibleInRoomDirectory(savedIsVisibleInRoomDirectory)
@@ -254,6 +258,7 @@ class SecurityAndPrivacyPresenter(
selectableJoinedSpaces = selectableJoinedSpaces, selectableJoinedSpaces = selectableJoinedSpaces,
spaceSelectionMode = spaceSelectionMode, spaceSelectionMode = spaceSelectionMode,
isEncryptionDisabledByHomeserver = isEncryptionDisabledByHomeserver, isEncryptionDisabledByHomeserver = isEncryptionDisabledByHomeserver,
arePublicRoomsDisabledByHomeserver = arePublicRoomsDisabledByHomeserver,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
@@ -28,6 +28,7 @@ data class SecurityAndPrivacyState(
val editedSettings: SecurityAndPrivacySettings, val editedSettings: SecurityAndPrivacySettings,
val homeserverName: String, val homeserverName: String,
val isEncryptionDisabledByHomeserver: Boolean, val isEncryptionDisabledByHomeserver: Boolean,
private val arePublicRoomsDisabledByHomeserver: Boolean,
val showEnableEncryptionConfirmation: Boolean, val showEnableEncryptionConfirmation: Boolean,
private val isKnockEnabled: Boolean, private val isKnockEnabled: Boolean,
val saveAction: AsyncAction<Unit>, val saveAction: AsyncAction<Unit>,
@@ -39,6 +40,11 @@ data class SecurityAndPrivacyState(
) { ) {
val isSpaceMemberSelectable = spaceSelectionMode != SpaceSelectionMode.None 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: // Show SpaceMember option in two cases:
// - SpaceMember is the current saved value // - SpaceMember is the current saved value
// - SpaceMember option is selectable (ie. the FF is enabled and there is at least one space to select) // - 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, showEncryptionConfirmation = true,
isSpace = false, isSpace = false,
), ),
aSecurityAndPrivacyState(
arePublicRoomsDisabledByHomeserver = true,
isSpace = false,
),
) )
} }
@@ -139,6 +143,7 @@ fun aSecurityAndPrivacyState(
selectableJoinedSpaces: Set<SpaceRoom> = emptySet(), selectableJoinedSpaces: Set<SpaceRoom> = emptySet(),
spaceSelectionMode: SpaceSelectionMode = SpaceSelectionMode.None, spaceSelectionMode: SpaceSelectionMode = SpaceSelectionMode.None,
isEncryptionDisabledByHomeserver: Boolean = false, isEncryptionDisabledByHomeserver: Boolean = false,
arePublicRoomsDisabledByHomeserver: Boolean = false,
eventSink: (SecurityAndPrivacyEvent) -> Unit = {} eventSink: (SecurityAndPrivacyEvent) -> Unit = {}
) = SecurityAndPrivacyState( ) = SecurityAndPrivacyState(
editedSettings = editedSettings, editedSettings = editedSettings,
@@ -152,5 +157,6 @@ fun aSecurityAndPrivacyState(
selectableJoinedSpaces = selectableJoinedSpaces.toImmutableSet(), selectableJoinedSpaces = selectableJoinedSpaces.toImmutableSet(),
spaceSelectionMode = spaceSelectionMode, spaceSelectionMode = spaceSelectionMode,
isEncryptionDisabledByHomeserver = isEncryptionDisabledByHomeserver, isEncryptionDisabledByHomeserver = isEncryptionDisabledByHomeserver,
arePublicRoomsDisabledByHomeserver = arePublicRoomsDisabledByHomeserver,
eventSink = eventSink, eventSink = eventSink,
) )
@@ -232,6 +232,7 @@ private fun RoomAccessSection(
title = stringResource(R.string.screen_security_and_privacy_room_access_section_header), title = stringResource(R.string.screen_security_and_privacy_room_access_section_header),
modifier = modifier, modifier = modifier,
) { ) {
if (state.showAnyoneOption) {
ListItem( ListItem(
content = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_title)) }, 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)) }, supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_description)) },
@@ -239,6 +240,7 @@ private fun RoomAccessSection(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Public())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Public())),
onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.Anyone) }, onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.Anyone) },
) )
}
if (state.showSpaceMemberOption) { if (state.showSpaceMemberOption) {
ListItem( ListItem(
content = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_title)) }, 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.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.libraries.previewutils.room.aSpaceRoom 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.assert
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -1048,7 +1050,10 @@ class SecurityAndPrivacyPresenterTest {
@Test @Test
fun `present - showEncryptionSection is false if the HS disabled encryption`() = runTest { 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( val presenter = createSecurityAndPrivacyPresenter(
sessionEnterpriseService = sessionEnterpriseService, 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( private fun roomPermissions(
canChangeRoomAccess: Boolean = true, canChangeRoomAccess: Boolean = true,
canChangeHistoryVisibility: Boolean = true, canChangeHistoryVisibility: Boolean = true,
@@ -1100,6 +1141,7 @@ class SecurityAndPrivacyPresenterTest {
spaceSelectionStateHolder: SpaceSelectionStateHolder = SpaceSelectionStateHolder(), spaceSelectionStateHolder: SpaceSelectionStateHolder = SpaceSelectionStateHolder(),
sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService( sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService(
isEncryptionDisabledResult = { false }, isEncryptionDisabledResult = { false },
arePublicRoomsDisabledResult = { false },
) )
): SecurityAndPrivacyPresenter { ): SecurityAndPrivacyPresenter {
return SecurityAndPrivacyPresenter( return SecurityAndPrivacyPresenter(
+5 -5
View File
@@ -44,7 +44,7 @@ appyx = "1.7.1"
sqldelight = "2.4.0" sqldelight = "2.4.0"
wysiwyg = "2.42.1" wysiwyg = "2.42.1"
telephoto = "0.19.0" telephoto = "0.19.0"
maplibre-compose = "0.18.0" maplibre-compose = "0.19.0"
# Dependency analysis # Dependency analysis
dependencyAnalysis = "3.19.2" dependencyAnalysis = "3.19.2"
@@ -64,7 +64,7 @@ ktlint = "1.8.0"
androidx-test-ext-junit = "1.3.0" androidx-test-ext-junit = "1.3.0"
kover = "0.9.9" kover = "0.9.9"
# The native MatrixRTC call component, https://github.com/element-hq/element-call-android # 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] [libraries]
# Project # 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-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-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" } 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" sqlite = "androidx.sqlite:sqlite-ktx:2.7.1"
unifiedpush = "org.unifiedpush.android:connector:3.3.5" unifiedpush = "org.unifiedpush.android:connector:3.3.5"
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
@@ -227,7 +227,7 @@ color_picker = "io.mhssn:colorpicker:1.0.0"
# Analytics # Analytics
posthog = "com.posthog:posthog-android:3.60.7" 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 # main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.38.0" 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" } metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro" }
# Element Call # 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. # 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_bom = { module = "io.element.android:element-call-bom", version.ref = "element_call" }
element_call_api = { module = "io.element.android:element-call-api" } 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 distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
networkTimeout=10000 networkTimeout=10000
retries=0 retries=0
retryBackOffMs=500 retryBackOffMs=500
@@ -8,26 +8,32 @@
package io.element.android.libraries.designsystem.atomic.atoms 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.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding 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.layout.sizeIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceAtLeast
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.SemanticColors import io.element.android.compound.tokens.generated.SemanticColors
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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 import io.element.android.libraries.designsystem.theme.components.Text
private const val MAX_COUNT = 99 private const val MAX_COUNT = 99
@@ -54,7 +60,7 @@ fun CounterAtom(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
containerColor: Color? = null, containerColor: Color? = null,
contentColor: Color? = null, contentColor: Color? = null,
contentPadding: PaddingValues = PaddingValues.Zero, contentPadding: PaddingValues = PaddingValues(vertical = 0.5.dp),
textStyle: TextStyle = CounterAtomDefaults.textStyle, textStyle: TextStyle = CounterAtomDefaults.textStyle,
dotSize: Dp = 12.0.dp, dotSize: Dp = 12.0.dp,
isCritical: Boolean = false, isCritical: Boolean = false,
@@ -65,32 +71,43 @@ fun CounterAtom(
else -> MAX_COUNT_STRING else -> MAX_COUNT_STRING
} }
val minSingleDigitSize = if (countAsText?.length == 1) { val density = LocalDensity.current
val textMeasurer = rememberTextMeasurer() val textMeasurer = rememberTextMeasurer()
// Measure the maximum count string size val minTextSize = remember(density.density, textStyle) {
val textLayoutResult = textMeasurer.measure( val result = textMeasurer.measure(
text = countAsText, text = MAX_COUNT_STRING,
style = textStyle style = textStyle
) )
val textSize = textLayoutResult.size with(density) {
maxOf(textSize.width, textSize.height) minOf(result.size.width, result.size.height).toDp()
}
}
val sizeModifier = if (countAsText?.length == 1) {
Modifier.size(minTextSize)
} else { } else {
0 Modifier.sizeIn(minHeight = minTextSize)
} }
androidx.compose.material3.Badge( val extraPadding = if (countAsText?.length == 1) {
modifier = modifier.then( contentPadding
if (countAsText == null) Modifier.sizeIn(minWidth = dotSize, minHeight = dotSize) else Modifier } else {
), contentPadding + PaddingValues(horizontal = 4.dp)
containerColor = containerColor ?: if (isCritical) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconAccentPrimary, }
contentColor = contentColor ?: ElementTheme.colors.textOnSolidPrimary, val backgroundColor = containerColor ?: if (isCritical) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconAccentPrimary
content = countAsText?.let {
@Composable { Box(
modifier = modifier
.clip(RoundedCornerShape(100))
.background(backgroundColor)
.then(
if (countAsText == null) Modifier.size(dotSize) else Modifier
)
) {
countAsText?.let {
Text( Text(
modifier = Modifier.sizeIn( modifier = sizeModifier
minWidth = (minSingleDigitSize.toDp() - 8.dp).coerceAtLeast(0.dp), .align(Alignment.Center)
minHeight = minSingleDigitSize.toDp() .padding(extraPadding),
).padding(contentPadding),
text = countAsText, text = countAsText,
style = textStyle, style = textStyle,
color = contentColor ?: ElementTheme.colors.textOnSolidPrimary, color = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
@@ -98,7 +115,6 @@ fun CounterAtom(
) )
} }
} }
)
} }
object CounterAtomDefaults { object CounterAtomDefaults {
@@ -112,7 +128,7 @@ internal fun CounterAtomPreview() = ElementPreview {
Row(horizontalArrangement = spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
CounterAtom(count = 0) CounterAtom(count = 0)
CounterAtom(count = 4) CounterAtom(count = 4)
CounterAtom(count = 99) CounterAtom(count = 10)
CounterAtom(count = 100) CounterAtom(count = 100)
CounterAtom(count = 4, isCritical = true) 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.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -27,29 +26,37 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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 @Composable
fun UnreadIndicatorAtom( fun UnreadIndicatorAtom(
count: Long?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
size: Dp = 12.dp, dotIndicatorSize: Dp = 12.dp,
count: Long? = null, color: Color = ElementTheme.colors.iconAccentPrimary,
color: Color = ElementTheme.colors.unreadIndicator,
isVisible: Boolean = true,
contentDescription: String? = null, contentDescription: String? = null,
border: BorderStroke? = null, border: BorderStroke? = null,
contentPadding: PaddingValues = PaddingValues.Zero, contentPadding: PaddingValues = PaddingValues.Zero,
) { ) {
when { count?.let {
!isVisible -> Spacer(modifier = modifier.size(size))
else -> count?.let {
CounterAtom( CounterAtom(
count = count.toInt(), count = count.toInt(),
modifier = modifier modifier = modifier
.semantics { .semantics {
contentDescription?.let { this.contentDescription = it } contentDescription?.let { this.contentDescription = it }
} }
.then(if (border != null) Modifier.border(border, RoundedCornerShape(percent = 50)) else Modifier), .then(if (count == 0L) Modifier.size(dotIndicatorSize) else Modifier)
.then(if (border != null) Modifier.border(border, RoundedCornerShape(percent = 100)) else Modifier),
containerColor = color, containerColor = color,
contentColor = ElementTheme.colors.bgCanvasDefault, contentColor = ElementTheme.colors.bgCanvasDefault,
textStyle = ElementTheme.typography.fontBodySmMedium, textStyle = ElementTheme.typography.fontBodySmMedium,
@@ -57,7 +64,6 @@ fun UnreadIndicatorAtom(
) )
} }
} }
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
@@ -66,7 +72,7 @@ internal fun UnreadIndicatorAtomPreview() = ElementPreview {
UnreadIndicatorAtom(count = null) UnreadIndicatorAtom(count = null)
UnreadIndicatorAtom(count = 0) UnreadIndicatorAtom(count = 0)
UnreadIndicatorAtom(count = 1) UnreadIndicatorAtom(count = 1)
UnreadIndicatorAtom(count = 10) UnreadIndicatorAtom(count = 20)
UnreadIndicatorAtom(count = 99) UnreadIndicatorAtom(count = 99)
UnreadIndicatorAtom(count = 999) UnreadIndicatorAtom(count = 999)
} }
@@ -32,9 +32,6 @@ val SemanticColors.roomListRoomMessage
val SemanticColors.roomListRoomMessageDate val SemanticColors.roomListRoomMessageDate
get() = textSecondary get() = textSecondary
val SemanticColors.unreadIndicator
get() = iconAccentTertiary
val SemanticColors.placeholderBackground val SemanticColors.placeholderBackground
get() = bgSubtleSecondary get() = bgSubtleSecondary
@@ -83,7 +80,6 @@ internal fun ColorAliasesPreview() = ElementPreview {
"roomListRoomName" to ElementTheme.colors.roomListRoomName, "roomListRoomName" to ElementTheme.colors.roomListRoomName,
"roomListRoomMessage" to ElementTheme.colors.roomListRoomMessage, "roomListRoomMessage" to ElementTheme.colors.roomListRoomMessage,
"roomListRoomMessageDate" to ElementTheme.colors.roomListRoomMessageDate, "roomListRoomMessageDate" to ElementTheme.colors.roomListRoomMessageDate,
"unreadIndicator" to ElementTheme.colors.unreadIndicator,
"placeholderBackground" to ElementTheme.colors.placeholderBackground, "placeholderBackground" to ElementTheme.colors.placeholderBackground,
"messageFromMeBackground" to ElementTheme.colors.messageFromMeBackground, "messageFromMeBackground" to ElementTheme.colors.messageFromMeBackground,
"messageFromOtherBackground" to ElementTheme.colors.messageFromOtherBackground, "messageFromOtherBackground" to ElementTheme.colors.messageFromOtherBackground,
@@ -36,7 +36,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons 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.PlainTooltip
import io.element.android.libraries.designsystem.components.tooltip.TooltipBox import io.element.android.libraries.designsystem.components.tooltip.TooltipBox
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -140,12 +140,10 @@ fun HorizontalFloatingToolbarItem(
BadgedBox( BadgedBox(
badge = { badge = {
counter?.let { counter?.let {
CounterAtom( UnreadIndicatorAtom(
count = it, count = it.toLong(),
// Tweak the offset so it's centered on the icon // Tweak the offset so it's centered on the icon
modifier = Modifier.offset(x = (-16).dp, y = 10.dp), 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 }, defaultValue = { false },
isFinished = 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( NewTimelineEventRenderer(
key = "feature.new_timeline_event_renderer", key = "feature.new_timeline_event_renderer",
title = "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.poll.PollKind
import io.element.android.libraries.matrix.api.room.IntentionalMention 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.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.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId 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> 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 eventIds the events whose content is forwarded.
* @param roomIds the rooms to forward it to. * @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. * 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. * Get the latest event id of the timeline, or `null` when it holds no event yet.
*/ */
suspend fun getLatestEventId(): Result<EventId?> 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 isOwn: Boolean,
val isRemote: Boolean, val isRemote: Boolean,
val localSendState: LocalEventSendState?, val localSendState: LocalEventSendState?,
val editSendState: LocalEventSendState?,
val redactionSendState: LocalEventSendState?,
val reactions: ImmutableList<EventReaction>, val reactions: ImmutableList<EventReaction>,
val receipts: ImmutableList<Receipt>, val receipts: ImmutableList<Receipt>,
val sender: UserId, val sender: UserId,
@@ -877,6 +877,7 @@ class RustMatrixClient(
override fun sendQueueDisabledFlow(): Flow<RoomId> = mxCallbackFlow { override fun sendQueueDisabledFlow(): Flow<RoomId> = mxCallbackFlow {
innerClient.subscribeToSendQueueStatus(object : SendQueueRoomErrorListener { innerClient.subscribeToSendQueueStatus(object : SendQueueRoomErrorListener {
override fun onError(roomId: String, error: ClientException) { override fun onError(roomId: String, error: ClientException) {
Timber.e(error, "Send queue error for room $roomId")
trySend(RoomId(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.core.RoomId
import io.element.android.libraries.matrix.api.room.ForwardEventException 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.roomlist.roomOrNull
import io.element.android.libraries.matrix.impl.timeline.runWithTimelineListenerRegistered
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import org.matrix.rustcomponents.sdk.MsgLikeKind import org.matrix.rustcomponents.sdk.MsgLikeKind
import org.matrix.rustcomponents.sdk.RoomListService import org.matrix.rustcomponents.sdk.RoomListService
@@ -31,33 +30,36 @@ class RoomContentForwarder(
private val roomListService: RoomListService, private val roomListService: RoomListService,
) { ) {
/** /**
* Forwards the event with the given [eventId] from the [fromTimeline] to the given [toRoomIds]. * Forwards the events with the given [eventIds] from the [fromTimeline] to the given [toRoomIds].
* @param fromTimeline the room to forward the event from * 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 eventId the id of the event to forward * @param fromTimeline the room to forward the events from
* @param toRoomIds the ids of the rooms to forward the event to * @param eventIds the ids of the events to forward, in the order they must be sent
* @param timeoutMs the maximum time in milliseconds to wait for the event to be sent to a room * @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( suspend fun forward(
fromTimeline: Timeline, fromTimeline: Timeline,
eventId: EventId, eventIds: List<EventId>,
toRoomIds: List<RoomId>, toRoomIds: List<RoomId>,
timeoutMs: Long = 5000L timeoutMs: Long = 5000L
) { ) {
val messageLikeContent = (fromTimeline.getEventTimelineItemByEventId(eventId.value).content as? TimelineItemContent.MsgLike)?.content val contents = eventIds.map { eventId ->
?: throw ForwardEventException(toRoomIds) val timelineEvent = fromTimeline.getEventTimelineItemByEventId(eventId.value)
val content = timelineEvent.content as? TimelineItemContent.MsgLike ?: throw ForwardEventException(toRoomIds)
val content = (messageLikeContent.kind as? MsgLikeKind.Message)?.content val message = content.content.kind as? MsgLikeKind.Message ?: throw ForwardEventException(toRoomIds)
?: throw ForwardEventException(toRoomIds) contentWithoutRelationFromMessage(message.content)
}
val targetRooms = toRoomIds.toSet().mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) } val targetRooms = toRoomIds.toSet().mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) }
val failedForwardingTo = mutableSetOf<RoomId>() val failedForwardingTo = mutableSetOf<RoomId>()
targetRooms.parallelMap { room -> targetRooms.parallelMap { room ->
room.use { targetRoom -> room.use { targetRoom ->
runCatchingExceptions { runCatchingExceptions {
// Sending a message requires a registered timeline listener targetRoom.timeline().use { timeline ->
targetRoom.timeline().runWithTimelineListenerRegistered { // Send the contents sequentially, so that they are received in the same order
contents.forEach { content ->
withTimeout(timeoutMs.milliseconds) { withTimeout(timeoutMs.milliseconds) {
targetRoom.timeline().send(contentWithoutRelationFromMessage(content)) timeline.send(content)
}
} }
} }
} }
@@ -57,7 +57,7 @@ fun RoomListInterface.loadingStateFlow(): Flow<RoomListLoadingState> =
internal fun RoomListInterface.entriesFlow( internal fun RoomListInterface.entriesFlow(
pageSize: Int, pageSize: Int,
initialFilterKind: RoomListEntriesDynamicFilterKind, initialFilterKind: RoomListEntriesDynamicFilterKind,
onControllerCreated: (RoomListDynamicEntriesController) -> Unit, onControllerCreated: suspend (RoomListDynamicEntriesController) -> Unit,
): Flow<List<RoomListEntriesUpdate>> = ): Flow<List<RoomListEntriesUpdate>> =
callbackFlow { callbackFlow {
val listener = object : RoomListEntriesListener { val listener = object : RoomListEntriesListener {
@@ -70,9 +70,13 @@ internal fun RoomListInterface.entriesFlow(
listener = listener, listener = listener,
) )
val controller = result.controller() val controller = result.controller()
// 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) controller.setFilter(initialFilterKind)
onControllerCreated(controller) onControllerCreated(controller)
awaitClose { awaitClose()
} finally {
result.entriesStream().cancelAndDestroy() result.entriesStream().cancelAndDestroy()
controller.destroy() controller.destroy()
result.destroy() result.destroy()
@@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController
import org.matrix.rustcomponents.sdk.RoomListLoadingState import org.matrix.rustcomponents.sdk.RoomListLoadingState
import org.matrix.rustcomponents.sdk.RoomListService import org.matrix.rustcomponents.sdk.RoomListService
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
@@ -49,7 +48,13 @@ internal class RoomListFactory(
val summariesFlow = MutableSharedFlow<List<RoomSummary>>(replay = 1, extraBufferCapacity = 1) val summariesFlow = MutableSharedFlow<List<RoomSummary>>(replay = 1, extraBufferCapacity = 1)
val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryFactory, analyticsService) val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryFactory, analyticsService)
var innerRoomList: InnerRoomList? = null 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") val firstRoomsTransaction = analyticsService.startTransaction("Load first set of rooms", "innerRoomList.entriesFlow")
@@ -60,7 +65,7 @@ internal class RoomListFactory(
pageSize = pageSize, pageSize = pageSize,
initialFilterKind = RoomListFilterMapper.toRustFilter(initialFilter), initialFilterKind = RoomListFilterMapper.toRustFilter(initialFilter),
onControllerCreated = { controller -> onControllerCreated = { controller ->
dynamicController = controller dynamicRoomList.onControllerCreated(controller, initialFilter)
} }
).onEach { update -> ).onEach { update ->
if (!firstRoomsTransaction.isFinished()) { if (!firstRoomsTransaction.isFinished()) {
@@ -80,13 +85,7 @@ internal class RoomListFactory(
}.invokeOnCompletion { }.invokeOnCompletion {
innerRoomList?.destroy() innerRoomList?.destroy()
} }
return RustDynamicRoomList( return dynamicRoomList
summaries = summariesFlow,
loadingState = loadingStateFlow,
processor = processor,
pageSize = pageSize,
dynamicController = { dynamicController }
)
} }
} }
@@ -24,37 +24,58 @@ internal class RustDynamicRoomList(
override val loadingState: MutableStateFlow<RoomList.LoadingState>, override val loadingState: MutableStateFlow<RoomList.LoadingState>,
private val processor: RoomSummaryListProcessor, private val processor: RoomSummaryListProcessor,
override val pageSize: Int, override val pageSize: Int,
private val dynamicController: () -> RoomListDynamicEntriesController?, initialFilter: RoomListFilter,
private val addPagesCount: Int = DEFAULT_ADD_PAGES_COUNT private val addPagesCount: Int = DEFAULT_ADD_PAGES_COUNT
) : DynamicRoomList { ) : DynamicRoomList {
private val mutex = Mutex() 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() { override suspend fun rebuildSummaries() {
processor.rebuildRoomSummaries() processor.rebuildRoomSummaries()
} }
override suspend fun updateFilter(filter: RoomListFilter) { override suspend fun updateFilter(filter: RoomListFilter) {
mutex.withLock { mutex.withLock {
dynamicController()?.let { controller -> currentFilter = filter
dynamicController?.applyFilter(filter)
}
}
private fun RoomListDynamicEntriesController.applyFilter(filter: RoomListFilter) {
// Reset pagination when filter changes // Reset pagination when filter changes
controller.resetToOnePage() resetToOnePage()
val rustFilter = RoomListFilterMapper.toRustFilter(filter) setFilter(RoomListFilterMapper.toRustFilter(filter))
controller.setFilter(rustFilter)
// Then preload some pages // Then preload some pages
controller.addPages(addPagesCount) addPages(addPagesCount)
}
}
} }
override suspend fun loadMore() { override suspend fun loadMore() {
mutex.withLock { mutex.withLock {
dynamicController()?.addPages(addPagesCount) dynamicController?.addPages(addPagesCount)
} }
} }
override suspend fun reset() { override suspend fun reset() {
mutex.withLock { mutex.withLock {
dynamicController()?.resetToOnePage() dynamicController?.resetToOnePage()
} }
} }
@@ -53,16 +53,3 @@ internal fun TimelineInterface.timelineDiffFlow(): Flow<List<TimelineDiff>> =
}.catch { }.catch {
Timber.d(it, "timelineDiffFlow() failed") Timber.d(it, "timelineDiffFlow() failed")
}.buffer(Channel.UNLIMITED) }.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.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline 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.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.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.impl.media.GalleryMediaUploadHandlerImpl 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 { runCatchingExceptions {
roomContentForwarder.forward(fromTimeline = inner, eventId = eventId, toRoomIds = roomIds) roomContentForwarder.forward(fromTimeline = inner, eventIds = eventIds, toRoomIds = roomIds)
}.onFailure { }.onFailure {
Timber.tag(loggerTag).e(it) 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) { private suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> = withContext(dispatcher) {
runCatchingExceptions { runCatchingExceptions {
inner.fetchDetailsForEvent(eventId.value) 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, isOwn = isOwn,
isRemote = isRemote, isRemote = isRemote,
localSendState = localSendState?.map(), localSendState = localSendState?.map(),
editSendState = editSendState?.map(),
redactionSendState = redactionSendState?.map(),
reactions = reactions.map(), reactions = reactions.map(),
receipts = readReceipts.map(), receipts = readReceipts.map(),
sender = UserId(sender), sender = UserId(sender),
@@ -113,12 +115,8 @@ fun RustEventSendState?.map(): LocalEventSendState? {
) )
} }
is QueueWedgeError.GenericApiError -> { is QueueWedgeError.GenericApiError -> {
if (isRecoverable) {
LocalEventSendState.Sending.Event
} else {
LocalEventSendState.Failed.Unknown(queueWedgeError.msg) LocalEventSendState.Failed.Unknown(queueWedgeError.msg)
} }
}
is QueueWedgeError.InvalidMimeType -> { is QueueWedgeError.InvalidMimeType -> {
LocalEventSendState.Failed.InvalidMimeType(queueWedgeError.mimeType) 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.MsgType
import io.element.android.libraries.matrix.api.timeline.ReceiptType 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.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.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
@@ -60,7 +61,9 @@ class FakeTimeline(
receiptType: ReceiptType, receiptType: ReceiptType,
) -> Result<Unit> = { _, _ -> ) -> Result<Unit> = { _, _ ->
lambdaError() lambdaError()
} },
private val retrySendResult: (eventOrTransactionId: EventOrTransactionId) -> Result<Boolean> = { lambdaError() },
private val abortSendResult: (eventOrTransactionId: EventOrTransactionId) -> Result<Boolean> = { lambdaError() },
) : Timeline { ) : Timeline {
var sendMessageLambda: ( var sendMessageLambda: (
body: String, 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 { override suspend fun forwardEvents(eventIds: List<EventId>, roomIds: List<RoomId>): Result<Unit> = simulateLongTask {
forwardEventLambda(eventId, roomIds) forwardEventsLambda(eventIds, roomIds)
} }
var createPollLambda: ( var createPollLambda: (
@@ -477,6 +480,14 @@ class FakeTimeline(
return getLatestEventIdResult() 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 var closeCounter = 0
private set private set
@@ -52,6 +52,8 @@ fun anEventTimelineItem(
isOwn: Boolean = false, isOwn: Boolean = false,
isRemote: Boolean = false, isRemote: Boolean = false,
localSendState: LocalEventSendState? = null, localSendState: LocalEventSendState? = null,
editSendState: LocalEventSendState? = null,
redactionSendState: LocalEventSendState? = null,
reactions: ImmutableList<EventReaction> = persistentListOf(), reactions: ImmutableList<EventReaction> = persistentListOf(),
receipts: ImmutableList<Receipt> = persistentListOf(), receipts: ImmutableList<Receipt> = persistentListOf(),
sender: UserId = A_USER_ID, sender: UserId = A_USER_ID,
@@ -69,6 +71,8 @@ fun anEventTimelineItem(
isOwn = isOwn, isOwn = isOwn,
isRemote = isRemote, isRemote = isRemote,
localSendState = localSendState, localSendState = localSendState,
editSendState = editSendState,
redactionSendState = redactionSendState,
reactions = reactions, reactions = reactions,
receipts = receipts, receipts = receipts,
sender = sender, sender = sender,
@@ -9,6 +9,8 @@
package io.element.android.libraries.matrix.ui.components package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.clickable 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.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer 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.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics 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.Icon
import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text 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 import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
@@ -62,9 +70,31 @@ fun SelectedItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val actionRemove = stringResource(id = CommonStrings.action_remove) val actionRemove = stringResource(id = CommonStrings.action_remove)
val interactionSource = remember { MutableInteractionSource() }
Box( Box(
modifier = modifier modifier = modifier
.width(avatarData.size.dp) .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 { .clearAndSetSemantics {
contentDescription = a11yContentDescription contentDescription = a11yContentDescription
if (canRemove) { if (canRemove) {
@@ -130,6 +160,8 @@ fun SelectedItem(
.clip(CircleShape) .clip(CircleShape)
.size(20.dp) .size(20.dp)
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
// The whole item is focusable, not the remove button
.focusProperties { canFocus = false }
.clickable( .clickable(
indication = ripple(), indication = ripple(),
interactionSource = remember { MutableInteractionSource() }, 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.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon 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.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.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomVisibility import io.element.android.libraries.matrix.api.spaces.SpaceRoomVisibility
@@ -168,10 +167,7 @@ private fun NameAndIndicatorRow(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
if (showIndicator) { if (showIndicator) {
UnreadIndicatorAtom( UnreadIndicatorAtom(count = 0)
color = ElementTheme.colors.unreadIndicator,
count = 0,
)
} }
} }
} }
@@ -13,7 +13,6 @@ import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
sealed interface RoomSelectEvent { sealed interface RoomSelectEvent {
data class ToggleSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvent data class ToggleSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvent
data object ToggleSearchActive : RoomSelectEvent
data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvent data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvent
data object ToggleAccountListExpanded : RoomSelectEvent data object ToggleAccountListExpanded : RoomSelectEvent
data class SelectAccount(val sessionId: SessionId) : RoomSelectEvent data class SelectAccount(val sessionId: SessionId) : RoomSelectEvent
@@ -8,7 +8,6 @@
package io.element.android.libraries.roomselect.impl package io.element.android.libraries.roomselect.impl
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -67,7 +66,6 @@ class RoomSelectPresenter(
override fun present(): RoomSelectState { override fun present(): RoomSelectState {
var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) } var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) }
val queryState = rememberTextFieldState() val queryState = rememberTextFieldState()
var isSearchActive by remember { mutableStateOf(false) }
var isAccountListExpanded by remember { mutableStateOf(false) } var isAccountListExpanded by remember { mutableStateOf(false) }
// Null until the sessions are loaded // Null until the sessions are loaded
val sessions by remember { sessionStore.sessionsFlow() }.collectAsState(initial = null) val sessions by remember { sessionStore.sessionsFlow() }.collectAsState(initial = null)
@@ -157,7 +155,7 @@ class RoomSelectPresenter(
derivedStateOf { derivedStateOf {
when { when {
roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList()) roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList())
isSearchActive -> SearchBarResultState.NoResultsFound queryState.text.isNotBlank() -> SearchBarResultState.NoResultsFound
else -> SearchBarResultState.Initial else -> SearchBarResultState.Initial
} }
} }
@@ -173,13 +171,11 @@ class RoomSelectPresenter(
selectedRooms.adding(event.room) selectedRooms.adding(event.room)
} }
} }
RoomSelectEvent.ToggleSearchActive -> isSearchActive = !isSearchActive
RoomSelectEvent.ToggleAccountListExpanded -> isAccountListExpanded = !isAccountListExpanded RoomSelectEvent.ToggleAccountListExpanded -> isAccountListExpanded = !isAccountListExpanded
is RoomSelectEvent.SelectAccount -> { is RoomSelectEvent.SelectAccount -> {
selectedSessionId = event.sessionId 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() selectedRooms = persistentListOf()
queryState.clearText()
isAccountListExpanded = false isAccountListExpanded = false
} }
is RoomSelectEvent.UpdateVisibleRange -> coroutineScope.launch { is RoomSelectEvent.UpdateVisibleRange -> coroutineScope.launch {
@@ -193,7 +189,6 @@ class RoomSelectPresenter(
maxNumberOfRooms = maxNumberOfRooms, maxNumberOfRooms = maxNumberOfRooms,
resultState = searchResults, resultState = searchResults,
searchQuery = queryState, searchQuery = queryState,
isSearchActive = isSearchActive,
selectedRooms = selectedRooms, selectedRooms = selectedRooms,
selectedAccount = selectedAccount, selectedAccount = selectedAccount,
hasRoomListError = hasRoomListError, hasRoomListError = hasRoomListError,
@@ -20,7 +20,6 @@ data class RoomSelectState(
val maxNumberOfRooms: Int, val maxNumberOfRooms: Int,
val resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>>, val resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>>,
val searchQuery: TextFieldState, val searchQuery: TextFieldState,
val isSearchActive: Boolean,
val selectedRooms: ImmutableList<SelectRoomInfo>, val selectedRooms: ImmutableList<SelectRoomInfo>,
val selectedAccount: MatrixUser, val selectedAccount: MatrixUser,
/** /**
@@ -26,17 +26,15 @@ open class RoomSelectStatePreviewParam : PreviewParameterProvider<RoomSelectStat
override val values: Sequence<RoomSelectState> override val values: Sequence<RoomSelectState>
get() = sequenceOf( get() = sequenceOf(
aRoomSelectState(), aRoomSelectState(),
aRoomSelectState(searchQuery = "Test", isSearchActive = true), aRoomSelectState(searchQuery = "Test", resultState = SearchBarResultState.NoResultsFound),
aRoomSelectState(resultState = SearchBarResultState.Results(aRoomSelectRoomList())), aRoomSelectState(resultState = SearchBarResultState.Results(aRoomSelectRoomList())),
aRoomSelectState( aRoomSelectState(
resultState = SearchBarResultState.Results(aRoomSelectRoomList()), resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
searchQuery = "Test", searchQuery = "Test",
isSearchActive = true,
), ),
aRoomSelectState( aRoomSelectState(
resultState = SearchBarResultState.Results(aRoomSelectRoomList()), resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
searchQuery = "Test", searchQuery = "Test",
isSearchActive = true,
selectedRooms = aRoomSelectRoomList().subList(0, 1), selectedRooms = aRoomSelectRoomList().subList(0, 1),
), ),
aRoomSelectState( aRoomSelectState(
@@ -76,7 +74,6 @@ internal fun aRoomSelectState(
maxNumberOfRooms: Int = 10, maxNumberOfRooms: Int = 10,
resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>> = SearchBarResultState.Initial, resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>> = SearchBarResultState.Initial,
searchQuery: String = "", searchQuery: String = "",
isSearchActive: Boolean = false,
selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(), selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(),
selectedAccount: MatrixUser = MatrixUser(UserId("@alice:example.org")), selectedAccount: MatrixUser = MatrixUser(UserId("@alice:example.org")),
hasRoomListError: Boolean = false, hasRoomListError: Boolean = false,
@@ -88,7 +85,6 @@ internal fun aRoomSelectState(
maxNumberOfRooms = maxNumberOfRooms, maxNumberOfRooms = maxNumberOfRooms,
resultState = resultState, resultState = resultState,
searchQuery = TextFieldState(initialText = searchQuery), searchQuery = TextFieldState(initialText = searchQuery),
isSearchActive = isSearchActive,
selectedRooms = selectedRooms, selectedRooms = selectedRooms,
selectedAccount = selectedAccount, selectedAccount = selectedAccount,
hasRoomListError = hasRoomListError, hasRoomListError = hasRoomListError,
@@ -10,6 +10,7 @@ package io.element.android.libraries.roomselect.impl
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn 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.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState 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.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -46,9 +52,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontStyle 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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp 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.ListItem
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader 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.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.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.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
@@ -123,10 +131,8 @@ fun RoomSelectView(
} }
var canHandleBack by remember { mutableStateOf(true) } var canHandleBack by remember { mutableStateOf(true) }
fun onBackButton(state: RoomSelectState) { fun onBackButton() {
if (state.isSearchActive) { if (canHandleBack) {
state.eventSink(RoomSelectEvent.ToggleSearchActive)
} else if (canHandleBack) {
canHandleBack = false canHandleBack = false
onDismiss() onDismiss()
} }
@@ -134,16 +140,34 @@ fun RoomSelectView(
BackHandler( BackHandler(
enabled = canHandleBack, 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 // 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() } 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. // 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. // Use a new state when the selected account changes, so that the section is fully displayed again.
val accountSwitchScrollBehavior = key(state.selectedAccount.userId) { val accountSwitchScrollBehavior = key(state.selectedAccount.userId) {
TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) 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 -> OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
state.eventSink(RoomSelectEvent.UpdateVisibleRange(visibleRange)) state.eventSink(RoomSelectEvent.UpdateVisibleRange(visibleRange))
} }
@@ -153,13 +177,13 @@ fun RoomSelectView(
topBar = { topBar = {
TopAppBar( TopAppBar(
titleStr = when (state.mode) { 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) RoomSelectMode.Share -> stringResource(CommonStrings.common_send_to)
}, },
navigationIcon = { navigationIcon = {
BackButton( BackButton(
enabled = canHandleBack, enabled = canHandleBack,
onClick = { onBackButton(state) } onClick = ::onBackButton,
) )
}, },
actions = { actions = {
@@ -177,55 +201,29 @@ fun RoomSelectView(
Modifier Modifier
.padding(paddingValues) .padding(paddingValues)
.consumeWindowInsets(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 modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding( .padding(
WindowInsets.safeDrawing WindowInsets.safeDrawing
.only(WindowInsetsSides.Horizontal) .only(WindowInsetsSides.Horizontal)
.asPaddingValues() .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),
LazyColumn( placeholder = stringResource(CommonStrings.action_search),
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) { if (state.showAccountSwitch) {
// Keep this space outside the collapsing section, so that the section does not touch the search bar when it is collapsing // 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)) Spacer(modifier = Modifier.height(8.dp))
TopAppBarScrollBehaviorLayout(scrollBehavior = accountSwitchScrollBehavior) { TopAppBarScrollBehaviorLayout(scrollBehavior = accountSwitchScrollBehavior) {
AccountSwitchSection( AccountSwitchSection(
@@ -242,17 +240,23 @@ fun RoomSelectView(
// showVerticalSpace only if there is no other accounts // showVerticalSpace only if there is no other accounts
showVerticalSpace = !state.showAccountSwitch, showVerticalSpace = !state.showAccountSwitch,
) )
if (state.hasRoomListError) { when {
RoomListError() state.hasRoomListError -> RoomListError()
} else if (state.resultState is SearchBarResultState.Results) { state.resultState is SearchBarResultState.NoResultsFound -> NoResultsView(searchQuery = searchQuery)
state.resultState is SearchBarResultState.Results -> {
LazyColumn( LazyColumn(
state = lazyListState, state = lazyListState,
contentPadding = lazyColumnContentPadding, contentPadding = lazyColumnContentPadding,
) { ) {
if (state.resultState.results.isNotEmpty()) { if (state.resultState.results.isNotEmpty()) {
item { item {
val headerText = if (searchQuery.isBlank()) {
stringResource(CommonStrings.common_header_rooms)
} else {
stringResource(CommonStrings.common_results_for, searchQuery)
}
ListSectionHeader( ListSectionHeader(
title = stringResource(CommonStrings.common_header_rooms), title = headerText,
hasDivider = false, 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 @Composable
private fun RoomListError() { private fun RoomListError() {
IconTitleSubtitleMolecule( IconTitleSubtitleMolecule(
@@ -63,7 +63,6 @@ class RoomSelectPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.selectedRooms).isEmpty() assertThat(initialState.selectedRooms).isEmpty()
assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java) assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.maxNumberOfRooms).isEqualTo(10) assertThat(initialState.maxNumberOfRooms).isEqualTo(10)
assertThat(initialState.canSelectMoreRooms).isTrue() assertThat(initialState.canSelectMoreRooms).isTrue()
assertThat(initialState.selectedAccount).isEqualTo(MatrixUser(A_SESSION_ID)) 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 @Test
fun `present - forward mode with several accounts does not show the account switch`() = runTest { fun `present - forward mode with several accounts does not show the account switch`() = runTest {
val presenter = createRoomSelectPresenter( 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 @Test
fun `present - update query`() = runTest { fun `present - update query`() = runTest {
val roomSummary = aRoomSummary() 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 // 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 val result = (awaitItem().resultState as SearchBarResultState.Results).results
assertThat(result).isEqualTo(listOf(expectedRoomInfo)) assertThat(result).isEqualTo(listOf(expectedRoomInfo))
initialState.eventSink(RoomSelectEvent.ToggleSearchActive)
skipItems(1)
initialState.searchQuery.setTextAndPlaceCursorAtEnd("string not contained") initialState.searchQuery.setTextAndPlaceCursorAtEnd("string not contained")
assertThat( assertThat(
roomList.currentFilter.value roomList.currentFilter.value
@@ -13,8 +13,12 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick 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 androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomId 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.clickOn
import io.element.android.tests.testutils.robolectric.RobolectricTest import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import org.junit.Test import org.junit.Test
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@@ -123,6 +128,44 @@ class RoomSelectViewTest : RobolectricTest() {
onNodeWithText(activity!!.getString(CommonStrings.common_switch_account)).assertDoesNotExist() 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 @Test
fun `an error is displayed instead of the rooms if they cannot be loaded`() = runAndroidComposeUiTest<ComponentActivity> { fun `an error is displayed instead of the rooms if they cannot be loaded`() = runAndroidComposeUiTest<ComponentActivity> {
setRoomSelectView( 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_mute">"Mute"</string>
<string name="common_name">"Name"</string> <string name="common_name">"Name"</string>
<string name="common_name_and_id">"%1$s (%2$s)"</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_results">"No results"</string>
<string name="common_no_room_name">"No room name"</string> <string name="common_no_room_name">"No room name"</string>
<string name="common_no_space_name">"No space 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_replying_to">"Replying to %1$s"</string>
<string name="common_report_a_problem">"Report a problem"</string> <string name="common_report_a_problem">"Report a problem"</string>
<string name="common_report_submitted">"Report submitted"</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_rich_text_editor">"Rich text editor"</string>
<string name="common_role">"Role"</string> <string name="common_role">"Role"</string>
<string name="common_room">"Room"</string> <string name="common_room">"Room"</string>
@@ -66,6 +66,7 @@ class KonsistClassNameTest {
"EditableAvatarViewUriPreviewParam", "EditableAvatarViewUriPreviewParam",
"LoginModeViewErrorPreviewParam", "LoginModeViewErrorPreviewParam",
"OverlapRatioPreviewParam", "OverlapRatioPreviewParam",
"SelectionCountPreviewParam",
"TextFileContentPreviewParam", "TextFileContentPreviewParam",
"HtmlMessageContentPreviewParam", "HtmlMessageContentPreviewParam",
) )
@@ -110,6 +110,7 @@ class KonsistPreviewTest {
"MessageComposerViewVoicePreview", "MessageComposerViewVoicePreview",
"MessagesReactionButtonAddPreview", "MessagesReactionButtonAddPreview",
"MessagesReactionButtonExtraPreview", "MessagesReactionButtonExtraPreview",
"MessagesViewSelectionModePreview",
"MessagesViewWithIdentityChangePreview", "MessagesViewWithIdentityChangePreview",
"PendingMemberRowWithLongNamePreview", "PendingMemberRowWithLongNamePreview",
"PinUnlockViewInAppPreview", "PinUnlockViewInAppPreview",
@@ -133,6 +134,7 @@ class KonsistPreviewTest {
"SasEmojisPreview", "SasEmojisPreview",
"SecureBackupSetupViewChangePreview", "SecureBackupSetupViewChangePreview",
"SelectedUserCannotRemovePreview", "SelectedUserCannotRemovePreview",
"SelectionIndicatorSelectedPreview",
"SpaceMembersViewNoHeroesPreview", "SpaceMembersViewNoHeroesPreview",
"SyncStateViewServerUnreachablePreview", "SyncStateViewServerUnreachablePreview",
"TextComposerAddCaptionPreview", "TextComposerAddCaptionPreview",
@@ -179,6 +181,7 @@ class KonsistPreviewTest {
"TimelineItemVoiceViewScanningContentPreview", "TimelineItemVoiceViewScanningContentPreview",
"TimelineItemVoiceViewUnifiedPreview", "TimelineItemVoiceViewUnifiedPreview",
"TimelineViewMessageShieldPreview", "TimelineViewMessageShieldPreview",
"TimelineViewSelectionModePreview",
"TimelineViewWithReadMarkerBothIndicatorsPreview", "TimelineViewWithReadMarkerBothIndicatorsPreview",
"TimelineViewWithReadMarkerJumpToUnreadIndicatorOnlyPreview", "TimelineViewWithReadMarkerJumpToUnreadIndicatorOnlyPreview",
"UserAvatarColorsPreview", "UserAvatarColorsPreview",

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