mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-05 22:36:34 +00:00
Merge remote-tracking branch 'origin/develop' into feature/bma/multiAccountShareAtRoot
# Conflicts: # libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
name: Sync Enterprise submodule
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ develop ]
|
||||
paths: [ enterprise ]
|
||||
schedule:
|
||||
# At 06:00 UTC every day
|
||||
- cron: '0 6 * * *'
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
sync-enterprise-submodule:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip in forks
|
||||
if: github.repository == 'element-hq/element-x-android'
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Add SSH private keys for submodule repositories
|
||||
uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
|
||||
- name: Check enterprise submodule reference
|
||||
id: check
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
current=$(git ls-tree HEAD enterprise | awk '{print $3}')
|
||||
# Do not init the submodule: the current commit may be on a deleted branch.
|
||||
git init -q "$RUNNER_TEMP/enterprise"
|
||||
git -C "$RUNNER_TEMP/enterprise" fetch -q git@github.com:element-hq/element-android-enterprise.git main
|
||||
main=$(git -C "$RUNNER_TEMP/enterprise" rev-parse FETCH_HEAD)
|
||||
if git -C "$RUNNER_TEMP/enterprise" merge-base --is-ancestor "$current" "$main" 2>/dev/null; then
|
||||
on_main=true
|
||||
else
|
||||
on_main=false
|
||||
fi
|
||||
echo "Current: $current, main: $main, current is on main: $on_main"
|
||||
|
||||
if [ "$current" = "$main" ]; then
|
||||
echo "Submodule is already up to date."
|
||||
echo "update=false" >> "$GITHUB_OUTPUT"
|
||||
elif [ "$EVENT_NAME" = "push" ] && [ "$on_main" = "true" ]; then
|
||||
echo "Submodule targets a commit on main, nothing to do."
|
||||
echo "update=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "update=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "body<<EOF"
|
||||
if [ "$on_main" = "true" ]; then
|
||||
# Do not list the commit messages, the enterprise repository is private.
|
||||
count=$(git -C "$RUNNER_TEMP/enterprise" rev-list --count "$current..$main")
|
||||
echo "Update the \`enterprise\` submodule from \`$current\` to the latest commit of \`main\` (\`$main\`), $count new commit(s)."
|
||||
else
|
||||
echo "The \`enterprise\` submodule targets \`$current\`, which is not on the \`main\` branch."
|
||||
echo
|
||||
echo "Update it to the latest commit of \`main\` (\`$main\`)."
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "$current" != "$main" ]; then
|
||||
# Update the submodule reference without cloning it.
|
||||
git update-index --cacheinfo "160000,$main,enterprise"
|
||||
git -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \
|
||||
commit -q -m "Update enterprise submodule to latest main" -m "Update to $main"
|
||||
fi
|
||||
- name: Create Pull Request to update the enterprise submodule
|
||||
# On schedule and manual runs, also run when there is nothing to update, so that a stale PR gets closed.
|
||||
if: ${{ steps.check.outputs.update == 'true' || github.event_name != 'push' }}
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
|
||||
title: Update enterprise submodule to latest main
|
||||
body: ${{ steps.check.outputs.body }}
|
||||
branch: sync-enterprise-submodule
|
||||
base: develop
|
||||
delete-branch: true
|
||||
labels: PR-Element-Pro
|
||||
@@ -41,7 +41,7 @@ class SendQueues(
|
||||
fun launchIn(coroutineScope: CoroutineScope) {
|
||||
combine(
|
||||
syncService.syncState,
|
||||
matrixClient.sendQueueDisabledFlow(),
|
||||
matrixClient.sendQueueDisabledFlow().onEach { Timber.tag("SendQueues").d("Send queue disabled for room: $it") },
|
||||
) { syncState, _ -> syncState }
|
||||
.debounce(SEND_QUEUES_RETRY_DELAY_MILLIS)
|
||||
.onEach { syncState ->
|
||||
|
||||
+1
-1
@@ -201,7 +201,7 @@ class JoinedRoomLoadedFlowNode(
|
||||
} else {
|
||||
(graph as TimelineBindings).timelineProvider
|
||||
}
|
||||
val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider)
|
||||
val params = ForwardEntryPoint.Params(listOf(navTarget.eventId), timelineProvider)
|
||||
val callback = object : ForwardEntryPoint.Callback {
|
||||
override fun onDone(roomIds: List<RoomId>) {
|
||||
backstack.pop()
|
||||
|
||||
+1
-1
Submodule enterprise updated: 819746a734...5c06c0f1e4
+7
-2
@@ -16,6 +16,7 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -92,6 +93,9 @@ class ConfigureRoomPresenter(
|
||||
val isKnockFeatureEnabled by remember {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
|
||||
}.collectAsState(initial = false)
|
||||
val arePublicRoomsDisabled by produceState(false) {
|
||||
value = sessionEnterpriseService.arePublicRoomsDisabledByHomeserver()
|
||||
}
|
||||
val roomAddressValidity = remember {
|
||||
mutableStateOf<RoomAddressValidity>(RoomAddressValidity.Unknown)
|
||||
}
|
||||
@@ -145,12 +149,13 @@ class ConfigureRoomPresenter(
|
||||
// 1. If we are creating a space.
|
||||
// 2. If it has a parent space.
|
||||
// 3. If knocking is enabled.
|
||||
// 4. If the homeserver forbids public rooms.
|
||||
val parentSpace = createRoomConfig.parentSpace
|
||||
val availableJoinRules = remember(parentSpace, isSpace, isKnockFeatureEnabled) {
|
||||
val availableJoinRules = remember(parentSpace, isSpace, isKnockFeatureEnabled, arePublicRoomsDisabled) {
|
||||
when {
|
||||
isSpace && parentSpace != null -> TODO("Adding a space to a parent space is not supported yet! How did you get here?")
|
||||
parentSpace == null || parentSpace.joinRule == JoinRule.Public -> listOfNotNull(
|
||||
JoinRuleItem.PublicVisibility.Public,
|
||||
JoinRuleItem.PublicVisibility.Public.takeIf { !arePublicRoomsDisabled },
|
||||
JoinRuleItem.PublicVisibility.AskToJoin.takeIf { !isSpace && isKnockFeatureEnabled },
|
||||
JoinRuleItem.PrivateVisibility.Private,
|
||||
).toImmutableList()
|
||||
|
||||
+41
-2
@@ -57,6 +57,7 @@ import io.element.android.libraries.previewutils.room.aSpaceRoom
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.matching
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
@@ -522,7 +523,10 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
|
||||
|
||||
@Test
|
||||
fun `present - creating a private room when encryption is disabled by the HS creates it unencrypted`() = runTest {
|
||||
val sessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { true })
|
||||
val sessionEnterpriseService = FakeSessionEnterpriseService(
|
||||
isEncryptionDisabledResult = { true },
|
||||
arePublicRoomsDisabledResult = { false },
|
||||
)
|
||||
val createRoomLambda = lambdaRecorder<CreateRoomParameters, Result<RoomId>> { Result.success(A_ROOM_ID) }
|
||||
val matrixClient = createMatrixClient().apply {
|
||||
createRoomResult = createRoomLambda
|
||||
@@ -544,6 +548,38 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when public rooms are disabled by the HS, the public join rule is not available`() = runTest {
|
||||
val presenter = createConfigureRoomPresenter(
|
||||
sessionEnterpriseService = FakeSessionEnterpriseService(
|
||||
isEncryptionDisabledResult = { false },
|
||||
arePublicRoomsDisabledResult = { true },
|
||||
),
|
||||
)
|
||||
presenter.test {
|
||||
val expectedJoinRules = listOf(JoinRuleItem.PublicVisibility.AskToJoin, JoinRuleItem.PrivateVisibility.Private)
|
||||
val state = consumeItemsUntilPredicate { it.availableJoinRules == expectedJoinRules }.last()
|
||||
assertThat(state.availableJoinRules).containsExactlyElementsIn(expectedJoinRules).inOrder()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when public rooms are disabled by the HS, only the private join rule is available for a space`() = runTest {
|
||||
val presenter = createConfigureRoomPresenter(
|
||||
isSpace = true,
|
||||
sessionEnterpriseService = FakeSessionEnterpriseService(
|
||||
isEncryptionDisabledResult = { false },
|
||||
arePublicRoomsDisabledResult = { true },
|
||||
),
|
||||
)
|
||||
presenter.test {
|
||||
val state = consumeItemsUntilPredicate { it.availableJoinRules == listOf(JoinRuleItem.PrivateVisibility.Private) }.last()
|
||||
assertThat(state.config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun TurbineTestContext<ConfigureRoomState>.initialState(): ConfigureRoomState {
|
||||
skipItems(1)
|
||||
return awaitItem()
|
||||
@@ -579,7 +615,10 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
|
||||
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||
isKnockFeatureEnabled: Boolean = true,
|
||||
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
||||
sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { false }),
|
||||
sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService(
|
||||
isEncryptionDisabledResult = { false },
|
||||
arePublicRoomsDisabledResult = { false },
|
||||
),
|
||||
) = ConfigureRoomPresenter(
|
||||
isSpace = isSpace,
|
||||
initialParentSpaceId = initialParenSpaceId,
|
||||
|
||||
+6
@@ -29,4 +29,10 @@ interface SessionEnterpriseService {
|
||||
|
||||
/** Whether the homeserver forbids creating encrypted rooms, in which case the app must not offer the option. */
|
||||
suspend fun isEncryptionDisabledByHomeserver(): Boolean
|
||||
|
||||
/**
|
||||
* Whether the homeserver forbids public rooms and spaces, in which case the app must not offer to create one,
|
||||
* nor to make an existing room or space public.
|
||||
*/
|
||||
suspend fun arePublicRoomsDisabledByHomeserver(): Boolean
|
||||
}
|
||||
|
||||
+1
@@ -18,4 +18,5 @@ class DefaultSessionEnterpriseService : SessionEnterpriseService {
|
||||
override suspend fun tweakMasUrl(url: String): String = url
|
||||
override suspend fun isElementCallAvailable(): Boolean = true
|
||||
override suspend fun isEncryptionDisabledByHomeserver(): Boolean = false
|
||||
override suspend fun arePublicRoomsDisabledByHomeserver(): Boolean = false
|
||||
}
|
||||
|
||||
+5
@@ -16,6 +16,7 @@ class FakeSessionEnterpriseService(
|
||||
private val isElementCallAvailableResult: () -> Boolean = { lambdaError() },
|
||||
private val tweakMasUrlResult: (String) -> String = { lambdaError() },
|
||||
private val isEncryptionDisabledResult: () -> Boolean = { lambdaError() },
|
||||
private val arePublicRoomsDisabledResult: () -> Boolean = { lambdaError() },
|
||||
) : SessionEnterpriseService {
|
||||
override suspend fun init() {
|
||||
}
|
||||
@@ -31,4 +32,8 @@ class FakeSessionEnterpriseService(
|
||||
override suspend fun isEncryptionDisabledByHomeserver(): Boolean {
|
||||
return isEncryptionDisabledResult()
|
||||
}
|
||||
|
||||
override suspend fun arePublicRoomsDisabledByHomeserver(): Boolean {
|
||||
return arePublicRoomsDisabledResult()
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ interface ForwardEntryPoint : FeatureEntryPoint {
|
||||
}
|
||||
|
||||
data class Params(
|
||||
val eventId: EventId,
|
||||
val eventIds: List<EventId>,
|
||||
val timelineProvider: TimelineProvider,
|
||||
) : NodeInputs
|
||||
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ class DefaultForwardEntryPoint : ForwardEntryPoint {
|
||||
buildContext = buildContext,
|
||||
plugins = listOf(
|
||||
ForwardMessagesNode.Inputs(
|
||||
eventId = params.eventId,
|
||||
eventIds = params.eventIds,
|
||||
timelineProvider = params.timelineProvider,
|
||||
),
|
||||
callback,
|
||||
|
||||
+2
-2
@@ -54,13 +54,13 @@ class ForwardMessagesNode(
|
||||
object NavTarget : Parcelable
|
||||
|
||||
data class Inputs(
|
||||
val eventId: EventId,
|
||||
val eventIds: List<EventId>,
|
||||
val timelineProvider: TimelineProvider,
|
||||
) : NodeInputs
|
||||
|
||||
private val inputs = inputs<Inputs>()
|
||||
private val callback: ForwardEntryPoint.Callback = callback()
|
||||
private val presenter = presenterFactory.create(inputs.eventId.value, inputs.timelineProvider)
|
||||
private val presenter = presenterFactory.create(inputs.eventIds, inputs.timelineProvider)
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
val callback = object : RoomSelectEntryPoint.Callback {
|
||||
|
||||
+7
-9
@@ -28,22 +28,20 @@ import timber.log.Timber
|
||||
|
||||
@AssistedInject
|
||||
class ForwardMessagesPresenter(
|
||||
@Assisted eventId: String,
|
||||
@Assisted private val eventIds: List<EventId>,
|
||||
@Assisted private val timelineProvider: TimelineProvider,
|
||||
@SessionCoroutineScope
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
) : Presenter<ForwardMessagesState> {
|
||||
private val eventId: EventId = EventId(eventId)
|
||||
|
||||
@AssistedFactory
|
||||
fun interface Factory {
|
||||
fun create(eventId: String, timelineProvider: TimelineProvider): ForwardMessagesPresenter
|
||||
fun create(eventIds: List<EventId>, timelineProvider: TimelineProvider): ForwardMessagesPresenter
|
||||
}
|
||||
|
||||
private val forwardingActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized)
|
||||
|
||||
fun onRoomSelected(roomIds: List<RoomId>) {
|
||||
sessionCoroutineScope.forwardEvent(eventId, roomIds)
|
||||
sessionCoroutineScope.forwardEvents(eventIds, roomIds)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -60,14 +58,14 @@ class ForwardMessagesPresenter(
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.forwardEvent(
|
||||
eventId: EventId,
|
||||
private fun CoroutineScope.forwardEvents(
|
||||
eventIds: List<EventId>,
|
||||
roomIds: List<RoomId>,
|
||||
) = launch {
|
||||
suspend {
|
||||
timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds)
|
||||
timelineProvider.getActiveTimeline().forwardEvents(eventIds, roomIds)
|
||||
.onFailure {
|
||||
Timber.e(it, "Error while forwarding event")
|
||||
Timber.e(it, "Error while forwarding events")
|
||||
}
|
||||
.getOrThrow()
|
||||
roomIds
|
||||
|
||||
+2
-2
@@ -47,7 +47,7 @@ class DefaultForwardEntryPointTest {
|
||||
override fun onDone(roomIds: List<RoomId>) = lambdaError()
|
||||
}
|
||||
val params = ForwardEntryPoint.Params(
|
||||
eventId = AN_EVENT_ID,
|
||||
eventIds = listOf(AN_EVENT_ID),
|
||||
timelineProvider = FakeTimelineProvider(),
|
||||
)
|
||||
val result = entryPoint.createNode(
|
||||
@@ -59,7 +59,7 @@ class DefaultForwardEntryPointTest {
|
||||
assertThat(result).isInstanceOf(ForwardMessagesNode::class.java)
|
||||
assertThat(result.plugins).contains(
|
||||
ForwardMessagesNode.Inputs(
|
||||
eventId = params.eventId,
|
||||
eventIds = params.eventIds,
|
||||
timelineProvider = params.timelineProvider,
|
||||
)
|
||||
)
|
||||
|
||||
+8
-8
@@ -44,11 +44,11 @@ class ForwardMessagesPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - forward successful`() = runTest {
|
||||
val forwardEventLambda = lambdaRecorder { _: EventId, _: List<RoomId> ->
|
||||
val forwardEventsLambda = lambdaRecorder { _: List<EventId>, _: List<RoomId> ->
|
||||
Result.success(Unit)
|
||||
}
|
||||
val timeline = FakeTimeline().apply {
|
||||
this.forwardEventLambda = forwardEventLambda
|
||||
this.forwardEventsLambda = forwardEventsLambda
|
||||
}
|
||||
val room = FakeJoinedRoom(liveTimeline = timeline)
|
||||
val presenter = createForwardMessagesPresenter(fakeRoom = room)
|
||||
@@ -62,17 +62,17 @@ class ForwardMessagesPresenterTest {
|
||||
assertThat(forwardingState.forwardAction.isLoading()).isTrue()
|
||||
val successfulForwardState = awaitItem()
|
||||
assertThat(successfulForwardState.forwardAction).isEqualTo(AsyncAction.Success(listOf(summary.roomId)))
|
||||
forwardEventLambda.assertions().isCalledOnce()
|
||||
forwardEventsLambda.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - select a room and forward failed, then clear`() = runTest {
|
||||
val forwardEventLambda = lambdaRecorder { _: EventId, _: List<RoomId> ->
|
||||
val forwardEventsLambda = lambdaRecorder { _: List<EventId>, _: List<RoomId> ->
|
||||
Result.failure<Unit>(IllegalStateException("error"))
|
||||
}
|
||||
val timeline = FakeTimeline().apply {
|
||||
this.forwardEventLambda = forwardEventLambda
|
||||
this.forwardEventsLambda = forwardEventsLambda
|
||||
}
|
||||
val room = FakeJoinedRoom(liveTimeline = timeline)
|
||||
val presenter = createForwardMessagesPresenter(fakeRoom = room)
|
||||
@@ -88,16 +88,16 @@ class ForwardMessagesPresenterTest {
|
||||
// Then clear error
|
||||
failedForwardState.eventSink(ForwardMessagesEvent.ClearError)
|
||||
assertThat(awaitItem().forwardAction.isUninitialized()).isTrue()
|
||||
forwardEventLambda.assertions().isCalledOnce()
|
||||
forwardEventsLambda.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun TestScope.createForwardMessagesPresenter(
|
||||
eventId: EventId = AN_EVENT_ID,
|
||||
eventIds: List<EventId> = listOf(AN_EVENT_ID),
|
||||
fakeRoom: FakeJoinedRoom = FakeJoinedRoom(),
|
||||
) = ForwardMessagesPresenter(
|
||||
eventId = eventId.value,
|
||||
eventIds = eventIds,
|
||||
timelineProvider = LiveTimelineProvider(fakeRoom),
|
||||
sessionCoroutineScope = this,
|
||||
)
|
||||
|
||||
+4
-9
@@ -60,7 +60,6 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.roomListRoomMessage
|
||||
import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate
|
||||
import io.element.android.libraries.designsystem.theme.roomListRoomName
|
||||
import io.element.android.libraries.designsystem.theme.unreadIndicator
|
||||
import io.element.android.libraries.matrix.api.notification.CallIntent
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
@@ -244,7 +243,7 @@ private fun NameAndTimestampRow(
|
||||
text = timestamp ?: "",
|
||||
style = ElementTheme.typography.fontBodySmMedium,
|
||||
color = if (isHighlighted) {
|
||||
ElementTheme.colors.unreadIndicator
|
||||
ElementTheme.colors.textActionAccent
|
||||
} else {
|
||||
ElementTheme.colors.roomListRoomMessageDate
|
||||
},
|
||||
@@ -347,13 +346,12 @@ private fun MessagePreviewAndIndicatorRow(
|
||||
// Call and unread
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(16.dp)
|
||||
// Used to force this line to be read aloud earlier than the latest event when using Talkback
|
||||
.zIndex(-1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val tint = if (room.isHighlighted) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary
|
||||
val tint = if (room.isHighlighted) ElementTheme.colors.textActionAccent else ElementTheme.colors.iconQuaternary
|
||||
if (room.hasRoomCall) {
|
||||
OnGoingCallIcon(
|
||||
color = tint,
|
||||
@@ -422,10 +420,7 @@ private fun InviteNameAndIndicatorRow(
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (!isInviteSeen) {
|
||||
UnreadIndicatorAtom(
|
||||
color = ElementTheme.colors.unreadIndicator,
|
||||
count = 0,
|
||||
)
|
||||
UnreadIndicatorAtom(count = 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -459,7 +454,7 @@ private fun MentionIndicatorAtom() {
|
||||
modifier = Modifier.size(16.dp),
|
||||
contentDescription = stringResource(CommonStrings.a11y_notifications_new_mentions),
|
||||
imageVector = CompoundIcons.Mention(),
|
||||
tint = ElementTheme.colors.unreadIndicator,
|
||||
tint = ElementTheme.colors.iconAccentPrimary,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -23,6 +23,7 @@ sealed interface MessagesEvent {
|
||||
data object StopLiveLocationShare : MessagesEvent
|
||||
data object ShowLiveLocationShare : MessagesEvent
|
||||
data object MarkAsFullyReadAndExit : MessagesEvent
|
||||
data object ConfirmSelectionAction : MessagesEvent
|
||||
}
|
||||
|
||||
enum class InviteDialogAction {
|
||||
|
||||
+7
-7
@@ -181,7 +181,7 @@ class MessagesFlowNode(
|
||||
|
||||
@Parcelize
|
||||
data class ForwardEvent(
|
||||
val eventId: EventId,
|
||||
val eventIds: List<EventId>,
|
||||
@IgnoredOnParcel
|
||||
val timelineProvider: TimelineProvider? = null,
|
||||
) : NavTarget
|
||||
@@ -306,8 +306,8 @@ class MessagesFlowNode(
|
||||
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
|
||||
}
|
||||
|
||||
override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) {
|
||||
backstack.push(NavTarget.ForwardEvent(eventId, timelineProvider))
|
||||
override fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
|
||||
backstack.push(NavTarget.ForwardEvent(eventIds, timelineProvider))
|
||||
}
|
||||
|
||||
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
|
||||
@@ -464,7 +464,7 @@ class MessagesFlowNode(
|
||||
is NavTarget.ForwardEvent -> {
|
||||
// If no timeline provider is received, assume the live timeline should be used
|
||||
val timelineProvider = navTarget.timelineProvider ?: timelineController
|
||||
val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider)
|
||||
val params = ForwardEntryPoint.Params(navTarget.eventIds, timelineProvider)
|
||||
val callback = object : ForwardEntryPoint.Callback {
|
||||
override fun onDone(roomIds: List<RoomId>) {
|
||||
backstack.pop()
|
||||
@@ -551,7 +551,7 @@ class MessagesFlowNode(
|
||||
}
|
||||
|
||||
override fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider) {
|
||||
backstack.push(NavTarget.ForwardEvent(eventId = eventId, timelineProvider = timelineProvider))
|
||||
backstack.push(NavTarget.ForwardEvent(eventIds = listOf(eventId), timelineProvider = timelineProvider))
|
||||
}
|
||||
|
||||
override fun navigateToThread(threadRootId: ThreadId) {
|
||||
@@ -617,8 +617,8 @@ class MessagesFlowNode(
|
||||
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
|
||||
}
|
||||
|
||||
override fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider) {
|
||||
backstack.push(NavTarget.ForwardEvent(eventId, timelineProvider))
|
||||
override fun handleForwardEventClick(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
|
||||
backstack.push(NavTarget.ForwardEvent(eventIds, timelineProvider))
|
||||
}
|
||||
|
||||
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
interface MessagesNavigator {
|
||||
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||
fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider)
|
||||
fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider)
|
||||
fun navigateToReportMessage(eventId: EventId, senderId: UserId)
|
||||
fun navigateToEditPoll(eventId: EventId)
|
||||
fun navigateToPreviewAttachments(attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?)
|
||||
|
||||
+3
-3
@@ -138,7 +138,7 @@ class MessagesNode(
|
||||
fun navigateToRoomMemberDetails(userId: UserId)
|
||||
fun handlePermalinkClick(data: PermalinkData)
|
||||
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||
fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider)
|
||||
fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider)
|
||||
fun navigateToReportMessage(eventId: EventId, senderId: UserId)
|
||||
fun navigateToSendLocation()
|
||||
fun navigateToCreatePoll()
|
||||
@@ -221,8 +221,8 @@ class MessagesNode(
|
||||
callback.navigateToEventDebugInfo(eventId, debugInfo)
|
||||
}
|
||||
|
||||
override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) {
|
||||
callback.forwardEvent(eventId, timelineProvider)
|
||||
override fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
|
||||
callback.forwardEvents(eventIds, timelineProvider)
|
||||
}
|
||||
|
||||
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
|
||||
|
||||
+33
-12
@@ -38,6 +38,8 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
||||
import io.element.android.features.messages.impl.timeline.MarkAsFullyRead
|
||||
import io.element.android.features.messages.impl.timeline.SelectionAction
|
||||
import io.element.android.features.messages.impl.timeline.SelectionState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvent
|
||||
import io.element.android.features.messages.impl.timeline.TimelineState
|
||||
@@ -52,6 +54,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
|
||||
import io.element.android.features.messages.impl.timeline.model.event.htmlCaptionOrNull
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.selectedEventIdsInTimelineOrder
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter
|
||||
import io.element.android.features.roomcall.api.RoomCallState
|
||||
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent
|
||||
@@ -83,6 +86,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState
|
||||
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.SendTarget
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.map
|
||||
@@ -257,6 +261,17 @@ class MessagesPresenter(
|
||||
redactEventAction = redactEventAction,
|
||||
)
|
||||
}
|
||||
is MessagesEvent.ConfirmSelectionAction -> {
|
||||
val selection = timelineState.selectionState as? SelectionState.Active
|
||||
if (selection != null) {
|
||||
when (selection.action) {
|
||||
SelectionAction.Forward -> {
|
||||
navigator.forwardEvents(timelineState.selectedEventIdsInTimelineOrder(), timelineController)
|
||||
}
|
||||
}
|
||||
timelineState.eventSink(TimelineEvent.ExitSelectionMode)
|
||||
}
|
||||
}
|
||||
is MessagesEvent.ConfirmRedact -> {
|
||||
val confirming = redactEventAction.value as? MessagesState.ConfirmingRedaction
|
||||
redactEventAction.value = AsyncAction.Uninitialized
|
||||
@@ -413,21 +428,27 @@ class MessagesPresenter(
|
||||
}
|
||||
}
|
||||
TimelineItemAction.ViewSource -> handleShowDebugInfoAction(targetEvent)
|
||||
TimelineItemAction.Forward -> handleForwardAction(targetEvent)
|
||||
TimelineItemAction.Forward -> {
|
||||
val eventId = targetEvent.eventId ?: return@launch
|
||||
if (featureFlagService.isFeatureEnabled(FeatureFlags.MessageMultiSelect)) {
|
||||
timelineState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, eventId))
|
||||
} else {
|
||||
navigator.forwardEvents(listOf(eventId), timelineController)
|
||||
}
|
||||
}
|
||||
TimelineItemAction.ReportContent -> handleReportAction(targetEvent)
|
||||
TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent, timelineState)
|
||||
TimelineItemAction.Pin -> handlePinAction(targetEvent)
|
||||
TimelineItemAction.Unpin -> handleUnpinAction(targetEvent)
|
||||
TimelineItemAction.ViewInTimeline -> Unit
|
||||
TimelineItemAction.RetrySending -> handleRetrySending(targetEvent)
|
||||
TimelineItemAction.AbortSending -> handleActionAbortSend(targetEvent)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleRetrySending(targetEvent: TimelineItem.Event) {
|
||||
val sendHandle = targetEvent.sendhandle ?: return Unit.also {
|
||||
Timber.w("No send handle for event ${targetEvent.eventOrTransactionId}")
|
||||
}
|
||||
sendHandle.retry()
|
||||
val (sendTarget, _) = targetEvent.pendingSend() ?: return
|
||||
timelineController.retrySend(targetEvent.eventOrTransactionId, sendTarget)
|
||||
.onSuccess {
|
||||
Timber.d("Succeed to add the message back to the send queue")
|
||||
}
|
||||
@@ -517,13 +538,18 @@ class MessagesPresenter(
|
||||
private suspend fun handleActionRedact(event: TimelineItem.Event, redactEventAction: MutableState<AsyncAction<Unit>>) {
|
||||
val eventId = event.eventId
|
||||
if (eventId == null) {
|
||||
// The message was never sent, so there is nobody to give a reason to.
|
||||
redact(event.eventOrTransactionId, reason = null)
|
||||
// The message (or its edit/redaction) was never sent, just remove it from the send queue.
|
||||
handleActionAbortSend(event)
|
||||
} else {
|
||||
redactEventAction.value = MessagesState.ConfirmingRedaction(eventId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleActionAbortSend(event: TimelineItem.Event) {
|
||||
val sendTarget = event.pendingSend()?.first ?: SendTarget.Event
|
||||
timelineController.abortSend(event.eventOrTransactionId, sendTarget).onFailure { Timber.e(it) }
|
||||
}
|
||||
|
||||
private suspend fun redact(eventOrTransactionId: EventOrTransactionId, reason: String?) {
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
redactEvent(eventOrTransactionId = eventOrTransactionId, reason = reason)
|
||||
@@ -612,11 +638,6 @@ class MessagesPresenter(
|
||||
navigator.navigateToEventDebugInfo(event.eventId, event.debugInfo)
|
||||
}
|
||||
|
||||
private fun handleForwardAction(event: TimelineItem.Event) {
|
||||
if (event.eventId == null) return
|
||||
navigator.forwardEvent(eventId = event.eventId, timelineProvider = timelineController)
|
||||
}
|
||||
|
||||
private fun handleReportAction(event: TimelineItem.Event) {
|
||||
if (event.eventId == null) return
|
||||
navigator.navigateToReportMessage(event.eventId, event.senderId)
|
||||
|
||||
+100
-29
@@ -8,6 +8,7 @@
|
||||
|
||||
package io.element.android.features.messages.impl
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
@@ -77,24 +78,31 @@ import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBan
|
||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerView
|
||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerViewDefaults
|
||||
import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS
|
||||
import io.element.android.features.messages.impl.timeline.SelectionAction
|
||||
import io.element.android.features.messages.impl.timeline.SelectionState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvent
|
||||
import io.element.android.features.messages.impl.timeline.TimelineView
|
||||
import io.element.android.features.messages.impl.timeline.aGroupedEvents
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemList
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineState
|
||||
import io.element.android.features.messages.impl.timeline.canApplyTo
|
||||
import io.element.android.features.messages.impl.timeline.components.CallMenuItem
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvent
|
||||
import io.element.android.features.messages.impl.timeline.isSelectionModeActive
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.selectedCount
|
||||
import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogView
|
||||
import io.element.android.features.messages.impl.topbars.MessagesViewTopBar
|
||||
import io.element.android.features.messages.impl.topbars.SelectionModeTopBar
|
||||
import io.element.android.features.messages.impl.topbars.ThreadTopBar
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
|
||||
@@ -134,6 +142,7 @@ import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@@ -178,6 +187,18 @@ fun MessagesView(
|
||||
block()
|
||||
}
|
||||
|
||||
// Entering selection mode collapses the composer, so dismiss the keyboard too. hideKeyboard
|
||||
// triggers the IME hide animation, keeping it in sync with the composer collapse.
|
||||
LaunchedEffect(state.timelineState.isSelectionModeActive) {
|
||||
if (state.timelineState.isSelectionModeActive) {
|
||||
localView.hideKeyboard()
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = state.timelineState.isSelectionModeActive) {
|
||||
state.timelineState.eventSink(TimelineEvent.ExitSelectionMode)
|
||||
}
|
||||
|
||||
fun onContentClick(event: TimelineItem.Event) {
|
||||
Timber.v("onMessageClick= ${event.id}")
|
||||
val eventId = event.eventId
|
||||
@@ -234,34 +255,50 @@ fun MessagesView(
|
||||
Scaffold(
|
||||
contentWindowInsets = scaffoldScrollableContentInsets,
|
||||
topBar = {
|
||||
if (state.timelineState.timelineMode is Timeline.Mode.Thread) {
|
||||
ThreadTopBar(
|
||||
roomName = state.roomName,
|
||||
roomAvatarData = state.roomAvatar,
|
||||
heroes = state.heroes,
|
||||
isTombstoned = state.isTombstoned,
|
||||
onBackClick = onBackClick,
|
||||
)
|
||||
} else {
|
||||
MessagesViewTopBar(
|
||||
roomName = state.roomName,
|
||||
roomAvatar = state.roomAvatar,
|
||||
isTombstoned = state.isTombstoned,
|
||||
heroes = state.heroes,
|
||||
dmUserIdentityState = state.dmUserVerificationState,
|
||||
sharedHistoryIcon = state.topBarSharedHistoryIcon,
|
||||
dmUserStatus = state.dmUserStatus,
|
||||
onBackClick = { hidingKeyboard { onBackClick() } },
|
||||
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
|
||||
menuActions = {
|
||||
MessagesMenuActions(
|
||||
displayThreads = state.timelineState.timelineMode !is Timeline.Mode.Thread && state.threads.hasThreads,
|
||||
roomCallState = state.roomCallState,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
onThreadsListClick = onThreadsListClick
|
||||
)
|
||||
}
|
||||
)
|
||||
when {
|
||||
state.timelineState.isSelectionModeActive -> {
|
||||
val selection = state.timelineState.selectionState as SelectionState.Active
|
||||
SelectionModeTopBar(
|
||||
selectedCount = state.timelineState.selectedCount,
|
||||
action = selection.action,
|
||||
onCancelClick = {
|
||||
state.timelineState.eventSink(TimelineEvent.ExitSelectionMode)
|
||||
},
|
||||
onConfirmClick = {
|
||||
state.eventSink(MessagesEvent.ConfirmSelectionAction)
|
||||
},
|
||||
)
|
||||
}
|
||||
state.timelineState.timelineMode is Timeline.Mode.Thread -> {
|
||||
ThreadTopBar(
|
||||
roomName = state.roomName,
|
||||
roomAvatarData = state.roomAvatar,
|
||||
heroes = state.heroes,
|
||||
isTombstoned = state.isTombstoned,
|
||||
onBackClick = onBackClick,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
MessagesViewTopBar(
|
||||
roomName = state.roomName,
|
||||
roomAvatar = state.roomAvatar,
|
||||
isTombstoned = state.isTombstoned,
|
||||
heroes = state.heroes,
|
||||
dmUserIdentityState = state.dmUserVerificationState,
|
||||
sharedHistoryIcon = state.topBarSharedHistoryIcon,
|
||||
dmUserStatus = state.dmUserStatus,
|
||||
onBackClick = { hidingKeyboard { onBackClick() } },
|
||||
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
|
||||
menuActions = {
|
||||
MessagesMenuActions(
|
||||
displayThreads = state.timelineState.timelineMode !is Timeline.Mode.Thread && state.threads.hasThreads,
|
||||
roomCallState = state.roomCallState,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
onThreadsListClick = onThreadsListClick
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
content = { padding ->
|
||||
@@ -449,7 +486,7 @@ fun MessagesView(
|
||||
onRemoveMessage = { event ->
|
||||
state.eventSink(
|
||||
MessagesEvent.HandleAction(
|
||||
action = TimelineItemAction.Redact,
|
||||
action = TimelineItemAction.AbortSending,
|
||||
event = event,
|
||||
)
|
||||
)
|
||||
@@ -728,6 +765,40 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStatePreviewParam::cl
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MessagesViewSelectionModePreview() = ElementPreview {
|
||||
val timelineItems = aTimelineItemList(aTimelineItemTextContent())
|
||||
val selectedEventIds = timelineItems
|
||||
.filterIsInstance<TimelineItem.Event>()
|
||||
.filter { SelectionAction.Forward.canApplyTo(it) }
|
||||
.take(2)
|
||||
.mapNotNull { it.eventId }
|
||||
.toImmutableSet()
|
||||
MessagesView(
|
||||
state = aMessagesState(
|
||||
timelineState = aTimelineState(
|
||||
timelineItems = timelineItems,
|
||||
selectionState = SelectionState.Active(SelectionAction.Forward, selectedEventIds),
|
||||
),
|
||||
),
|
||||
onBackClick = {},
|
||||
onRoomDetailsClick = {},
|
||||
onEventContentClick = { _, _ -> false },
|
||||
onGalleryEventItemClick = { _, _, _ -> false },
|
||||
onUserDataClick = {},
|
||||
onLinkClick = { _, _ -> },
|
||||
onSendLocationClick = {},
|
||||
onCreatePollClick = {},
|
||||
onJoinCallClick = {},
|
||||
onViewAllPinnedMessagesClick = { },
|
||||
forceJumpToBottomVisibility = true,
|
||||
knockRequestsBannerView = {},
|
||||
customReactionBottomSheet = {},
|
||||
onThreadsListClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun MessagesViewA11yPreview() = ElementPreview {
|
||||
|
||||
+2
-1
@@ -37,6 +37,7 @@ enum class TimelineItemAction(
|
||||
Pin(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin),
|
||||
Unpin(CommonStrings.action_unpin, CompoundDrawables.ic_compound_unpin),
|
||||
|
||||
// Note: this action is not used in UI for now
|
||||
// Note: these actions are not used in UI for now
|
||||
RetrySending(CommonStrings.action_retry, CompoundDrawables.ic_compound_send_solid),
|
||||
AbortSending(CommonStrings.action_cancel, CompoundDrawables.ic_compound_send_solid),
|
||||
}
|
||||
|
||||
+2
-1
@@ -32,8 +32,9 @@ class TimelineItemActionComparator : Comparator<TimelineItemAction> {
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.ReportContent,
|
||||
TimelineItemAction.Redact,
|
||||
// Not rendered in the action list, but has to be listed here for exhaustiveness.
|
||||
// Not rendered in the action list, but have to be listed here for exhaustiveness.
|
||||
TimelineItemAction.RetrySending,
|
||||
TimelineItemAction.AbortSending,
|
||||
)
|
||||
|
||||
override fun compare(o1: TimelineItemAction, o2: TimelineItemAction): Int {
|
||||
|
||||
+2
@@ -38,6 +38,7 @@ import io.element.android.features.messages.impl.timeline.components.event.Timel
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemSelectionData
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.poll.api.pollcontent.PollTitleView
|
||||
@@ -289,6 +290,7 @@ private fun PinnedMessagesListLoaded(
|
||||
onContentLayoutChange = onContentLayoutChange
|
||||
)
|
||||
},
|
||||
selectionData = TimelineItemSelectionData(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -143,7 +143,7 @@ class ThreadedMessagesNode(
|
||||
fun navigateToRoomMemberDetails(userId: UserId)
|
||||
fun handlePermalinkClick(data: PermalinkData)
|
||||
fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||
fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider)
|
||||
fun handleForwardEventClick(eventIds: List<EventId>, timelineProvider: TimelineProvider)
|
||||
fun navigateToReportMessage(eventId: EventId, senderId: UserId)
|
||||
fun navigateToSendLocation()
|
||||
fun navigateToCreatePoll()
|
||||
@@ -227,8 +227,8 @@ class ThreadedMessagesNode(
|
||||
callback.navigateToEventDebugInfo(eventId, debugInfo)
|
||||
}
|
||||
|
||||
override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) {
|
||||
callback.handleForwardEventClick(eventId, timelineProvider)
|
||||
override fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
|
||||
callback.handleForwardEventClick(eventIds, timelineProvider)
|
||||
}
|
||||
|
||||
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
|
||||
|
||||
+6
-5
@@ -264,11 +264,12 @@ private fun ThreadListItemRow(
|
||||
)
|
||||
}
|
||||
|
||||
UnreadIndicatorAtom(
|
||||
size = 14.dp,
|
||||
isVisible = hasUnreadNotifications,
|
||||
count = 0,
|
||||
)
|
||||
if (hasUnreadNotifications) {
|
||||
UnreadIndicatorAtom(
|
||||
dotIndicatorSize = 14.dp,
|
||||
count = 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+88
@@ -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
|
||||
}
|
||||
+11
@@ -23,6 +23,8 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
|
||||
import io.element.android.libraries.matrix.api.timeline.item.SendTarget
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -32,6 +34,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
@@ -134,6 +137,14 @@ class TimelineController(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun retrySend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result<Boolean> {
|
||||
return currentTimelineFlow.first().retrySend(eventOrTransactionId, sendTarget)
|
||||
}
|
||||
|
||||
suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result<Boolean> {
|
||||
return currentTimelineFlow.first().abortSend(eventOrTransactionId, sendTarget)
|
||||
}
|
||||
|
||||
private val currentTimelineFlow = combine(liveTimelineFlow, detachedTimelineFlow) { live, detached ->
|
||||
when {
|
||||
detached.isPresent -> detached.get()
|
||||
|
||||
+3
-1
@@ -28,7 +28,9 @@ sealed interface TimelineEvent {
|
||||
data object HideShieldDialog : TimelineEvent
|
||||
|
||||
data object MarkAllAsRead : TimelineEvent
|
||||
|
||||
data class EnterSelectionMode(val action: SelectionAction, val eventId: EventId) : TimelineEvent
|
||||
data class ToggleSelection(val eventId: EventId) : TimelineEvent
|
||||
data object ExitSelectionMode : TimelineEvent
|
||||
data class CopyToClipboard(val text: String) : TimelineEvent
|
||||
|
||||
/**
|
||||
|
||||
+51
-1
@@ -48,6 +48,8 @@ import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.androidutils.toast.ToastHelper
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
@@ -71,13 +73,17 @@ import io.element.android.services.analytics.api.finishLongRunningTransaction
|
||||
import io.element.android.services.analyticsproviders.api.AnalyticsUserData
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -103,6 +109,7 @@ class TimelinePresenter(
|
||||
private val typingNotificationPresenter: Presenter<TypingNotificationState>,
|
||||
private val roomCallStatePresenter: Presenter<RoomCallState>,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val liveLocationShareManager: ActiveLiveLocationShareManager,
|
||||
private val markAsFullyRead: MarkAsFullyRead,
|
||||
@@ -172,6 +179,18 @@ class TimelinePresenter(
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.NewTimelineEventRenderer)
|
||||
}
|
||||
|
||||
val isMultiSelectEnabled by produceState(false) {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageMultiSelect).collect { value = it }
|
||||
}
|
||||
var selectionState by remember { mutableStateOf<SelectionState>(SelectionState.Disabled) }
|
||||
|
||||
// When the feature flag is turned off, clear any active selection.
|
||||
LaunchedEffect(isMultiSelectEnabled) {
|
||||
if (!isMultiSelectEnabled) {
|
||||
selectionState = SelectionState.Disabled
|
||||
}
|
||||
}
|
||||
|
||||
val timelineProtectionState = timelineProtectionPresenter.present()
|
||||
|
||||
fun handleEvent(event: TimelineEvent) {
|
||||
@@ -252,6 +271,26 @@ class TimelinePresenter(
|
||||
is TimelineEvent.JumpToLive -> {
|
||||
timelineController.focusOnLive()
|
||||
}
|
||||
is TimelineEvent.EnterSelectionMode -> {
|
||||
if (!isMultiSelectEnabled) return
|
||||
selectionState = SelectionState.Active(event.action, persistentSetOf(event.eventId))
|
||||
}
|
||||
is TimelineEvent.ToggleSelection -> {
|
||||
val current = selectionState as? SelectionState.Active ?: return
|
||||
val selected = current.selectedEventIds
|
||||
selectionState = when {
|
||||
// Deselecting the last event does not exit selection mode: it is only left explicitly, as on iOS.
|
||||
event.eventId in selected -> current.deselect(setOf(event.eventId))
|
||||
selected.size >= MAX_SELECTION_COUNT -> {
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.screen_room_maximum_messages_selected))
|
||||
current
|
||||
}
|
||||
else -> current.copy(selectedEventIds = (selected + event.eventId).toImmutableSet())
|
||||
}
|
||||
}
|
||||
is TimelineEvent.ExitSelectionMode -> {
|
||||
selectionState = SelectionState.Disabled
|
||||
}
|
||||
TimelineEvent.HideShieldDialog -> messageShieldDialogData.value = null
|
||||
TimelineEvent.MarkAllAsRead -> sessionCoroutineScope.launch {
|
||||
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
|
||||
@@ -263,7 +302,9 @@ class TimelinePresenter(
|
||||
}
|
||||
is TimelineEvent.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData
|
||||
is TimelineEvent.ShowSendFailureDialog -> {
|
||||
val sendStateFailure = event.event.localSendState as? LocalEventSendState.Failed ?: return
|
||||
val (_, sendStateFailure) = event.event.pendingSend() ?: return
|
||||
if (sendStateFailure !is LocalEventSendState.Failed) return
|
||||
|
||||
when (sendStateFailure) {
|
||||
is LocalEventSendState.Failed.VerifiedUser -> {
|
||||
// defer to the resolveVerifiedUserSendFailureState to handle this case,
|
||||
@@ -341,6 +382,14 @@ class TimelinePresenter(
|
||||
}
|
||||
.launchIn(this)
|
||||
|
||||
// Drop the selected events which the action cannot be applied to anymore, for instance because the message has been deleted.
|
||||
timelineItemsFactory.timelineItems
|
||||
.map { items -> selectionState.eventIdsToDeselect(items) }
|
||||
.filter { eventIdsToDeselect -> eventIdsToDeselect.isNotEmpty() }
|
||||
.flowOn(dispatchers.computation)
|
||||
.onEach { eventIdsToDeselect -> selectionState = selectionState.deselect(eventIdsToDeselect) }
|
||||
.launchIn(this)
|
||||
|
||||
combine(
|
||||
timelineController.timelineItems(),
|
||||
room.membersStateFlow,
|
||||
@@ -477,6 +526,7 @@ class TimelinePresenter(
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
displayJumpToUnread = displayJumpToUnread,
|
||||
jumpToUnread = jumpToUnread.value,
|
||||
selectionState = selectionState,
|
||||
useNewTimelineEventRenderer = useNewTimelineEventRenderer,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
|
||||
+43
@@ -22,6 +22,8 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlin.time.Duration
|
||||
|
||||
data class TimelineState(
|
||||
@@ -38,6 +40,7 @@ data class TimelineState(
|
||||
val displayThreadSummaries: Boolean,
|
||||
val displayJumpToUnread: Boolean,
|
||||
val jumpToUnread: JumpToUnreadState,
|
||||
val selectionState: SelectionState,
|
||||
// When true, formatted message bodies are rendered with the native Compose renderer.
|
||||
val useNewTimelineEventRenderer: Boolean,
|
||||
val eventSink: (TimelineEvent) -> Unit,
|
||||
@@ -104,3 +107,43 @@ sealed interface JumpToUnreadState {
|
||||
/** The read marker event is older than the loaded window — load it via focused-event navigation. */
|
||||
data class OutOfWindow(val eventId: EventId) : JumpToUnreadState
|
||||
}
|
||||
|
||||
val TimelineState.isSelectionModeActive: Boolean
|
||||
get() = selectionState is SelectionState.Active
|
||||
|
||||
val TimelineState.selectedCount: Int
|
||||
get() = (selectionState as? SelectionState.Active)?.selectedEventIds?.size ?: 0
|
||||
|
||||
fun TimelineState.isSelected(item: TimelineItem): Boolean {
|
||||
val selection = selectionState as? SelectionState.Active ?: return false
|
||||
return when (item) {
|
||||
is TimelineItem.Event -> selection.selectedEventIds.contains(item.eventId)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [item] can be selected right now: selection mode must be active, and the action it was entered for must accept this event.
|
||||
*/
|
||||
fun TimelineState.canSelect(item: TimelineItem): Boolean {
|
||||
val selection = selectionState as? SelectionState.Active ?: return false
|
||||
return when (item) {
|
||||
is TimelineItem.Event -> selection.action.canApplyTo(item)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected event ids, sorted from the oldest to the most recent one, as they appear in the timeline.
|
||||
* The selection itself is stored as a set, in the order the events were tapped, which is not necessarily the order they must be acted on.
|
||||
*/
|
||||
fun TimelineState.selectedEventIdsInTimelineOrder(): ImmutableList<EventId> {
|
||||
val selectedEventIds = (selectionState as? SelectionState.Active)?.selectedEventIds ?: return persistentListOf()
|
||||
return timelineItems
|
||||
.filterIsInstance<TimelineItem.Event>()
|
||||
.mapNotNull { it.eventId }
|
||||
.filter { it in selectedEventIds }
|
||||
// Most recent item is first in the timeline, so reverse the result.
|
||||
.reversed()
|
||||
.toImmutableList()
|
||||
}
|
||||
|
||||
+6
@@ -63,6 +63,7 @@ fun aTimelineState(
|
||||
displayJumpToUnread: Boolean = false,
|
||||
jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden,
|
||||
newEventState: NewEventState = NewEventState.None,
|
||||
selectionState: SelectionState = SelectionState.Disabled,
|
||||
useNewTimelineEventRenderer: Boolean = false,
|
||||
eventSink: (TimelineEvent) -> Unit = {},
|
||||
): TimelineState {
|
||||
@@ -85,6 +86,7 @@ fun aTimelineState(
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
displayJumpToUnread = displayJumpToUnread,
|
||||
jumpToUnread = jumpToUnread,
|
||||
selectionState = selectionState,
|
||||
useNewTimelineEventRenderer = useNewTimelineEventRenderer,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
@@ -157,6 +159,8 @@ internal fun aTimelineItemEvent(
|
||||
content: TimelineItemEventContent = aTimelineItemTextContent(),
|
||||
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
||||
sendState: LocalEventSendState? = null,
|
||||
editSendState: LocalEventSendState? = null,
|
||||
redactionSendState: LocalEventSendState? = null,
|
||||
inReplyTo: InReplyToDetails? = null,
|
||||
threadInfo: TimelineItemThreadInfo? = null,
|
||||
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
|
||||
@@ -183,6 +187,8 @@ internal fun aTimelineItemEvent(
|
||||
),
|
||||
groupPosition = groupPosition,
|
||||
localSendState = sendState,
|
||||
editSendState = editSendState,
|
||||
redactionSendState = redactionSendState,
|
||||
inReplyTo = inReplyTo,
|
||||
threadInfo = threadInfo,
|
||||
origin = null,
|
||||
|
||||
+58
-3
@@ -11,6 +11,7 @@ package io.element.android.features.messages.impl.timeline
|
||||
import android.view.HapticFeedbackConstants
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -88,8 +89,10 @@ import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPr
|
||||
import io.element.android.features.messages.impl.timeline.focus.FocusRequestStateView
|
||||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemSelectionData
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentPreviewParam
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
|
||||
@@ -108,6 +111,7 @@ import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -181,6 +185,18 @@ fun TimelineView(
|
||||
state.eventSink(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS))
|
||||
}
|
||||
|
||||
val isSelectionModeActive = state.isSelectionModeActive
|
||||
// Animate once here (shared by every row) rather than per-row: the value is identical for all
|
||||
// rows since it only depends on the timeline-wide selection mode.
|
||||
val selectionProgress by animateFloatAsState(
|
||||
targetValue = if (isSelectionModeActive) 1f else 0f,
|
||||
label = "selectionProgress",
|
||||
)
|
||||
fun onToggleSelection(event: TimelineItem.Event) {
|
||||
val eventId = event.eventId ?: return
|
||||
state.eventSink(TimelineEvent.ToggleSelection(eventId))
|
||||
}
|
||||
|
||||
// Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
|
||||
AnimatedVisibility(visible = true, enter = fadeIn()) {
|
||||
val composeLocalTimelineEventRendererConfig = remember(state.useNewTimelineEventRenderer, state.timelineRoomInfo.currentUserId) {
|
||||
@@ -213,12 +229,22 @@ fun TimelineView(
|
||||
isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()),
|
||||
focusedEventId = state.focusedEventId,
|
||||
displayThreadSummaries = state.displayThreadSummaries,
|
||||
selectionData = TimelineItemSelectionData(
|
||||
isSelectionModeActive = isSelectionModeActive,
|
||||
progress = selectionProgress,
|
||||
isSelected = state.isSelected(timelineItem),
|
||||
canBeSelected = state.canSelect(timelineItem),
|
||||
),
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = ::onLinkLongClick,
|
||||
onContentClick = onContentClick,
|
||||
onContentClick = { event ->
|
||||
if (state.canSelect(event)) onToggleSelection(event) else onContentClick(event)
|
||||
},
|
||||
onGalleryItemClick = onGalleryItemClick,
|
||||
onLongClick = onMessageLongClick,
|
||||
onLongClick = { event ->
|
||||
if (state.canSelect(event)) onToggleSelection(event) else onMessageLongClick(event)
|
||||
},
|
||||
inReplyToClick = ::inReplyToClick,
|
||||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
@@ -571,7 +597,6 @@ private fun JumpToPositionButton(
|
||||
modifier = Modifier
|
||||
.align(dotAlignment)
|
||||
.offset { IntOffset(x = 0, y = dotYOffset.roundToPx()) },
|
||||
color = ElementTheme.colors.iconSuccessPrimary,
|
||||
border = BorderStroke(2.dp, ElementTheme.colors.bgCanvasDefault),
|
||||
count = 0,
|
||||
)
|
||||
@@ -617,6 +642,36 @@ internal fun TimelineViewPreview(
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineViewSelectionModePreview() = ElementPreview {
|
||||
val timelineItems = aTimelineItemList(aTimelineItemTextContent())
|
||||
val selectedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().first { SelectionAction.Forward.canApplyTo(it) }.eventId!!
|
||||
CompositionLocalProvider(
|
||||
LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
|
||||
) {
|
||||
TimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = timelineItems,
|
||||
selectionState = SelectionState.Active(SelectionAction.Forward, persistentSetOf(selectedEventId)),
|
||||
),
|
||||
timelineProtectionState = aTimelineProtectionState(),
|
||||
onUserDataClick = {},
|
||||
onLinkClick = {},
|
||||
onContentClick = {},
|
||||
onMessageLongClick = {},
|
||||
onSwipeToReply = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onJoinCallClick = {},
|
||||
onMoreReactionsClick = {},
|
||||
onReadReceiptClick = {},
|
||||
onGalleryItemClick = { _, _ -> },
|
||||
forceJumpToBottomVisibility = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The jump-to-unread FAB's indicator is always rendered when the FAB is visible — in
|
||||
// production the FAB only appears when unread content exists above. So `hasUnreadAbove`
|
||||
// here only varies the scroll-target state, not the upper indicator's visibility.
|
||||
|
||||
+16
@@ -32,6 +32,8 @@ import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.drawOutline
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
||||
import androidx.compose.ui.layout.HorizontalAlignmentLine
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -53,12 +55,20 @@ import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
|
||||
import io.element.android.libraries.ui.utils.graphics.drawInLayer
|
||||
import kotlin.math.min
|
||||
|
||||
private val BUBBLE_RADIUS = 12.dp
|
||||
private val avatarRadius = AvatarSize.TimelineSender.dp / 2
|
||||
|
||||
private val MIN_BUBBLE_WIDTH = 80.dp
|
||||
|
||||
/**
|
||||
* Alignment line published by [MessageEventBubble] at its vertical center.
|
||||
* It lets the timeline row align decorations with the bubble itself, instead of with the whole row, whose height also
|
||||
* covers the sender information, the reactions, the thread summary and the read receipts.
|
||||
*/
|
||||
val BubbleVerticalCenter = HorizontalAlignmentLine(::min)
|
||||
|
||||
@Composable
|
||||
fun MessageEventBubble(
|
||||
state: BubbleState,
|
||||
@@ -93,6 +103,12 @@ fun MessageEventBubble(
|
||||
val updatedBorderColor by rememberUpdatedState(borderColor)
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
layout(placeable.width, placeable.height, mapOf(BubbleVerticalCenter to placeable.height / 2)) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
.drawWithCache {
|
||||
// Calculate the outline of the background and cache it
|
||||
val outline = bubbleShape.createOutline(size, layoutDirection, this)
|
||||
|
||||
+48
@@ -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)
|
||||
}
|
||||
+8
-7
@@ -55,13 +55,20 @@ fun TimelineEventTimestampView(
|
||||
val shield = event.messageShield
|
||||
val isVerifiedUserSendFailure = event.localSendState is LocalEventSendState.Failed.VerifiedUser
|
||||
val onClickLabel = when {
|
||||
hasError -> stringResource(CommonStrings.a11y_view_details)
|
||||
shield != null -> stringResource(CommonStrings.a11y_view_details)
|
||||
isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu)
|
||||
hasError -> stringResource(CommonStrings.a11y_view_details)
|
||||
else -> null
|
||||
}
|
||||
val clickableModifier = remember(event) {
|
||||
when {
|
||||
hasError -> {
|
||||
Modifier.clickable(
|
||||
onClickLabel = onClickLabel,
|
||||
) {
|
||||
eventSink(TimelineEvent.ShowSendFailureDialog(event))
|
||||
}
|
||||
}
|
||||
shield != null -> {
|
||||
Modifier.clickable(
|
||||
onClickLabel = onClickLabel,
|
||||
@@ -69,12 +76,6 @@ fun TimelineEventTimestampView(
|
||||
eventSink(TimelineEvent.ShowShieldDialog(shield))
|
||||
}
|
||||
}
|
||||
hasError -> Modifier
|
||||
.clickable(
|
||||
onClickLabel = onClickLabel,
|
||||
) {
|
||||
eventSink(TimelineEvent.ShowSendFailureDialog(event))
|
||||
}
|
||||
else -> Modifier
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.absoluteOffset
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -59,6 +60,7 @@ import androidx.compose.ui.semantics.traversalIndex
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
@@ -180,6 +182,9 @@ fun TimelineItemEventRow(
|
||||
onSwipeToReply: () -> Unit,
|
||||
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
// Horizontal offset applied to this row's content by selection mode. The read receipts are
|
||||
// anchored to the end edge and should stay pinned, so they are counter-offset by this amount.
|
||||
selectionContentOffset: Dp = 0.dp,
|
||||
eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange ->
|
||||
// Only pass down a custom clickable lambda if the content can be clicked separately
|
||||
val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable }
|
||||
@@ -319,14 +324,18 @@ fun TimelineItemEventRow(
|
||||
}
|
||||
|
||||
// Read receipts / Send state
|
||||
val sendState = event.pendingSend()?.second
|
||||
TimelineItemReadReceiptView(
|
||||
state = ReadReceiptViewState(
|
||||
sendState = event.localSendState,
|
||||
sendState = sendState,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
receipts = event.readReceiptState.receipts,
|
||||
),
|
||||
onReadReceiptsClick = { onReadReceiptClick(event) },
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
// Counter the selection-mode content slide so the end-anchored read receipts stay pinned.
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.offset(x = -selectionContentOffset)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.components.receipt.Rea
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
|
||||
import io.element.android.features.messages.impl.timeline.groups.isRedactedMessagesGroup
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemSelectionData
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
@@ -190,6 +191,7 @@ private fun TimelineItemGroupedEventsRowContent(
|
||||
onSwipeToReply = {},
|
||||
eventSink = eventSink,
|
||||
eventContentView = eventContentView,
|
||||
selectionData = TimelineItemSelectionData(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+153
-9
@@ -8,28 +8,39 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.AlignmentLine
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.isTraversalGroup
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvent
|
||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemSelectionData
|
||||
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
|
||||
@@ -60,6 +71,7 @@ internal fun TimelineItemRow(
|
||||
timelineProtectionState: TimelineProtectionState,
|
||||
focusedEventId: EventId?,
|
||||
displayThreadSummaries: Boolean,
|
||||
selectionData: TimelineItemSelectionData,
|
||||
onUserDataClick: (MatrixUser) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
@@ -92,17 +104,148 @@ internal fun TimelineItemRow(
|
||||
)
|
||||
},
|
||||
) {
|
||||
val backgroundModifier = if (timelineItem.isEvent(focusedEventId)) {
|
||||
val focusedEventOffset = if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) {
|
||||
14.dp
|
||||
} else {
|
||||
2.dp
|
||||
val isSelectionModeActive = selectionData.isSelectionModeActive
|
||||
val selectionProgress = selectionData.progress
|
||||
val isSelected = selectionData.isSelected
|
||||
val backgroundModifier = when {
|
||||
timelineItem.isEvent(focusedEventId) && !isSelectionModeActive -> {
|
||||
val focusedEventOffset =
|
||||
if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) {
|
||||
14.dp
|
||||
} else {
|
||||
2.dp
|
||||
}
|
||||
Modifier.focusedEvent(focusedEventOffset)
|
||||
}
|
||||
else -> {
|
||||
Modifier
|
||||
}
|
||||
Modifier.focusedEvent(focusedEventOffset)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Box(modifier = modifier.then(backgroundModifier)) {
|
||||
val selectableEvent = (timelineItem as? TimelineItem.Event)?.takeIf { selectionData.canBeSelected }
|
||||
// Only start-aligned message bubbles slide to make room for the selection indicator:
|
||||
// - Outgoing (own) messages are end-aligned, opposite the indicator, so they already have room.
|
||||
// - Centered tiles (state events, call tiles, day dividers, grouped events) must stay put.
|
||||
val isOutgoing = (timelineItem as? TimelineItem.Event)?.isMine == true
|
||||
val shouldSlideContent = timelineItem.rendersAsBubble() && !isOutgoing
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val contentSlide = if (shouldSlideContent) SELECTION_COLUMN_WIDTH * selectionProgress else 0.dp
|
||||
Layout(
|
||||
modifier = modifier.then(backgroundModifier),
|
||||
contents = listOf(
|
||||
{
|
||||
TimelineItemRowContent(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.offset(x = contentSlide),
|
||||
selectionContentOffset = contentSlide,
|
||||
timelineItem = timelineItem,
|
||||
timelineMode = timelineMode,
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
focusedEventId = focusedEventId,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClick = onLinkClick,
|
||||
onLinkLongClick = onLinkLongClick,
|
||||
onContentClick = onContentClick,
|
||||
onGalleryItemClick = onGalleryItemClick,
|
||||
onLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
eventSink = eventSink,
|
||||
eventContentView = eventContentView,
|
||||
)
|
||||
},
|
||||
{
|
||||
if (selectableEvent != null && selectionProgress > 0f) {
|
||||
val slideSign = if (layoutDirection == LayoutDirection.Ltr) -1f else 1f
|
||||
SelectionIndicator(
|
||||
selected = isSelected,
|
||||
modifier = Modifier
|
||||
.graphicsLayer { translationX = slideSign * SELECTION_COLUMN_WIDTH.toPx() * (1f - selectionProgress) }
|
||||
.padding(start = 16.dp)
|
||||
.size(24.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
if (isSelectionModeActive) {
|
||||
// In selection mode the whole row toggles selection.
|
||||
Box(modifier = Modifier.clickable(enabled = selectionData.canBeSelected, role = Role.Checkbox) {
|
||||
if (selectableEvent != null) {
|
||||
onContentClick(selectableEvent)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
),
|
||||
) { (contentMeasurables, indicatorMeasurables, overlayMeasurables), constraints ->
|
||||
val contentPlaceable = contentMeasurables.first().measure(constraints)
|
||||
val width = contentPlaceable.width
|
||||
val height = contentPlaceable.height
|
||||
val indicatorPlaceable = indicatorMeasurables.firstOrNull()?.measure(constraints.copy(minWidth = 0, minHeight = 0))
|
||||
val overlayPlaceable = overlayMeasurables.firstOrNull()?.measure(Constraints.fixed(width, height))
|
||||
layout(width = width, height = height) {
|
||||
contentPlaceable.place(0, 0)
|
||||
if (indicatorPlaceable != null) {
|
||||
val bubbleCenter = contentPlaceable[BubbleVerticalCenter].takeIf { it != AlignmentLine.Unspecified } ?: height / 2
|
||||
val x = if (layoutDirection == LayoutDirection.Ltr) 0 else width - indicatorPlaceable.width
|
||||
indicatorPlaceable.place(x = x, y = bubbleCenter - indicatorPlaceable.height / 2)
|
||||
}
|
||||
overlayPlaceable?.place(0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Width reserved for the selection indicator : the row content slides by this amount. */
|
||||
private val SELECTION_COLUMN_WIDTH = 36.dp
|
||||
|
||||
/**
|
||||
* Whether this item is rendered as a start/end-aligned message bubble, as opposed to a centered
|
||||
* tile (state event, legacy call invite, call/RTC notification, day divider, grouped events).
|
||||
* Mirrors the dispatch in [TimelineItemRowContent]: only bubbles shift to reveal the selection
|
||||
* indicator; centered tiles stay put.
|
||||
*/
|
||||
private fun TimelineItem.rendersAsBubble(): Boolean = this is TimelineItem.Event &&
|
||||
content !is TimelineItemStateContent &&
|
||||
content !is TimelineItemLegacyCallInviteContent &&
|
||||
content !is TimelineItemRtcNotificationContent
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun TimelineItemRowContent(
|
||||
timelineItem: TimelineItem,
|
||||
timelineMode: Timeline.Mode,
|
||||
timelineRoomInfo: TimelineRoomInfo,
|
||||
isLastOutgoingMessage: Boolean,
|
||||
timelineProtectionState: TimelineProtectionState,
|
||||
focusedEventId: EventId?,
|
||||
displayThreadSummaries: Boolean,
|
||||
onUserDataClick: (MatrixUser) -> Unit,
|
||||
onLinkClick: (Link) -> Unit,
|
||||
onLinkLongClick: (Link) -> Unit,
|
||||
onContentClick: (TimelineItem.Event) -> Unit,
|
||||
onGalleryItemClick: (TimelineItem.Event, Int) -> Unit,
|
||||
onLongClick: (TimelineItem.Event) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
||||
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
|
||||
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
|
||||
onReadReceiptClick: (TimelineItem.Event) -> Unit,
|
||||
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
|
||||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
selectionContentOffset: Dp = 0.dp,
|
||||
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit,
|
||||
) {
|
||||
Box(modifier) {
|
||||
when (timelineItem) {
|
||||
is TimelineItem.Virtual -> {
|
||||
TimelineItemVirtualRow(
|
||||
@@ -197,6 +340,7 @@ internal fun TimelineItemRow(
|
||||
onSwipeToReply = { onSwipeToReply(timelineItem) },
|
||||
onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) },
|
||||
eventSink = eventSink,
|
||||
selectionContentOffset = selectionContentOffset,
|
||||
eventContentView = { contentModifier, onContentLayoutChange ->
|
||||
eventContentView(timelineItem, contentModifier, onContentLayoutChange)
|
||||
},
|
||||
|
||||
+7
-4
@@ -20,9 +20,9 @@ import kotlinx.collections.immutable.toImmutableList
|
||||
class ReadReceiptViewStatePreviewParam : PreviewParameterProvider<ReadReceiptViewState> {
|
||||
override val values: Sequence<ReadReceiptViewState>
|
||||
get() = sequenceOf(
|
||||
aReadReceiptViewState(),
|
||||
aReadReceiptViewState(sendState = LocalEventSendState.Sending.Event),
|
||||
aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId"))),
|
||||
aReadReceiptViewState(sendState = null, isLastOutgoingMessage = true),
|
||||
aReadReceiptViewState(sendState = LocalEventSendState.Sending.Event, isLastOutgoingMessage = true),
|
||||
aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId")), isLastOutgoingMessage = true),
|
||||
aReadReceiptViewState(
|
||||
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
|
||||
receipts = List(1) { aReadReceiptData(it) },
|
||||
@@ -43,12 +43,15 @@ class ReadReceiptViewStatePreviewParam : PreviewParameterProvider<ReadReceiptVie
|
||||
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
|
||||
receipts = List(5) { aReadReceiptData(it) },
|
||||
),
|
||||
aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId")), isLastOutgoingMessage = false),
|
||||
aReadReceiptViewState(sendState = LocalEventSendState.Sending.Event, isLastOutgoingMessage = false),
|
||||
aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId")), isLastOutgoingMessage = false),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aReadReceiptViewState(
|
||||
sendState: LocalEventSendState? = null,
|
||||
isLastOutgoingMessage: Boolean = true,
|
||||
isLastOutgoingMessage: Boolean = false,
|
||||
receipts: List<ReadReceiptData> = emptyList(),
|
||||
) = ReadReceiptViewState(
|
||||
sendState = sendState,
|
||||
|
||||
+50
-33
@@ -57,43 +57,60 @@ fun TimelineItemReadReceiptView(
|
||||
onReadReceiptsClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (state.receipts.isNotEmpty()) {
|
||||
ReadReceiptsRow(modifier = modifier) {
|
||||
ReadReceiptsAvatars(
|
||||
receipts = state.receipts,
|
||||
onClick = onReadReceiptsClick,
|
||||
)
|
||||
val content: @Composable (() -> Unit)? = when (state.sendState) {
|
||||
// If null, assume it's Sent and act the same way
|
||||
null if state.isLastOutgoingMessage && state.receipts.isEmpty() -> {
|
||||
@Composable { SentIcon() }
|
||||
}
|
||||
} else {
|
||||
when (state.sendState) {
|
||||
is LocalEventSendState.Sending -> {
|
||||
ReadReceiptsRow(modifier) {
|
||||
Icon(
|
||||
modifier = Modifier.padding(2.dp),
|
||||
imageVector = CompoundIcons.Circle(),
|
||||
contentDescription = stringResource(id = CommonStrings.common_sending),
|
||||
tint = ElementTheme.colors.iconSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
is LocalEventSendState.Failed -> {
|
||||
// Error? The timestamp is already displayed in red
|
||||
}
|
||||
null,
|
||||
is LocalEventSendState.Sent -> {
|
||||
if (state.isLastOutgoingMessage) {
|
||||
ReadReceiptsRow(modifier = modifier) {
|
||||
Icon(
|
||||
modifier = Modifier.padding(2.dp),
|
||||
imageVector = CompoundIcons.CheckCircle(),
|
||||
contentDescription = stringResource(id = CommonStrings.common_sent),
|
||||
tint = ElementTheme.colors.iconSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
is LocalEventSendState.Sending -> {
|
||||
@Composable { SendingIcon() }
|
||||
}
|
||||
is LocalEventSendState.Failed -> {
|
||||
// Error? The timestamp is already displayed in red
|
||||
null
|
||||
}
|
||||
is LocalEventSendState.Sent if state.isLastOutgoingMessage && state.receipts.isEmpty() -> {
|
||||
@Composable { SentIcon() }
|
||||
}
|
||||
else -> if (state.receipts.isNotEmpty()) {
|
||||
@Composable {
|
||||
ReadReceiptsAvatars(
|
||||
receipts = state.receipts,
|
||||
onClick = onReadReceiptsClick,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Only show the row if there is content to display
|
||||
content?.let {
|
||||
ReadReceiptsRow(
|
||||
modifier = modifier,
|
||||
content = it,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SendingIcon() {
|
||||
Icon(
|
||||
modifier = Modifier.padding(2.dp),
|
||||
imageVector = CompoundIcons.Circle(),
|
||||
contentDescription = stringResource(id = CommonStrings.common_sending),
|
||||
tint = ElementTheme.colors.iconSecondary
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SentIcon() {
|
||||
Icon(
|
||||
modifier = Modifier.padding(2.dp),
|
||||
imageVector = CompoundIcons.CheckCircle(),
|
||||
contentDescription = stringResource(id = CommonStrings.common_sent),
|
||||
tint = ElementTheme.colors.iconSecondary
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ class ReadReceiptBottomSheetStatePreviewParam : PreviewParameterProvider<ReadRec
|
||||
// Reuse the provider ReadReceiptViewStatePreviewParam
|
||||
private val readReceiptViewStateProvider = ReadReceiptViewStatePreviewParam()
|
||||
override val values: Sequence<ReadReceiptBottomSheetState> = readReceiptViewStateProvider.values
|
||||
.filter { it.sendState is LocalEventSendState.Sent }
|
||||
.filter { it.sendState is LocalEventSendState.Sent && it.receipts.isNotEmpty() }
|
||||
.map { readReceiptViewState ->
|
||||
ReadReceiptBottomSheetState(
|
||||
selectedEvent = aTimelineItemEvent(
|
||||
|
||||
+2
@@ -123,6 +123,8 @@ class TimelineItemEventFactory(
|
||||
reactionsState = currentTimelineItem.computeReactionsState(),
|
||||
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers, renderReadReceipts),
|
||||
localSendState = currentTimelineItem.event.localSendState,
|
||||
editSendState = currentTimelineItem.event.editSendState,
|
||||
redactionSendState = currentTimelineItem.event.redactionSendState,
|
||||
inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser),
|
||||
threadInfo = mappedThreadInfo,
|
||||
origin = currentTimelineItem.event.origin,
|
||||
|
||||
+16
-1
@@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.SendTarget
|
||||
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
@@ -92,6 +93,8 @@ sealed interface TimelineItem {
|
||||
val reactionsState: TimelineItemReactions,
|
||||
val readReceiptState: TimelineItemReadReceipts,
|
||||
val localSendState: LocalEventSendState?,
|
||||
private val editSendState: LocalEventSendState?,
|
||||
private val redactionSendState: LocalEventSendState?,
|
||||
val inReplyTo: InReplyToDetails?,
|
||||
val threadInfo: TimelineItemThreadInfo?,
|
||||
val origin: TimelineItemEventOrigin?,
|
||||
@@ -110,7 +113,7 @@ sealed interface TimelineItem {
|
||||
|
||||
val safeSenderName: String = senderProfile.getDisambiguatedDisplayName(senderId)
|
||||
|
||||
val failedToSend: Boolean = localSendState is LocalEventSendState.Failed
|
||||
val failedToSend: Boolean = listOf(localSendState, editSendState, redactionSendState).any { it is LocalEventSendState.Failed }
|
||||
|
||||
val isTextMessage: Boolean = content is TimelineItemTextBasedContent
|
||||
|
||||
@@ -143,6 +146,18 @@ sealed interface TimelineItem {
|
||||
get() = timelineItemDebugInfoProvider()
|
||||
|
||||
val sendhandle: SendHandle? get() = sendHandleProvider()
|
||||
|
||||
/**
|
||||
* Returns the first pending send state, if any, along with the corresponding [SendTarget].
|
||||
*/
|
||||
fun pendingSend(): Pair<SendTarget, LocalEventSendState>? {
|
||||
return when {
|
||||
localSendState != null -> SendTarget.Event to localSendState
|
||||
editSendState != null -> SendTarget.Edit to editSendState
|
||||
redactionSendState != null -> SendTarget.Redaction to redactionSendState
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class GroupedEvents(
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.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,
|
||||
)
|
||||
+4
-2
@@ -50,16 +50,18 @@ fun SendFailureDialogView(
|
||||
)
|
||||
}
|
||||
|
||||
val (sendTarget, _) = sendFailureDialogState.event.pendingSend() ?: null to null
|
||||
|
||||
ConfirmationDialog(
|
||||
modifier = modifier,
|
||||
title = stringResource(id = CommonStrings.common_sending_failed),
|
||||
content = content,
|
||||
onDismiss = onDismiss,
|
||||
submitText = stringResource(id = CommonStrings.action_retry),
|
||||
onSubmitClick = { onRetry(sendFailureDialogState.event) },
|
||||
onSubmitClick = { sendTarget?.let { onRetry(sendFailureDialogState.event) } },
|
||||
onCancelClick = onDismiss,
|
||||
thirdButtonText = stringResource(id = CommonStrings.action_remove_message),
|
||||
onThirdButtonClick = { onRemoveMessage(sendFailureDialogState.event) },
|
||||
onThirdButtonClick = { sendTarget?.let { onRemoveMessage(sendFailureDialogState.event) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+78
@@ -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)
|
||||
}
|
||||
+3
-3
@@ -20,7 +20,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
class FakeMessagesNavigator(
|
||||
private val onShowEventDebugInfoClickLambda: (eventId: EventId?, debugInfo: TimelineItemDebugInfo) -> Unit = { _, _ -> lambdaError() },
|
||||
private val onForwardEventClickLambda: (eventId: EventId, timelineProvider: TimelineProvider) -> Unit = { _, _ -> lambdaError() },
|
||||
private val onForwardEventClickLambda: (eventIds: List<EventId>, timelineProvider: TimelineProvider) -> Unit = { _, _ -> lambdaError() },
|
||||
private val onReportContentClickLambda: (eventId: EventId, senderId: UserId) -> Unit = { _, _ -> lambdaError() },
|
||||
private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() },
|
||||
private val onPreviewAttachmentLambda: (attachments: ImmutableList<Attachment>, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() },
|
||||
@@ -35,8 +35,8 @@ class FakeMessagesNavigator(
|
||||
onShowEventDebugInfoClickLambda(eventId, debugInfo)
|
||||
}
|
||||
|
||||
override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) {
|
||||
onForwardEventClickLambda(eventId, timelineProvider)
|
||||
override fun forwardEvents(eventIds: List<EventId>, timelineProvider: TimelineProvider) {
|
||||
onForwardEventClickLambda(eventIds, timelineProvider)
|
||||
}
|
||||
|
||||
override fun navigateToReportMessage(eventId: EventId, senderId: UserId) {
|
||||
|
||||
+96
-31
@@ -28,8 +28,12 @@ import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMess
|
||||
import io.element.android.features.messages.impl.threads.list.aThreadListItem
|
||||
import io.element.android.features.messages.impl.timeline.FakeMarkAsFullyRead
|
||||
import io.element.android.features.messages.impl.timeline.MarkAsFullyRead
|
||||
import io.element.android.features.messages.impl.timeline.SelectionAction
|
||||
import io.element.android.features.messages.impl.timeline.SelectionState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvent
|
||||
import io.element.android.features.messages.impl.timeline.TimelineState
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||
@@ -78,6 +82,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSen
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.A_CAPTION
|
||||
import io.element.android.libraries.matrix.test.A_REASON
|
||||
@@ -88,7 +93,6 @@ import io.element.android.libraries.matrix.test.A_THREAD_ID
|
||||
import io.element.android.libraries.matrix.test.A_TRANSACTION_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.core.FakeSendHandle
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
@@ -118,6 +122,7 @@ import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import io.element.android.tests.testutils.testWithLifecycleOwner
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -289,17 +294,37 @@ class MessagesPresenterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action forward`() = runTest {
|
||||
val onForwardEventClickLambda = lambdaRecorder<EventId, TimelineProvider, Unit> { _, _ -> }
|
||||
val navigator = FakeMessagesNavigator(
|
||||
onForwardEventClickLambda = onForwardEventClickLambda,
|
||||
fun `present - handle action forward enters selection mode when multi select is enabled`() = runTest {
|
||||
val timelineEventSink = lambdaRecorder<TimelineEvent, Unit> { }
|
||||
val presenter = createMessagesPresenter(
|
||||
timelineEventSink = timelineEventSink,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
|
||||
)
|
||||
val presenter = createMessagesPresenter(navigator = navigator)
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
onForwardEventClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID), any())
|
||||
timelineEventSink.assertions().isCalledOnce()
|
||||
.with(value(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action forward navigates directly when multi select is disabled`() = runTest {
|
||||
val onForwardEventClickLambda = lambdaRecorder<List<EventId>, TimelineProvider, Unit> { _, _ -> }
|
||||
val navigator = FakeMessagesNavigator(
|
||||
onForwardEventClickLambda = onForwardEventClickLambda,
|
||||
)
|
||||
val presenter = createMessagesPresenter(
|
||||
navigator = navigator,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to false)),
|
||||
)
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Forward, aMessageEvent()))
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
onForwardEventClickLambda.assertions().isCalledOnce().with(value(listOf(AN_EVENT_ID)), any())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,24 +645,24 @@ class MessagesPresenterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action redact - a message which was never sent is redacted without asking`() = runTest {
|
||||
fun `present - handle action redact - a message which was never sent is removed from the send queue without asking`() = runTest {
|
||||
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
|
||||
val liveTimeline = FakeTimeline()
|
||||
val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) }
|
||||
liveTimeline.redactEventLambda = redactEventLambda
|
||||
val abortSendLambda = lambdaRecorder { _: EventOrTransactionId -> Result.success(true) }
|
||||
val presenter = createMessagesPresenter(
|
||||
timeline = liveTimeline,
|
||||
timeline = FakeTimeline(abortSendResult = abortSendLambda),
|
||||
coroutineDispatchers = coroutineDispatchers,
|
||||
)
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
val localEcho = aMessageEvent(eventId = null, transactionId = A_TRANSACTION_ID)
|
||||
val localEcho = aMessageEvent(
|
||||
eventId = null,
|
||||
transactionId = A_TRANSACTION_ID,
|
||||
sendState = LocalEventSendState.Failed.Unknown("Error"),
|
||||
)
|
||||
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, localEcho))
|
||||
advanceUntilIdle()
|
||||
assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized)
|
||||
assert(redactEventLambda)
|
||||
.isCalledOnce()
|
||||
.with(value(localEcho.eventOrTransactionId), value(null))
|
||||
abortSendLambda.assertions().isCalledOnce().with(value(localEcho.eventOrTransactionId))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1151,29 +1176,27 @@ class MessagesPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - handle action retry sending`() = runTest {
|
||||
val retryLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val retryLambda = lambdaRecorder { _: EventOrTransactionId -> Result.success(true) }
|
||||
val messageEvent = aMessageEvent(
|
||||
sendState = LocalEventSendState.Failed.Unknown("Error"),
|
||||
sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) },
|
||||
)
|
||||
val presenter = createMessagesPresenter()
|
||||
val presenter = createMessagesPresenter(timeline = FakeTimeline(retrySendResult = retryLambda))
|
||||
presenter.testWithLifecycleOwner {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent))
|
||||
advanceUntilIdle()
|
||||
retryLambda.assertions().isCalledOnce()
|
||||
retryLambda.assertions().isCalledOnce().with(value(messageEvent.eventOrTransactionId))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action retry sending - failure is ignored`() = runTest {
|
||||
val retryLambda = lambdaRecorder<Result<Unit>> { Result.failure(AN_EXCEPTION) }
|
||||
val retryLambda = lambdaRecorder { _: EventOrTransactionId -> Result.failure<Boolean>(AN_EXCEPTION) }
|
||||
val messageEvent = aMessageEvent(
|
||||
sendState = LocalEventSendState.Failed.Unknown("Error"),
|
||||
sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) },
|
||||
)
|
||||
val presenter = createMessagesPresenter()
|
||||
val presenter = createMessagesPresenter(timeline = FakeTimeline(retrySendResult = retryLambda))
|
||||
presenter.testWithLifecycleOwner {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
@@ -1184,18 +1207,16 @@ class MessagesPresenterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action retry sending - no send handle, it should have no effect`() = runTest {
|
||||
val messageEvent = aMessageEvent(
|
||||
sendState = LocalEventSendState.Failed.Unknown("Error"),
|
||||
sendHandleProvider = { null },
|
||||
)
|
||||
val presenter = createMessagesPresenter()
|
||||
fun `present - handle action retry sending - no pending send state, it should have no effect`() = runTest {
|
||||
val retryLambda = lambdaRecorder { _: EventOrTransactionId -> Result.success(true) }
|
||||
val messageEvent = aMessageEvent(sendState = null)
|
||||
val presenter = createMessagesPresenter(timeline = FakeTimeline(retrySendResult = retryLambda))
|
||||
presenter.testWithLifecycleOwner {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent))
|
||||
advanceUntilIdle()
|
||||
// No op!
|
||||
retryLambda.assertions().isNeverCalled()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1501,6 +1522,49 @@ class MessagesPresenterTest {
|
||||
canPinUnpin = canPinUnpin,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `present - confirm selection action forwards the selection in timeline order`() = runTest {
|
||||
val onForwardEventClickLambda = lambdaRecorder<List<EventId>, TimelineProvider, Unit> { _, _ -> }
|
||||
val navigator = FakeMessagesNavigator(onForwardEventClickLambda = onForwardEventClickLambda)
|
||||
val timelineEventSink = lambdaRecorder<TimelineEvent, Unit> { }
|
||||
val presenter = createMessagesPresenter(
|
||||
navigator = navigator,
|
||||
timelineEventSink = timelineEventSink,
|
||||
timelineState = aTimelineState(
|
||||
// The timeline is rendered from the most recent event to the oldest one.
|
||||
timelineItems = persistentListOf(
|
||||
aTimelineItemEvent(eventId = AN_EVENT_ID_2),
|
||||
aTimelineItemEvent(eventId = AN_EVENT_ID),
|
||||
),
|
||||
selectionState = SelectionState.Active(
|
||||
SelectionAction.Forward,
|
||||
persistentSetOf(AN_EVENT_ID_2, AN_EVENT_ID),
|
||||
),
|
||||
eventSink = timelineEventSink,
|
||||
),
|
||||
)
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvent.ConfirmSelectionAction)
|
||||
onForwardEventClickLambda.assertions().isCalledOnce().with(value(listOf(AN_EVENT_ID, AN_EVENT_ID_2)), any())
|
||||
timelineEventSink.assertions().isCalledOnce().with(value(TimelineEvent.ExitSelectionMode))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - confirm selection action does nothing when no selection is active`() = runTest {
|
||||
val onForwardEventClickLambda = lambdaRecorder<List<EventId>, TimelineProvider, Unit> { _, _ -> }
|
||||
val navigator = FakeMessagesNavigator(onForwardEventClickLambda = onForwardEventClickLambda)
|
||||
val presenter = createMessagesPresenter(navigator = navigator)
|
||||
presenter.testWithLifecycleOwner {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvent.ConfirmSelectionAction)
|
||||
onForwardEventClickLambda.assertions().isNeverCalled()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createMessagesPresenter(
|
||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||
timeline: Timeline = FakeTimeline(),
|
||||
@@ -1523,6 +1587,7 @@ class MessagesPresenterTest {
|
||||
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
|
||||
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
||||
timelineEventSink: (TimelineEvent) -> Unit = {},
|
||||
timelineState: TimelineState = aTimelineState(eventSink = timelineEventSink),
|
||||
permalinkParser: PermalinkParser = FakePermalinkParser(),
|
||||
messageComposerPresenter: Presenter<MessageComposerState> = Presenter {
|
||||
aMessageComposerState(
|
||||
@@ -1545,7 +1610,7 @@ class MessagesPresenterTest {
|
||||
room = joinedRoom,
|
||||
composerPresenter = messageComposerPresenter,
|
||||
voiceMessageComposerPresenterFactory = FakeDefaultVoiceMessageComposerPresenterFactory(backgroundScope),
|
||||
timelinePresenter = { aTimelineState(eventSink = timelineEventSink) },
|
||||
timelinePresenter = { timelineState },
|
||||
timelineProtectionPresenter = { aTimelineProtectionState() },
|
||||
identityChangeStatePresenter = { anIdentityChangeState() },
|
||||
linkPresenter = { aLinkState() },
|
||||
|
||||
+1
-1
@@ -606,7 +606,7 @@ class MessagesViewTest : RobolectricTest() {
|
||||
timelineEventsRecorder.clear()
|
||||
|
||||
clickOn(CommonStrings.action_remove_message)
|
||||
messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.Redact, timelineItem))
|
||||
messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.AbortSending, timelineItem))
|
||||
timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog)
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -43,7 +43,7 @@ internal fun aMessageEvent(
|
||||
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = A_MESSAGE, isEdited = false),
|
||||
inReplyTo: InReplyToDetails? = null,
|
||||
threadInfo: TimelineItemThreadInfo? = null,
|
||||
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
|
||||
sendState: LocalEventSendState? = LocalEventSendState.Sent(AN_EVENT_ID),
|
||||
debugInfoProvider: TimelineItemDebugInfoProvider = TimelineItemDebugInfoProvider { aTimelineItemDebugInfo() },
|
||||
messageShieldProvider: MessageShieldProvider = MessageShieldProvider { null },
|
||||
sendHandleProvider: SendHandleProvider = SendHandleProvider { FakeSendHandle() }
|
||||
@@ -62,6 +62,8 @@ internal fun aMessageEvent(
|
||||
reactionsState = aTimelineItemReactions(count = 0),
|
||||
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
|
||||
localSendState = sendState,
|
||||
editSendState = null,
|
||||
redactionSendState = null,
|
||||
inReplyTo = inReplyTo,
|
||||
threadInfo = threadInfo,
|
||||
origin = null,
|
||||
|
||||
+60
@@ -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()
|
||||
}
|
||||
}
|
||||
+89
@@ -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(),
|
||||
)
|
||||
}
|
||||
+149
@@ -38,6 +38,7 @@ import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
|
||||
import io.element.android.libraries.androidutils.toast.FakeToastHelper
|
||||
import io.element.android.libraries.androidutils.toast.ToastHelper
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
@@ -91,6 +92,7 @@ import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -1861,6 +1863,152 @@ class TimelinePresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - entering selection mode selects the target event`() = runTest {
|
||||
val timelineItems = MutableStateFlow(
|
||||
listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())))
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = FakeTimeline(timelineItems = timelineItems),
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
|
||||
assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled)
|
||||
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
|
||||
val state = consumeItemsUntilPredicate { it.isSelectionModeActive }.last()
|
||||
assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID)))
|
||||
assertThat(state.selectedCount).isEqualTo(1)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - entering selection mode is ignored when the flag is off`() = runTest {
|
||||
val featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to false))
|
||||
val presenter = createTimelinePresenter(featureFlagService = featureFlagService)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
|
||||
expectNoEvents()
|
||||
assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - toggling the last selected event keeps selection mode active`() = runTest {
|
||||
val timelineItems = MutableStateFlow(
|
||||
listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())))
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = FakeTimeline(timelineItems = timelineItems),
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
|
||||
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
|
||||
consumeItemsUntilPredicate { it.isSelectionModeActive }
|
||||
initialState.eventSink(TimelineEvent.ToggleSelection(AN_EVENT_ID))
|
||||
// Selection mode is only exited explicitly, so an empty selection keeps it active.
|
||||
val state = consumeItemsUntilPredicate { it.selectedCount == 0 }.last()
|
||||
assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf()))
|
||||
assertThat(state.isSelectionModeActive).isTrue()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - exiting selection mode clears the selection`() = runTest {
|
||||
val timelineItems = MutableStateFlow(
|
||||
listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())))
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = FakeTimeline(timelineItems = timelineItems),
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
|
||||
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
|
||||
consumeItemsUntilPredicate { it.isSelectionModeActive }
|
||||
initialState.eventSink(TimelineEvent.ExitSelectionMode)
|
||||
val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last()
|
||||
assertThat(state.selectionState).isEqualTo(SelectionState.Disabled)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - turning the flag off clears the active selection`() = runTest {
|
||||
val timelineItems = MutableStateFlow(
|
||||
listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())))
|
||||
)
|
||||
val featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true))
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = FakeTimeline(timelineItems = timelineItems),
|
||||
featureFlagService = featureFlagService,
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
|
||||
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
|
||||
consumeItemsUntilPredicate { it.isSelectionModeActive }
|
||||
featureFlagService.setFeatureEnabled(FeatureFlags.MessageMultiSelect, false)
|
||||
val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last()
|
||||
assertThat(state.selectionState).isEqualTo(SelectionState.Disabled)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - a selected event which cannot be forwarded anymore is deselected`() = runTest {
|
||||
val timelineItems = MutableStateFlow(
|
||||
listOf(
|
||||
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())),
|
||||
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(eventId = AN_EVENT_ID_2, content = aMessageContent())),
|
||||
)
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = FakeTimeline(timelineItems = timelineItems),
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
|
||||
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
|
||||
initialState.eventSink(TimelineEvent.ToggleSelection(AN_EVENT_ID_2))
|
||||
consumeItemsUntilPredicate { it.selectedCount == 2 }
|
||||
// The first message is deleted, so it cannot be forwarded anymore.
|
||||
timelineItems.value = listOf(
|
||||
MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aRedactedContent())),
|
||||
MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(eventId = AN_EVENT_ID_2, content = aMessageContent())),
|
||||
)
|
||||
val state = consumeItemsUntilPredicate { it.selectedCount == 1 }.last()
|
||||
assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID_2)))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - the selection survives a timeline which no longer contains the selected event`() = runTest {
|
||||
val timelineItems = MutableStateFlow(
|
||||
listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())))
|
||||
)
|
||||
val presenter = createTimelinePresenter(
|
||||
timeline = FakeTimeline(timelineItems = timelineItems),
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last()
|
||||
initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))
|
||||
consumeItemsUntilPredicate { it.isSelectionModeActive }
|
||||
// The timeline no longer holds the selected event, for instance because it has been reset or because it switched to a detached timeline.
|
||||
timelineItems.value = emptyList()
|
||||
val state = consumeItemsUntilPredicate { !it.hasAnyEvent }.last()
|
||||
// The selection is the user intent: it is only validated when the action is confirmed, so that a transient timeline update cannot drop it.
|
||||
assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID)))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||
return awaitItem()
|
||||
}
|
||||
@@ -1958,6 +2106,7 @@ class TimelinePresenterTest {
|
||||
typingNotificationPresenter = { aTypingNotificationState() },
|
||||
roomCallStatePresenter = { aStandByCallState() },
|
||||
featureFlagService = featureFlagService,
|
||||
snackbarDispatcher = SnackbarDispatcher(),
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
liveLocationShareManager = liveLocationShareManager,
|
||||
markAsFullyRead = markAsFullyRead,
|
||||
|
||||
+77
@@ -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()
|
||||
}
|
||||
}
|
||||
+2
@@ -43,6 +43,8 @@ class TimelineItemGrouperTest {
|
||||
reactionsState = aTimelineItemReactions(count = 0),
|
||||
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
|
||||
localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
|
||||
editSendState = null,
|
||||
redactionSendState = null,
|
||||
isEditable = false,
|
||||
canBeRepliedTo = false,
|
||||
inReplyTo = null,
|
||||
|
||||
+2
@@ -83,6 +83,8 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
|
||||
isOwn = false,
|
||||
isRemote = false,
|
||||
localSendState = null,
|
||||
editSendState = null,
|
||||
redactionSendState = null,
|
||||
reactions = persistentListOf(),
|
||||
receipts = persistentListOf(),
|
||||
sender = A_USER_ID,
|
||||
|
||||
+5
@@ -89,6 +89,10 @@ class SecurityAndPrivacyPresenter(
|
||||
value = sessionEnterpriseService.isEncryptionDisabledByHomeserver()
|
||||
}
|
||||
|
||||
val arePublicRoomsDisabledByHomeserver by produceState(false) {
|
||||
value = sessionEnterpriseService.arePublicRoomsDisabledByHomeserver()
|
||||
}
|
||||
|
||||
val savedIsVisibleInRoomDirectory = remember { mutableStateOf<AsyncData<Boolean>>(AsyncData.Uninitialized) }
|
||||
LaunchedEffect(Unit) {
|
||||
isRoomVisibleInRoomDirectory(savedIsVisibleInRoomDirectory)
|
||||
@@ -254,6 +258,7 @@ class SecurityAndPrivacyPresenter(
|
||||
selectableJoinedSpaces = selectableJoinedSpaces,
|
||||
spaceSelectionMode = spaceSelectionMode,
|
||||
isEncryptionDisabledByHomeserver = isEncryptionDisabledByHomeserver,
|
||||
arePublicRoomsDisabledByHomeserver = arePublicRoomsDisabledByHomeserver,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
|
||||
|
||||
+6
@@ -28,6 +28,7 @@ data class SecurityAndPrivacyState(
|
||||
val editedSettings: SecurityAndPrivacySettings,
|
||||
val homeserverName: String,
|
||||
val isEncryptionDisabledByHomeserver: Boolean,
|
||||
private val arePublicRoomsDisabledByHomeserver: Boolean,
|
||||
val showEnableEncryptionConfirmation: Boolean,
|
||||
private val isKnockEnabled: Boolean,
|
||||
val saveAction: AsyncAction<Unit>,
|
||||
@@ -39,6 +40,11 @@ data class SecurityAndPrivacyState(
|
||||
) {
|
||||
val isSpaceMemberSelectable = spaceSelectionMode != SpaceSelectionMode.None
|
||||
|
||||
// Show Anyone option when:
|
||||
// - Anyone is the current saved value, OR
|
||||
// - The homeserver allows public rooms
|
||||
val showAnyoneOption = savedSettings.roomAccess == SecurityAndPrivacyRoomAccess.Anyone || !arePublicRoomsDisabledByHomeserver
|
||||
|
||||
// Show SpaceMember option in two cases:
|
||||
// - SpaceMember is the current saved value
|
||||
// - SpaceMember option is selectable (ie. the FF is enabled and there is at least one space to select)
|
||||
|
||||
+6
@@ -37,6 +37,10 @@ open class SecurityAndPrivacyStatePreviewParam : PreviewParameterProvider<Securi
|
||||
showEncryptionConfirmation = true,
|
||||
isSpace = false,
|
||||
),
|
||||
aSecurityAndPrivacyState(
|
||||
arePublicRoomsDisabledByHomeserver = true,
|
||||
isSpace = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -139,6 +143,7 @@ fun aSecurityAndPrivacyState(
|
||||
selectableJoinedSpaces: Set<SpaceRoom> = emptySet(),
|
||||
spaceSelectionMode: SpaceSelectionMode = SpaceSelectionMode.None,
|
||||
isEncryptionDisabledByHomeserver: Boolean = false,
|
||||
arePublicRoomsDisabledByHomeserver: Boolean = false,
|
||||
eventSink: (SecurityAndPrivacyEvent) -> Unit = {}
|
||||
) = SecurityAndPrivacyState(
|
||||
editedSettings = editedSettings,
|
||||
@@ -152,5 +157,6 @@ fun aSecurityAndPrivacyState(
|
||||
selectableJoinedSpaces = selectableJoinedSpaces.toImmutableSet(),
|
||||
spaceSelectionMode = spaceSelectionMode,
|
||||
isEncryptionDisabledByHomeserver = isEncryptionDisabledByHomeserver,
|
||||
arePublicRoomsDisabledByHomeserver = arePublicRoomsDisabledByHomeserver,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
+9
-7
@@ -232,13 +232,15 @@ private fun RoomAccessSection(
|
||||
title = stringResource(R.string.screen_security_and_privacy_room_access_section_header),
|
||||
modifier = modifier,
|
||||
) {
|
||||
ListItem(
|
||||
content = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_title)) },
|
||||
supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_description)) },
|
||||
trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.Anyone),
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Public())),
|
||||
onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.Anyone) },
|
||||
)
|
||||
if (state.showAnyoneOption) {
|
||||
ListItem(
|
||||
content = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_title)) },
|
||||
supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_description)) },
|
||||
trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.Anyone),
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Public())),
|
||||
onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.Anyone) },
|
||||
)
|
||||
}
|
||||
if (state.showSpaceMemberOption) {
|
||||
ListItem(
|
||||
content = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_title)) },
|
||||
|
||||
+43
-1
@@ -34,6 +34,8 @@ import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
|
||||
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
|
||||
import io.element.android.libraries.previewutils.room.aSpaceRoom
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.consumeItemsUntilTimeout
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
@@ -1048,7 +1050,10 @@ class SecurityAndPrivacyPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - showEncryptionSection is false if the HS disabled encryption`() = runTest {
|
||||
val sessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { true })
|
||||
val sessionEnterpriseService = FakeSessionEnterpriseService(
|
||||
isEncryptionDisabledResult = { true },
|
||||
arePublicRoomsDisabledResult = { false },
|
||||
)
|
||||
val presenter = createSecurityAndPrivacyPresenter(
|
||||
sessionEnterpriseService = sessionEnterpriseService,
|
||||
)
|
||||
@@ -1060,6 +1065,42 @@ class SecurityAndPrivacyPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - showAnyoneOption is false if the HS disabled public rooms`() = runTest {
|
||||
val presenter = createSecurityAndPrivacyPresenter(
|
||||
sessionEnterpriseService = FakeSessionEnterpriseService(
|
||||
isEncryptionDisabledResult = { false },
|
||||
arePublicRoomsDisabledResult = { true },
|
||||
),
|
||||
)
|
||||
presenter.test {
|
||||
consumeItemsUntilPredicate { !it.showAnyoneOption }
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - showAnyoneOption is true if the HS disabled public rooms but the room is already public`() = runTest {
|
||||
val presenter = createSecurityAndPrivacyPresenter(
|
||||
room = FakeJoinedRoom(
|
||||
baseRoom = FakeBaseRoom(
|
||||
roomPermissions = roomPermissions(),
|
||||
getRoomVisibilityResult = { Result.success(RoomVisibility.Public) },
|
||||
initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Public)
|
||||
),
|
||||
),
|
||||
sessionEnterpriseService = FakeSessionEnterpriseService(
|
||||
isEncryptionDisabledResult = { false },
|
||||
arePublicRoomsDisabledResult = { true },
|
||||
),
|
||||
)
|
||||
presenter.test {
|
||||
val states = consumeItemsUntilTimeout()
|
||||
assertThat(states).isNotEmpty()
|
||||
assertThat(states.all { it.showAnyoneOption }).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
private fun roomPermissions(
|
||||
canChangeRoomAccess: Boolean = true,
|
||||
canChangeHistoryVisibility: Boolean = true,
|
||||
@@ -1100,6 +1141,7 @@ class SecurityAndPrivacyPresenterTest {
|
||||
spaceSelectionStateHolder: SpaceSelectionStateHolder = SpaceSelectionStateHolder(),
|
||||
sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService(
|
||||
isEncryptionDisabledResult = { false },
|
||||
arePublicRoomsDisabledResult = { false },
|
||||
)
|
||||
): SecurityAndPrivacyPresenter {
|
||||
return SecurityAndPrivacyPresenter(
|
||||
|
||||
@@ -44,7 +44,7 @@ appyx = "1.7.1"
|
||||
sqldelight = "2.4.0"
|
||||
wysiwyg = "2.42.1"
|
||||
telephoto = "0.19.0"
|
||||
maplibre-compose = "0.18.0"
|
||||
maplibre-compose = "0.19.0"
|
||||
|
||||
# Dependency analysis
|
||||
dependencyAnalysis = "3.19.2"
|
||||
@@ -64,7 +64,7 @@ ktlint = "1.8.0"
|
||||
androidx-test-ext-junit = "1.3.0"
|
||||
kover = "0.9.9"
|
||||
# The native MatrixRTC call component, https://github.com/element-hq/element-call-android
|
||||
element_call = "0.1.0-rc.6"
|
||||
element_call = "0.1.0-rc.7"
|
||||
|
||||
[libraries]
|
||||
# Project
|
||||
@@ -208,7 +208,7 @@ matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose",
|
||||
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
|
||||
sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
|
||||
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
|
||||
sqlcipher = "net.zetetic:sqlcipher-android:4.19.0"
|
||||
sqlcipher = "net.zetetic:sqlcipher-android:4.19.1"
|
||||
sqlite = "androidx.sqlite:sqlite-ktx:2.7.1"
|
||||
unifiedpush = "org.unifiedpush.android:connector:3.3.5"
|
||||
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
|
||||
@@ -227,7 +227,7 @@ color_picker = "io.mhssn:colorpicker:1.0.0"
|
||||
|
||||
# Analytics
|
||||
posthog = "com.posthog:posthog-android:3.60.7"
|
||||
sentry = "io.sentry:sentry-android:8.57.0"
|
||||
sentry = "io.sentry:sentry-android:8.59.0"
|
||||
# main branch can be tested replacing the version with main-SNAPSHOT
|
||||
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.38.0"
|
||||
|
||||
@@ -239,7 +239,7 @@ sigpwned_emoji4j = "com.sigpwned:emoji4j-core:16.0.0"
|
||||
metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro" }
|
||||
|
||||
# Element Call
|
||||
element_call_embedded = "io.element.android:element-call-embedded:0.26.0"
|
||||
element_call_embedded = "io.element.android:element-call-embedded:0.27.0"
|
||||
# The native MatrixRTC call component. Versions come from the BOM, so every artifact moves together.
|
||||
element_call_bom = { module = "io.element.android:element-call-bom", version.ref = "element_call" }
|
||||
element_call_api = { module = "io.element.android:element-call-api" }
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+2
-2
@@ -1,7 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
|
||||
distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
|
||||
+48
-32
@@ -8,26 +8,32 @@
|
||||
|
||||
package io.element.android.libraries.designsystem.atomic.atoms
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.plus
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.coerceAtLeast
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.SemanticColors
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.toDp
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
|
||||
private const val MAX_COUNT = 99
|
||||
@@ -54,7 +60,7 @@ fun CounterAtom(
|
||||
modifier: Modifier = Modifier,
|
||||
containerColor: Color? = null,
|
||||
contentColor: Color? = null,
|
||||
contentPadding: PaddingValues = PaddingValues.Zero,
|
||||
contentPadding: PaddingValues = PaddingValues(vertical = 0.5.dp),
|
||||
textStyle: TextStyle = CounterAtomDefaults.textStyle,
|
||||
dotSize: Dp = 12.0.dp,
|
||||
isCritical: Boolean = false,
|
||||
@@ -65,40 +71,50 @@ fun CounterAtom(
|
||||
else -> MAX_COUNT_STRING
|
||||
}
|
||||
|
||||
val minSingleDigitSize = if (countAsText?.length == 1) {
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
// Measure the maximum count string size
|
||||
val textLayoutResult = textMeasurer.measure(
|
||||
text = countAsText,
|
||||
val density = LocalDensity.current
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val minTextSize = remember(density.density, textStyle) {
|
||||
val result = textMeasurer.measure(
|
||||
text = MAX_COUNT_STRING,
|
||||
style = textStyle
|
||||
)
|
||||
val textSize = textLayoutResult.size
|
||||
maxOf(textSize.width, textSize.height)
|
||||
with(density) {
|
||||
minOf(result.size.width, result.size.height).toDp()
|
||||
}
|
||||
}
|
||||
val sizeModifier = if (countAsText?.length == 1) {
|
||||
Modifier.size(minTextSize)
|
||||
} else {
|
||||
0
|
||||
Modifier.sizeIn(minHeight = minTextSize)
|
||||
}
|
||||
|
||||
androidx.compose.material3.Badge(
|
||||
modifier = modifier.then(
|
||||
if (countAsText == null) Modifier.sizeIn(minWidth = dotSize, minHeight = dotSize) else Modifier
|
||||
),
|
||||
containerColor = containerColor ?: if (isCritical) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconAccentPrimary,
|
||||
contentColor = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
|
||||
content = countAsText?.let {
|
||||
@Composable {
|
||||
Text(
|
||||
modifier = Modifier.sizeIn(
|
||||
minWidth = (minSingleDigitSize.toDp() - 8.dp).coerceAtLeast(0.dp),
|
||||
minHeight = minSingleDigitSize.toDp()
|
||||
).padding(contentPadding),
|
||||
text = countAsText,
|
||||
style = textStyle,
|
||||
color = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
val extraPadding = if (countAsText?.length == 1) {
|
||||
contentPadding
|
||||
} else {
|
||||
contentPadding + PaddingValues(horizontal = 4.dp)
|
||||
}
|
||||
val backgroundColor = containerColor ?: if (isCritical) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconAccentPrimary
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(100))
|
||||
.background(backgroundColor)
|
||||
.then(
|
||||
if (countAsText == null) Modifier.size(dotSize) else Modifier
|
||||
)
|
||||
) {
|
||||
countAsText?.let {
|
||||
Text(
|
||||
modifier = sizeModifier
|
||||
.align(Alignment.Center)
|
||||
.padding(extraPadding),
|
||||
text = countAsText,
|
||||
style = textStyle,
|
||||
color = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object CounterAtomDefaults {
|
||||
@@ -112,7 +128,7 @@ internal fun CounterAtomPreview() = ElementPreview {
|
||||
Row(horizontalArrangement = spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
CounterAtom(count = 0)
|
||||
CounterAtom(count = 4)
|
||||
CounterAtom(count = 99)
|
||||
CounterAtom(count = 10)
|
||||
CounterAtom(count = 100)
|
||||
CounterAtom(count = 4, isCritical = true)
|
||||
}
|
||||
|
||||
+29
-23
@@ -13,7 +13,6 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -27,35 +26,42 @@ import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.unreadIndicator
|
||||
|
||||
/**
|
||||
* A small indicator to show the number of unread messages or notifications.
|
||||
*
|
||||
* @param count The number of unread messages or notifications. If null, the indicator will be hidden, if 0, a notification dot will be shown.
|
||||
* @param modifier The modifier to be applied to the indicator.
|
||||
* @param dotIndicatorSize The size of the dot indicator.
|
||||
* @param color The background color of the indicator.
|
||||
* @param contentDescription The content description for accessibility.
|
||||
* @param border The border to be applied to the indicator.
|
||||
* @param contentPadding The padding inside the indicator.
|
||||
*/
|
||||
@Composable
|
||||
fun UnreadIndicatorAtom(
|
||||
count: Long?,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 12.dp,
|
||||
count: Long? = null,
|
||||
color: Color = ElementTheme.colors.unreadIndicator,
|
||||
isVisible: Boolean = true,
|
||||
dotIndicatorSize: Dp = 12.dp,
|
||||
color: Color = ElementTheme.colors.iconAccentPrimary,
|
||||
contentDescription: String? = null,
|
||||
border: BorderStroke? = null,
|
||||
contentPadding: PaddingValues = PaddingValues.Zero,
|
||||
) {
|
||||
when {
|
||||
!isVisible -> Spacer(modifier = modifier.size(size))
|
||||
else -> count?.let {
|
||||
CounterAtom(
|
||||
count = count.toInt(),
|
||||
modifier = modifier
|
||||
.semantics {
|
||||
contentDescription?.let { this.contentDescription = it }
|
||||
}
|
||||
.then(if (border != null) Modifier.border(border, RoundedCornerShape(percent = 50)) else Modifier),
|
||||
containerColor = color,
|
||||
contentColor = ElementTheme.colors.bgCanvasDefault,
|
||||
textStyle = ElementTheme.typography.fontBodySmMedium,
|
||||
contentPadding = contentPadding,
|
||||
)
|
||||
}
|
||||
count?.let {
|
||||
CounterAtom(
|
||||
count = count.toInt(),
|
||||
modifier = modifier
|
||||
.semantics {
|
||||
contentDescription?.let { this.contentDescription = it }
|
||||
}
|
||||
.then(if (count == 0L) Modifier.size(dotIndicatorSize) else Modifier)
|
||||
.then(if (border != null) Modifier.border(border, RoundedCornerShape(percent = 100)) else Modifier),
|
||||
containerColor = color,
|
||||
contentColor = ElementTheme.colors.bgCanvasDefault,
|
||||
textStyle = ElementTheme.typography.fontBodySmMedium,
|
||||
contentPadding = contentPadding,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +72,7 @@ internal fun UnreadIndicatorAtomPreview() = ElementPreview {
|
||||
UnreadIndicatorAtom(count = null)
|
||||
UnreadIndicatorAtom(count = 0)
|
||||
UnreadIndicatorAtom(count = 1)
|
||||
UnreadIndicatorAtom(count = 10)
|
||||
UnreadIndicatorAtom(count = 20)
|
||||
UnreadIndicatorAtom(count = 99)
|
||||
UnreadIndicatorAtom(count = 999)
|
||||
}
|
||||
|
||||
-4
@@ -32,9 +32,6 @@ val SemanticColors.roomListRoomMessage
|
||||
val SemanticColors.roomListRoomMessageDate
|
||||
get() = textSecondary
|
||||
|
||||
val SemanticColors.unreadIndicator
|
||||
get() = iconAccentTertiary
|
||||
|
||||
val SemanticColors.placeholderBackground
|
||||
get() = bgSubtleSecondary
|
||||
|
||||
@@ -83,7 +80,6 @@ internal fun ColorAliasesPreview() = ElementPreview {
|
||||
"roomListRoomName" to ElementTheme.colors.roomListRoomName,
|
||||
"roomListRoomMessage" to ElementTheme.colors.roomListRoomMessage,
|
||||
"roomListRoomMessageDate" to ElementTheme.colors.roomListRoomMessageDate,
|
||||
"unreadIndicator" to ElementTheme.colors.unreadIndicator,
|
||||
"placeholderBackground" to ElementTheme.colors.placeholderBackground,
|
||||
"messageFromMeBackground" to ElementTheme.colors.messageFromMeBackground,
|
||||
"messageFromOtherBackground" to ElementTheme.colors.messageFromOtherBackground,
|
||||
|
||||
+3
-5
@@ -36,7 +36,7 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.CounterAtom
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
|
||||
import io.element.android.libraries.designsystem.components.tooltip.PlainTooltip
|
||||
import io.element.android.libraries.designsystem.components.tooltip.TooltipBox
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
@@ -140,12 +140,10 @@ fun HorizontalFloatingToolbarItem(
|
||||
BadgedBox(
|
||||
badge = {
|
||||
counter?.let {
|
||||
CounterAtom(
|
||||
count = it,
|
||||
UnreadIndicatorAtom(
|
||||
count = it.toLong(),
|
||||
// Tweak the offset so it's centered on the icon
|
||||
modifier = Modifier.offset(x = (-16).dp, y = 10.dp),
|
||||
contentPadding = PaddingValues(vertical = 0.5.dp),
|
||||
textStyle = ElementTheme.typography.fontBodySmMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.utils
|
||||
|
||||
import androidx.compose.ui.input.key.Key
|
||||
|
||||
/**
|
||||
* Keys which can be used to activate (i.e. click) the component having the keyboard focus.
|
||||
*/
|
||||
val activationKeys = setOf(
|
||||
Key.Enter,
|
||||
Key.NumPadEnter,
|
||||
Key.DirectionCenter,
|
||||
Key.Spacebar,
|
||||
)
|
||||
+8
@@ -154,6 +154,14 @@ enum class FeatureFlags(
|
||||
defaultValue = { false },
|
||||
isFinished = false,
|
||||
),
|
||||
MessageMultiSelect(
|
||||
key = "feature.message_multi_select",
|
||||
title = "Multi-select messages",
|
||||
description = "The Forward action enters a selection mode, to pick and forward up to 10 messages at once.",
|
||||
defaultValue = { false },
|
||||
isFinished = false,
|
||||
isInLabs = false,
|
||||
),
|
||||
NewTimelineEventRenderer(
|
||||
key = "feature.new_timeline_event_renderer",
|
||||
title = "New timeline event renderer",
|
||||
|
||||
+22
-4
@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
import io.element.android.libraries.matrix.api.room.IntentionalMention
|
||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.SendTarget
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
||||
@@ -316,12 +317,13 @@ interface Timeline : AutoCloseable {
|
||||
suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Boolean>
|
||||
|
||||
/**
|
||||
* Forwards the content of an event to other rooms, as new messages sent by the current user.
|
||||
* Forwards the content of some events to other rooms, as new messages sent by the current user.
|
||||
* The events are forwarded in the order of [eventIds].
|
||||
*
|
||||
* @param eventId the event whose content is forwarded.
|
||||
* @param roomIds the rooms to forward it to.
|
||||
* @param eventIds the events whose content is forwarded.
|
||||
* @param roomIds the rooms to forward them to.
|
||||
*/
|
||||
suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit>
|
||||
suspend fun forwardEvents(eventIds: List<EventId>, roomIds: List<RoomId>): Result<Unit>
|
||||
|
||||
/**
|
||||
* Cancels an event that is still waiting in the send queue, by redacting its local echo.
|
||||
@@ -422,4 +424,20 @@ interface Timeline : AutoCloseable {
|
||||
* Get the latest event id of the timeline, or `null` when it holds no event yet.
|
||||
*/
|
||||
suspend fun getLatestEventId(): Result<EventId?>
|
||||
|
||||
/**
|
||||
* Retries sending an event that failed to send, by putting it back in the send queue.
|
||||
*
|
||||
* @param eventOrTransactionId the event or transaction ID of the event to retry sending.
|
||||
* @param sendTarget the target action of the send operation.
|
||||
*/
|
||||
suspend fun retrySend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result<Boolean>
|
||||
|
||||
/**
|
||||
* Aborts sending an event that failed to send, by removing it from the send queue.
|
||||
*
|
||||
* @param eventOrTransactionId the event or transaction ID of the event to abort sending.
|
||||
* @param sendTarget the target action of the send operation.
|
||||
*/
|
||||
suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result<Boolean>
|
||||
}
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.timeline.item
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
/**
|
||||
* Represents the target action of a send operation in a timeline.
|
||||
*/
|
||||
@Immutable
|
||||
sealed interface SendTarget {
|
||||
/** Send a new event. */
|
||||
data object Event : SendTarget
|
||||
|
||||
/** Edit an existing event. */
|
||||
data object Edit : SendTarget
|
||||
|
||||
/** Redact an existing event. */
|
||||
data object Redaction : SendTarget
|
||||
|
||||
/** React to an existing event with the given key. */
|
||||
data class Reaction(val key: String) : SendTarget
|
||||
}
|
||||
+2
@@ -24,6 +24,8 @@ data class EventTimelineItem(
|
||||
val isOwn: Boolean,
|
||||
val isRemote: Boolean,
|
||||
val localSendState: LocalEventSendState?,
|
||||
val editSendState: LocalEventSendState?,
|
||||
val redactionSendState: LocalEventSendState?,
|
||||
val reactions: ImmutableList<EventReaction>,
|
||||
val receipts: ImmutableList<Receipt>,
|
||||
val sender: UserId,
|
||||
|
||||
+1
@@ -877,6 +877,7 @@ class RustMatrixClient(
|
||||
override fun sendQueueDisabledFlow(): Flow<RoomId> = mxCallbackFlow {
|
||||
innerClient.subscribeToSendQueueStatus(object : SendQueueRoomErrorListener {
|
||||
override fun onError(roomId: String, error: ClientException) {
|
||||
Timber.e(error, "Send queue error for room $roomId")
|
||||
trySend(RoomId(roomId))
|
||||
}
|
||||
})
|
||||
|
||||
+19
-17
@@ -14,7 +14,6 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.ForwardEventException
|
||||
import io.element.android.libraries.matrix.impl.roomlist.roomOrNull
|
||||
import io.element.android.libraries.matrix.impl.timeline.runWithTimelineListenerRegistered
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.matrix.rustcomponents.sdk.MsgLikeKind
|
||||
import org.matrix.rustcomponents.sdk.RoomListService
|
||||
@@ -31,33 +30,36 @@ class RoomContentForwarder(
|
||||
private val roomListService: RoomListService,
|
||||
) {
|
||||
/**
|
||||
* Forwards the event with the given [eventId] from the [fromTimeline] to the given [toRoomIds].
|
||||
* @param fromTimeline the room to forward the event from
|
||||
* @param eventId the id of the event to forward
|
||||
* @param toRoomIds the ids of the rooms to forward the event to
|
||||
* @param timeoutMs the maximum time in milliseconds to wait for the event to be sent to a room
|
||||
* Forwards the events with the given [eventIds] from the [fromTimeline] to the given [toRoomIds].
|
||||
* The events are sent to each room in the order they are provided in [eventIds], it is up to the caller to sort them.
|
||||
* @param fromTimeline the room to forward the events from
|
||||
* @param eventIds the ids of the events to forward, in the order they must be sent
|
||||
* @param toRoomIds the ids of the rooms to forward the events to
|
||||
* @param timeoutMs the maximum time in milliseconds to wait for an event to be sent to a room
|
||||
*/
|
||||
suspend fun forward(
|
||||
fromTimeline: Timeline,
|
||||
eventId: EventId,
|
||||
eventIds: List<EventId>,
|
||||
toRoomIds: List<RoomId>,
|
||||
timeoutMs: Long = 5000L
|
||||
) {
|
||||
val messageLikeContent = (fromTimeline.getEventTimelineItemByEventId(eventId.value).content as? TimelineItemContent.MsgLike)?.content
|
||||
?: throw ForwardEventException(toRoomIds)
|
||||
|
||||
val content = (messageLikeContent.kind as? MsgLikeKind.Message)?.content
|
||||
?: throw ForwardEventException(toRoomIds)
|
||||
|
||||
val contents = eventIds.map { eventId ->
|
||||
val timelineEvent = fromTimeline.getEventTimelineItemByEventId(eventId.value)
|
||||
val content = timelineEvent.content as? TimelineItemContent.MsgLike ?: throw ForwardEventException(toRoomIds)
|
||||
val message = content.content.kind as? MsgLikeKind.Message ?: throw ForwardEventException(toRoomIds)
|
||||
contentWithoutRelationFromMessage(message.content)
|
||||
}
|
||||
val targetRooms = toRoomIds.toSet().mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) }
|
||||
val failedForwardingTo = mutableSetOf<RoomId>()
|
||||
targetRooms.parallelMap { room ->
|
||||
room.use { targetRoom ->
|
||||
runCatchingExceptions {
|
||||
// Sending a message requires a registered timeline listener
|
||||
targetRoom.timeline().runWithTimelineListenerRegistered {
|
||||
withTimeout(timeoutMs.milliseconds) {
|
||||
targetRoom.timeline().send(contentWithoutRelationFromMessage(content))
|
||||
targetRoom.timeline().use { timeline ->
|
||||
// Send the contents sequentially, so that they are received in the same order
|
||||
contents.forEach { content ->
|
||||
withTimeout(timeoutMs.milliseconds) {
|
||||
timeline.send(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-4
@@ -57,7 +57,7 @@ fun RoomListInterface.loadingStateFlow(): Flow<RoomListLoadingState> =
|
||||
internal fun RoomListInterface.entriesFlow(
|
||||
pageSize: Int,
|
||||
initialFilterKind: RoomListEntriesDynamicFilterKind,
|
||||
onControllerCreated: (RoomListDynamicEntriesController) -> Unit,
|
||||
onControllerCreated: suspend (RoomListDynamicEntriesController) -> Unit,
|
||||
): Flow<List<RoomListEntriesUpdate>> =
|
||||
callbackFlow {
|
||||
val listener = object : RoomListEntriesListener {
|
||||
@@ -70,9 +70,13 @@ internal fun RoomListInterface.entriesFlow(
|
||||
listener = listener,
|
||||
)
|
||||
val controller = result.controller()
|
||||
controller.setFilter(initialFilterKind)
|
||||
onControllerCreated(controller)
|
||||
awaitClose {
|
||||
// Use try/finally rather than awaitClose's block, so that the resources are released even if
|
||||
// onControllerCreated is cancelled or throws before awaitClose is reached
|
||||
try {
|
||||
controller.setFilter(initialFilterKind)
|
||||
onControllerCreated(controller)
|
||||
awaitClose()
|
||||
} finally {
|
||||
result.entriesStream().cancelAndDestroy()
|
||||
controller.destroy()
|
||||
result.destroy()
|
||||
|
||||
+9
-10
@@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController
|
||||
import org.matrix.rustcomponents.sdk.RoomListLoadingState
|
||||
import org.matrix.rustcomponents.sdk.RoomListService
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
@@ -49,7 +48,13 @@ internal class RoomListFactory(
|
||||
val summariesFlow = MutableSharedFlow<List<RoomSummary>>(replay = 1, extraBufferCapacity = 1)
|
||||
val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryFactory, analyticsService)
|
||||
var innerRoomList: InnerRoomList? = null
|
||||
var dynamicController: RoomListDynamicEntriesController? = null
|
||||
val dynamicRoomList = RustDynamicRoomList(
|
||||
summaries = summariesFlow,
|
||||
loadingState = loadingStateFlow,
|
||||
processor = processor,
|
||||
pageSize = pageSize,
|
||||
initialFilter = initialFilter,
|
||||
)
|
||||
|
||||
val firstRoomsTransaction = analyticsService.startTransaction("Load first set of rooms", "innerRoomList.entriesFlow")
|
||||
|
||||
@@ -60,7 +65,7 @@ internal class RoomListFactory(
|
||||
pageSize = pageSize,
|
||||
initialFilterKind = RoomListFilterMapper.toRustFilter(initialFilter),
|
||||
onControllerCreated = { controller ->
|
||||
dynamicController = controller
|
||||
dynamicRoomList.onControllerCreated(controller, initialFilter)
|
||||
}
|
||||
).onEach { update ->
|
||||
if (!firstRoomsTransaction.isFinished()) {
|
||||
@@ -80,13 +85,7 @@ internal class RoomListFactory(
|
||||
}.invokeOnCompletion {
|
||||
innerRoomList?.destroy()
|
||||
}
|
||||
return RustDynamicRoomList(
|
||||
summaries = summariesFlow,
|
||||
loadingState = loadingStateFlow,
|
||||
processor = processor,
|
||||
pageSize = pageSize,
|
||||
dynamicController = { dynamicController }
|
||||
)
|
||||
return dynamicRoomList
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+32
-11
@@ -24,37 +24,58 @@ internal class RustDynamicRoomList(
|
||||
override val loadingState: MutableStateFlow<RoomList.LoadingState>,
|
||||
private val processor: RoomSummaryListProcessor,
|
||||
override val pageSize: Int,
|
||||
private val dynamicController: () -> RoomListDynamicEntriesController?,
|
||||
initialFilter: RoomListFilter,
|
||||
private val addPagesCount: Int = DEFAULT_ADD_PAGES_COUNT
|
||||
) : DynamicRoomList {
|
||||
private val mutex = Mutex()
|
||||
|
||||
// The controller is created asynchronously, once the entries of the room list are observed
|
||||
private var dynamicController: RoomListDynamicEntriesController? = null
|
||||
|
||||
// The last requested filter, so that it can be applied when the controller is created
|
||||
private var currentFilter: RoomListFilter = initialFilter
|
||||
|
||||
/**
|
||||
* To be called when the controller is created. The controller already has the initial filter set, so apply the filter
|
||||
* only if it has been updated in the meantime.
|
||||
*/
|
||||
suspend fun onControllerCreated(controller: RoomListDynamicEntriesController, initialFilter: RoomListFilter) {
|
||||
mutex.withLock {
|
||||
dynamicController = controller
|
||||
if (currentFilter != initialFilter) {
|
||||
controller.applyFilter(currentFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun rebuildSummaries() {
|
||||
processor.rebuildRoomSummaries()
|
||||
}
|
||||
|
||||
override suspend fun updateFilter(filter: RoomListFilter) {
|
||||
mutex.withLock {
|
||||
dynamicController()?.let { controller ->
|
||||
// Reset pagination when filter changes
|
||||
controller.resetToOnePage()
|
||||
val rustFilter = RoomListFilterMapper.toRustFilter(filter)
|
||||
controller.setFilter(rustFilter)
|
||||
// Then preload some pages
|
||||
controller.addPages(addPagesCount)
|
||||
}
|
||||
currentFilter = filter
|
||||
dynamicController?.applyFilter(filter)
|
||||
}
|
||||
}
|
||||
|
||||
private fun RoomListDynamicEntriesController.applyFilter(filter: RoomListFilter) {
|
||||
// Reset pagination when filter changes
|
||||
resetToOnePage()
|
||||
setFilter(RoomListFilterMapper.toRustFilter(filter))
|
||||
// Then preload some pages
|
||||
addPages(addPagesCount)
|
||||
}
|
||||
|
||||
override suspend fun loadMore() {
|
||||
mutex.withLock {
|
||||
dynamicController()?.addPages(addPagesCount)
|
||||
dynamicController?.addPages(addPagesCount)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun reset() {
|
||||
mutex.withLock {
|
||||
dynamicController()?.resetToOnePage()
|
||||
dynamicController?.resetToOnePage()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-13
@@ -53,16 +53,3 @@ internal fun TimelineInterface.timelineDiffFlow(): Flow<List<TimelineDiff>> =
|
||||
}.catch {
|
||||
Timber.d(it, "timelineDiffFlow() failed")
|
||||
}.buffer(Channel.UNLIMITED)
|
||||
|
||||
internal suspend fun TimelineInterface.runWithTimelineListenerRegistered(action: suspend () -> Unit) {
|
||||
val result = addListener(NoOpTimelineListener)
|
||||
try {
|
||||
action()
|
||||
} finally {
|
||||
result.cancelAndDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
private object NoOpTimelineListener : TimelineListener {
|
||||
override fun onUpdate(diff: List<TimelineDiff>) = Unit
|
||||
}
|
||||
|
||||
+15
-2
@@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.timeline.MsgType
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
||||
import io.element.android.libraries.matrix.api.timeline.item.SendTarget
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||
import io.element.android.libraries.matrix.impl.media.GalleryMediaUploadHandlerImpl
|
||||
@@ -489,9 +490,9 @@ class RustTimeline(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = withContext(dispatcher) {
|
||||
override suspend fun forwardEvents(eventIds: List<EventId>, roomIds: List<RoomId>): Result<Unit> = withContext(dispatcher) {
|
||||
runCatchingExceptions {
|
||||
roomContentForwarder.forward(fromTimeline = inner, eventId = eventId, toRoomIds = roomIds)
|
||||
roomContentForwarder.forward(fromTimeline = inner, eventIds = eventIds, toRoomIds = roomIds)
|
||||
}.onFailure {
|
||||
Timber.tag(loggerTag).e(it)
|
||||
}
|
||||
@@ -697,6 +698,18 @@ class RustTimeline(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun retrySend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result<Boolean> = withContext(dispatcher) {
|
||||
runCatchingExceptions {
|
||||
inner.retrySend(eventOrTransactionId.toRustEventOrTransactionId(), sendTarget.map())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result<Boolean> = withContext(dispatcher) {
|
||||
runCatchingExceptions {
|
||||
inner.abortSend(eventOrTransactionId.toRustEventOrTransactionId(), sendTarget.map())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> = withContext(dispatcher) {
|
||||
runCatchingExceptions {
|
||||
inner.fetchDetailsForEvent(eventId.value)
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.timeline
|
||||
|
||||
import io.element.android.libraries.matrix.api.timeline.item.SendTarget
|
||||
|
||||
fun SendTarget.map() = when (this) {
|
||||
is SendTarget.Event -> org.matrix.rustcomponents.sdk.SendTarget.Event
|
||||
is SendTarget.Edit -> org.matrix.rustcomponents.sdk.SendTarget.Edit
|
||||
is SendTarget.Redaction -> org.matrix.rustcomponents.sdk.SendTarget.Redaction
|
||||
is SendTarget.Reaction -> org.matrix.rustcomponents.sdk.SendTarget.Reaction(key)
|
||||
}
|
||||
+3
-5
@@ -51,6 +51,8 @@ class EventTimelineItemMapper(
|
||||
isOwn = isOwn,
|
||||
isRemote = isRemote,
|
||||
localSendState = localSendState?.map(),
|
||||
editSendState = editSendState?.map(),
|
||||
redactionSendState = redactionSendState?.map(),
|
||||
reactions = reactions.map(),
|
||||
receipts = readReceipts.map(),
|
||||
sender = UserId(sender),
|
||||
@@ -113,11 +115,7 @@ fun RustEventSendState?.map(): LocalEventSendState? {
|
||||
)
|
||||
}
|
||||
is QueueWedgeError.GenericApiError -> {
|
||||
if (isRecoverable) {
|
||||
LocalEventSendState.Sending.Event
|
||||
} else {
|
||||
LocalEventSendState.Failed.Unknown(queueWedgeError.msg)
|
||||
}
|
||||
LocalEventSendState.Failed.Unknown(queueWedgeError.msg)
|
||||
}
|
||||
is QueueWedgeError.InvalidMimeType -> {
|
||||
LocalEventSendState.Failed.InvalidMimeType(queueWedgeError.mimeType)
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.fixtures.fakes
|
||||
|
||||
import org.matrix.rustcomponents.sdk.NoHandle
|
||||
import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController
|
||||
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
|
||||
|
||||
class FakeFfiRoomListDynamicEntriesController : RoomListDynamicEntriesController(NoHandle) {
|
||||
val filters = mutableListOf<RoomListEntriesDynamicFilterKind>()
|
||||
|
||||
override fun setFilter(kind: RoomListEntriesDynamicFilterKind): Boolean {
|
||||
filters.add(kind)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun resetToOnePage() = Unit
|
||||
|
||||
override fun addOnePage() = Unit
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.roomlist
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomList
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListDynamicEntriesController
|
||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListService
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class RustDynamicRoomListTest {
|
||||
@Test
|
||||
fun `updateFilter before the controller is created applies the filter when it is created`() = runTest {
|
||||
val sut = createRustDynamicRoomList()
|
||||
sut.updateFilter(RoomListFilter.NormalizedMatchRoomName("query"))
|
||||
val controller = FakeFfiRoomListDynamicEntriesController()
|
||||
sut.onControllerCreated(controller, initialFilter = RoomListFilter.all())
|
||||
assertThat(controller.filters).containsExactly(RoomListFilterMapper.toRustFilter(RoomListFilter.NormalizedMatchRoomName("query")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the filter is not updated, the controller filter is not changed when it is created`() = runTest {
|
||||
val sut = createRustDynamicRoomList()
|
||||
val controller = FakeFfiRoomListDynamicEntriesController()
|
||||
sut.onControllerCreated(controller, initialFilter = RoomListFilter.all())
|
||||
assertThat(controller.filters).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateFilter after the controller is created applies the filter`() = runTest {
|
||||
val sut = createRustDynamicRoomList()
|
||||
val controller = FakeFfiRoomListDynamicEntriesController()
|
||||
sut.onControllerCreated(controller, initialFilter = RoomListFilter.all())
|
||||
sut.updateFilter(RoomListFilter.NormalizedMatchRoomName("query"))
|
||||
assertThat(controller.filters).containsExactly(RoomListFilterMapper.toRustFilter(RoomListFilter.NormalizedMatchRoomName("query")))
|
||||
}
|
||||
|
||||
private fun TestScope.createRustDynamicRoomList(): RustDynamicRoomList {
|
||||
val summaries = MutableSharedFlow<List<RoomSummary>>(replay = 1)
|
||||
return RustDynamicRoomList(
|
||||
summaries = summaries,
|
||||
loadingState = MutableStateFlow(RoomList.LoadingState.NotLoaded),
|
||||
processor = RoomSummaryListProcessor(
|
||||
summaries,
|
||||
FakeFfiRoomListService(),
|
||||
coroutineContext = StandardTestDispatcher(testScheduler),
|
||||
roomSummaryFactory = RoomSummaryFactory(),
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
),
|
||||
pageSize = 10,
|
||||
initialFilter = RoomListFilter.all(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+15
-4
@@ -24,6 +24,7 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.MsgType
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.SendTarget
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
|
||||
@@ -60,7 +61,9 @@ class FakeTimeline(
|
||||
receiptType: ReceiptType,
|
||||
) -> Result<Unit> = { _, _ ->
|
||||
lambdaError()
|
||||
}
|
||||
},
|
||||
private val retrySendResult: (eventOrTransactionId: EventOrTransactionId) -> Result<Boolean> = { lambdaError() },
|
||||
private val abortSendResult: (eventOrTransactionId: EventOrTransactionId) -> Result<Boolean> = { lambdaError() },
|
||||
) : Timeline {
|
||||
var sendMessageLambda: (
|
||||
body: String,
|
||||
@@ -355,10 +358,10 @@ class FakeTimeline(
|
||||
)
|
||||
}
|
||||
|
||||
var forwardEventLambda: (eventId: EventId, roomIds: List<RoomId>) -> Result<Unit> = { _, _ -> lambdaError() }
|
||||
var forwardEventsLambda: (eventIds: List<EventId>, roomIds: List<RoomId>) -> Result<Unit> = { _, _ -> lambdaError() }
|
||||
|
||||
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = simulateLongTask {
|
||||
forwardEventLambda(eventId, roomIds)
|
||||
override suspend fun forwardEvents(eventIds: List<EventId>, roomIds: List<RoomId>): Result<Unit> = simulateLongTask {
|
||||
forwardEventsLambda(eventIds, roomIds)
|
||||
}
|
||||
|
||||
var createPollLambda: (
|
||||
@@ -477,6 +480,14 @@ class FakeTimeline(
|
||||
return getLatestEventIdResult()
|
||||
}
|
||||
|
||||
override suspend fun retrySend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result<Boolean> {
|
||||
return retrySendResult(eventOrTransactionId)
|
||||
}
|
||||
|
||||
override suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result<Boolean> {
|
||||
return abortSendResult(eventOrTransactionId)
|
||||
}
|
||||
|
||||
var closeCounter = 0
|
||||
private set
|
||||
|
||||
|
||||
+4
@@ -52,6 +52,8 @@ fun anEventTimelineItem(
|
||||
isOwn: Boolean = false,
|
||||
isRemote: Boolean = false,
|
||||
localSendState: LocalEventSendState? = null,
|
||||
editSendState: LocalEventSendState? = null,
|
||||
redactionSendState: LocalEventSendState? = null,
|
||||
reactions: ImmutableList<EventReaction> = persistentListOf(),
|
||||
receipts: ImmutableList<Receipt> = persistentListOf(),
|
||||
sender: UserId = A_USER_ID,
|
||||
@@ -69,6 +71,8 @@ fun anEventTimelineItem(
|
||||
isOwn = isOwn,
|
||||
isRemote = isRemote,
|
||||
localSendState = localSendState,
|
||||
editSendState = editSendState,
|
||||
redactionSendState = redactionSendState,
|
||||
reactions = reactions,
|
||||
receipts = receipts,
|
||||
sender = sender,
|
||||
|
||||
+32
@@ -9,6 +9,8 @@
|
||||
package io.element.android.libraries.matrix.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.indication
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -25,11 +27,16 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
@@ -48,6 +55,7 @@ import io.element.android.libraries.designsystem.text.toPx
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.utils.activationKeys
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
@@ -62,9 +70,31 @@ fun SelectedItem(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val actionRemove = stringResource(id = CommonStrings.action_remove)
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(avatarData.size.dp)
|
||||
.then(
|
||||
if (canRemove) {
|
||||
// Let the whole item get the keyboard focus, so that the focus indicator is drawn around it.
|
||||
Modifier
|
||||
.onKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyUp && event.key in activationKeys) {
|
||||
onRemoveClick()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
.indication(
|
||||
interactionSource = interactionSource,
|
||||
indication = ripple(),
|
||||
)
|
||||
.focusable(interactionSource = interactionSource)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.clearAndSetSemantics {
|
||||
contentDescription = a11yContentDescription
|
||||
if (canRemove) {
|
||||
@@ -130,6 +160,8 @@ fun SelectedItem(
|
||||
.clip(CircleShape)
|
||||
.size(20.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
// The whole item is focusable, not the remove button
|
||||
.focusProperties { canFocus = false }
|
||||
.clickable(
|
||||
indication = ripple(),
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
|
||||
+1
-5
@@ -44,7 +44,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.unreadIndicator
|
||||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceRoomVisibility
|
||||
@@ -168,10 +167,7 @@ private fun NameAndIndicatorRow(
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (showIndicator) {
|
||||
UnreadIndicatorAtom(
|
||||
color = ElementTheme.colors.unreadIndicator,
|
||||
count = 0,
|
||||
)
|
||||
UnreadIndicatorAtom(count = 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -13,7 +13,6 @@ import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
|
||||
|
||||
sealed interface RoomSelectEvent {
|
||||
data class ToggleSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvent
|
||||
data object ToggleSearchActive : RoomSelectEvent
|
||||
data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvent
|
||||
data object ToggleAccountListExpanded : RoomSelectEvent
|
||||
data class SelectAccount(val sessionId: SessionId) : RoomSelectEvent
|
||||
|
||||
+2
-7
@@ -8,7 +8,6 @@
|
||||
|
||||
package io.element.android.libraries.roomselect.impl
|
||||
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -67,7 +66,6 @@ class RoomSelectPresenter(
|
||||
override fun present(): RoomSelectState {
|
||||
var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) }
|
||||
val queryState = rememberTextFieldState()
|
||||
var isSearchActive by remember { mutableStateOf(false) }
|
||||
var isAccountListExpanded by remember { mutableStateOf(false) }
|
||||
// Null until the sessions are loaded
|
||||
val sessions by remember { sessionStore.sessionsFlow() }.collectAsState(initial = null)
|
||||
@@ -157,7 +155,7 @@ class RoomSelectPresenter(
|
||||
derivedStateOf {
|
||||
when {
|
||||
roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList())
|
||||
isSearchActive -> SearchBarResultState.NoResultsFound
|
||||
queryState.text.isNotBlank() -> SearchBarResultState.NoResultsFound
|
||||
else -> SearchBarResultState.Initial
|
||||
}
|
||||
}
|
||||
@@ -173,13 +171,11 @@ class RoomSelectPresenter(
|
||||
selectedRooms.adding(event.room)
|
||||
}
|
||||
}
|
||||
RoomSelectEvent.ToggleSearchActive -> isSearchActive = !isSearchActive
|
||||
RoomSelectEvent.ToggleAccountListExpanded -> isAccountListExpanded = !isAccountListExpanded
|
||||
is RoomSelectEvent.SelectAccount -> {
|
||||
selectedSessionId = event.sessionId
|
||||
// The selected rooms and the search query are specific to the session
|
||||
// The selected rooms are specific to the session. The search query is kept, and applied to the rooms of the new session.
|
||||
selectedRooms = persistentListOf()
|
||||
queryState.clearText()
|
||||
isAccountListExpanded = false
|
||||
}
|
||||
is RoomSelectEvent.UpdateVisibleRange -> coroutineScope.launch {
|
||||
@@ -193,7 +189,6 @@ class RoomSelectPresenter(
|
||||
maxNumberOfRooms = maxNumberOfRooms,
|
||||
resultState = searchResults,
|
||||
searchQuery = queryState,
|
||||
isSearchActive = isSearchActive,
|
||||
selectedRooms = selectedRooms,
|
||||
selectedAccount = selectedAccount,
|
||||
hasRoomListError = hasRoomListError,
|
||||
|
||||
-1
@@ -20,7 +20,6 @@ data class RoomSelectState(
|
||||
val maxNumberOfRooms: Int,
|
||||
val resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>>,
|
||||
val searchQuery: TextFieldState,
|
||||
val isSearchActive: Boolean,
|
||||
val selectedRooms: ImmutableList<SelectRoomInfo>,
|
||||
val selectedAccount: MatrixUser,
|
||||
/**
|
||||
|
||||
+1
-5
@@ -26,17 +26,15 @@ open class RoomSelectStatePreviewParam : PreviewParameterProvider<RoomSelectStat
|
||||
override val values: Sequence<RoomSelectState>
|
||||
get() = sequenceOf(
|
||||
aRoomSelectState(),
|
||||
aRoomSelectState(searchQuery = "Test", isSearchActive = true),
|
||||
aRoomSelectState(searchQuery = "Test", resultState = SearchBarResultState.NoResultsFound),
|
||||
aRoomSelectState(resultState = SearchBarResultState.Results(aRoomSelectRoomList())),
|
||||
aRoomSelectState(
|
||||
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
|
||||
searchQuery = "Test",
|
||||
isSearchActive = true,
|
||||
),
|
||||
aRoomSelectState(
|
||||
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
|
||||
searchQuery = "Test",
|
||||
isSearchActive = true,
|
||||
selectedRooms = aRoomSelectRoomList().subList(0, 1),
|
||||
),
|
||||
aRoomSelectState(
|
||||
@@ -76,7 +74,6 @@ internal fun aRoomSelectState(
|
||||
maxNumberOfRooms: Int = 10,
|
||||
resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>> = SearchBarResultState.Initial,
|
||||
searchQuery: String = "",
|
||||
isSearchActive: Boolean = false,
|
||||
selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(),
|
||||
selectedAccount: MatrixUser = MatrixUser(UserId("@alice:example.org")),
|
||||
hasRoomListError: Boolean = false,
|
||||
@@ -88,7 +85,6 @@ internal fun aRoomSelectState(
|
||||
maxNumberOfRooms = maxNumberOfRooms,
|
||||
resultState = resultState,
|
||||
searchQuery = TextFieldState(initialText = searchQuery),
|
||||
isSearchActive = isSearchActive,
|
||||
selectedRooms = selectedRooms,
|
||||
selectedAccount = selectedAccount,
|
||||
hasRoomListError = hasRoomListError,
|
||||
|
||||
+105
-67
@@ -10,6 +10,7 @@ package io.element.android.libraries.roomselect.impl
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
@@ -24,18 +25,23 @@ import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -46,9 +52,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -70,8 +78,8 @@ import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBar
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchField
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
@@ -123,10 +131,8 @@ fun RoomSelectView(
|
||||
}
|
||||
|
||||
var canHandleBack by remember { mutableStateOf(true) }
|
||||
fun onBackButton(state: RoomSelectState) {
|
||||
if (state.isSearchActive) {
|
||||
state.eventSink(RoomSelectEvent.ToggleSearchActive)
|
||||
} else if (canHandleBack) {
|
||||
fun onBackButton() {
|
||||
if (canHandleBack) {
|
||||
canHandleBack = false
|
||||
onDismiss()
|
||||
}
|
||||
@@ -134,16 +140,34 @@ fun RoomSelectView(
|
||||
|
||||
BackHandler(
|
||||
enabled = canHandleBack,
|
||||
onBack = { onBackButton(state) }
|
||||
onBack = ::onBackButton,
|
||||
)
|
||||
|
||||
val searchQuery = state.searchQuery.text.toString()
|
||||
// Use a new list state when the selected account changes, so that the list of rooms is scrolled to the top
|
||||
val lazyListState = key(state.selectedAccount.userId) { rememberLazyListState() }
|
||||
// Scroll the list of rooms to the top when the search query changes
|
||||
LaunchedEffect(lazyListState, searchQuery) {
|
||||
lazyListState.scrollToItem(0)
|
||||
}
|
||||
// Hide the account switch section when the room list is scrolled up, and show it again as soon as it is scrolled down.
|
||||
// Use a new state when the selected account changes, so that the section is fully displayed again.
|
||||
val accountSwitchScrollBehavior = key(state.selectedAccount.userId) {
|
||||
TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState())
|
||||
}
|
||||
// Collapse the account switch section when the user starts searching. It can be displayed again by scrolling down.
|
||||
val isSearching = searchQuery.isNotBlank()
|
||||
LaunchedEffect(isSearching) {
|
||||
if (isSearching) {
|
||||
val topAppBarState = accountSwitchScrollBehavior.state
|
||||
animate(
|
||||
initialValue = topAppBarState.heightOffset,
|
||||
targetValue = topAppBarState.heightOffsetLimit,
|
||||
) { value, _ ->
|
||||
topAppBarState.heightOffset = value
|
||||
}
|
||||
}
|
||||
}
|
||||
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
|
||||
state.eventSink(RoomSelectEvent.UpdateVisibleRange(visibleRange))
|
||||
}
|
||||
@@ -153,13 +177,13 @@ fun RoomSelectView(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
titleStr = when (state.mode) {
|
||||
RoomSelectMode.Forward -> stringResource(CommonStrings.common_forward_message)
|
||||
RoomSelectMode.Forward -> stringResource(CommonStrings.common_forward_to)
|
||||
RoomSelectMode.Share -> stringResource(CommonStrings.common_send_to)
|
||||
},
|
||||
navigationIcon = {
|
||||
BackButton(
|
||||
enabled = canHandleBack,
|
||||
onClick = { onBackButton(state) }
|
||||
onClick = ::onBackButton,
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
@@ -177,82 +201,62 @@ fun RoomSelectView(
|
||||
Modifier
|
||||
.padding(paddingValues)
|
||||
.consumeWindowInsets(paddingValues)
|
||||
.nestedScroll(accountSwitchScrollBehavior.nestedScrollConnection)
|
||||
.then(
|
||||
// Only when the account switch section is displayed, else its unknown height would consume all the scroll events
|
||||
if (state.showAccountSwitch) {
|
||||
Modifier.nestedScroll(accountSwitchScrollBehavior.nestedScrollConnection)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
) {
|
||||
SearchBar(
|
||||
SearchField(
|
||||
state = state.searchQuery,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
WindowInsets.safeDrawing
|
||||
.only(WindowInsetsSides.Horizontal)
|
||||
.asPaddingValues()
|
||||
),
|
||||
placeHolderTitle = stringResource(CommonStrings.action_search),
|
||||
queryState = state.searchQuery,
|
||||
active = state.isSearchActive,
|
||||
onActiveChange = { state.eventSink(RoomSelectEvent.ToggleSearchActive) },
|
||||
resultState = state.resultState,
|
||||
showBackButton = false,
|
||||
) { summaries ->
|
||||
if (summaries.isNotEmpty()) {
|
||||
ListSectionHeader(
|
||||
title = stringResource(CommonStrings.common_header_rooms),
|
||||
hasDivider = false,
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
placeholder = stringResource(CommonStrings.action_search),
|
||||
)
|
||||
if (state.showAccountSwitch) {
|
||||
// Keep this space outside the collapsing section, so that the section does not touch the search field when it is collapsing
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TopAppBarScrollBehaviorLayout(scrollBehavior = accountSwitchScrollBehavior) {
|
||||
AccountSwitchSection(
|
||||
selectedAccount = state.selectedAccount,
|
||||
otherAccounts = state.otherAccounts,
|
||||
isExpanded = state.isAccountListExpanded,
|
||||
onToggleExpand = { state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) },
|
||||
onSelectAccount = { state.eventSink(RoomSelectEvent.SelectAccount(it)) },
|
||||
)
|
||||
}
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
contentPadding = lazyColumnContentPadding,
|
||||
) {
|
||||
item {
|
||||
SelectedRoomsHelper(
|
||||
selectedRooms = state.selectedRooms,
|
||||
showVerticalSpace = false,
|
||||
)
|
||||
}
|
||||
items(summaries, key = { it.roomId.value }) { roomSummary ->
|
||||
RoomSummaryView(
|
||||
roomSummary,
|
||||
isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId },
|
||||
onSelection = { roomSummary ->
|
||||
state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary))
|
||||
},
|
||||
canBeSelected = state.canSelectMoreRooms,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.isSearchActive) {
|
||||
if (state.showAccountSwitch) {
|
||||
// Keep this space outside the collapsing section, so that the section does not touch the search bar when it is collapsing
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TopAppBarScrollBehaviorLayout(scrollBehavior = accountSwitchScrollBehavior) {
|
||||
AccountSwitchSection(
|
||||
selectedAccount = state.selectedAccount,
|
||||
otherAccounts = state.otherAccounts,
|
||||
isExpanded = state.isAccountListExpanded,
|
||||
onToggleExpand = { state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) },
|
||||
onSelectAccount = { state.eventSink(RoomSelectEvent.SelectAccount(it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
SelectedRoomsHelper(
|
||||
selectedRooms = state.selectedRooms,
|
||||
// showVerticalSpace only if there is no other accounts
|
||||
showVerticalSpace = !state.showAccountSwitch,
|
||||
)
|
||||
if (state.hasRoomListError) {
|
||||
RoomListError()
|
||||
} else if (state.resultState is SearchBarResultState.Results) {
|
||||
SelectedRoomsHelper(
|
||||
selectedRooms = state.selectedRooms,
|
||||
// showVerticalSpace only if there is no other accounts
|
||||
showVerticalSpace = !state.showAccountSwitch,
|
||||
)
|
||||
when {
|
||||
state.hasRoomListError -> RoomListError()
|
||||
state.resultState is SearchBarResultState.NoResultsFound -> NoResultsView(searchQuery = searchQuery)
|
||||
state.resultState is SearchBarResultState.Results -> {
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
contentPadding = lazyColumnContentPadding,
|
||||
) {
|
||||
if (state.resultState.results.isNotEmpty()) {
|
||||
item {
|
||||
val headerText = if (searchQuery.isBlank()) {
|
||||
stringResource(CommonStrings.common_header_rooms)
|
||||
} else {
|
||||
stringResource(CommonStrings.common_results_for, searchQuery)
|
||||
}
|
||||
ListSectionHeader(
|
||||
title = stringResource(CommonStrings.common_header_rooms),
|
||||
title = headerText,
|
||||
hasDivider = false,
|
||||
)
|
||||
}
|
||||
@@ -269,11 +273,45 @@ fun RoomSelectView(
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoResultsView(
|
||||
searchQuery: String,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// Scrollable, so that the account switch section can be displayed again by scrolling down
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 40.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(CommonStrings.common_no_results),
|
||||
modifier = Modifier
|
||||
.semantics {
|
||||
heading()
|
||||
},
|
||||
textAlign = TextAlign.Center,
|
||||
style = ElementTheme.typography.fontHeadingMdBold,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(CommonStrings.common_no_results_for, searchQuery),
|
||||
modifier = Modifier.widthIn(max = 300.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomListError() {
|
||||
IconTitleSubtitleMolecule(
|
||||
|
||||
+27
-15
@@ -63,7 +63,6 @@ class RoomSelectPresenterTest {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.selectedRooms).isEmpty()
|
||||
assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java)
|
||||
assertThat(initialState.isSearchActive).isFalse()
|
||||
assertThat(initialState.maxNumberOfRooms).isEqualTo(10)
|
||||
assertThat(initialState.canSelectMoreRooms).isTrue()
|
||||
assertThat(initialState.selectedAccount).isEqualTo(MatrixUser(A_SESSION_ID))
|
||||
@@ -366,6 +365,33 @@ class RoomSelectPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - select another account keeps the search query and applies it to the rooms of this account`() = runTest {
|
||||
val roomList2 = FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary(roomId = A_ROOM_ID_2))))
|
||||
val roomListServices = mapOf(
|
||||
A_SESSION_ID to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary()))) }),
|
||||
A_SESSION_ID_2 to FakeRoomListService(createRoomListLambda = { roomList2 }),
|
||||
)
|
||||
val presenter = createRoomSelectPresenter(
|
||||
mode = RoomSelectMode.Share,
|
||||
sessionStore = aSessionStoreWithTwoAccounts(),
|
||||
matrixClientProvider = FakeMatrixClientProvider { sessionId ->
|
||||
Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListServices.getValue(sessionId)))
|
||||
},
|
||||
)
|
||||
presenter.test {
|
||||
val state = awaitLastSequentialItem()
|
||||
state.searchQuery.setTextAndPlaceCursorAtEnd("query")
|
||||
state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2))
|
||||
val finalState = consumeItemsUntilPredicate {
|
||||
it.selectedAccount.userId == A_SESSION_ID_2 && it.resultState is SearchBarResultState.Results
|
||||
}.last()
|
||||
assertThat(finalState.searchQuery.text.toString()).isEqualTo("query")
|
||||
assertThat(finalState.showAccountSwitch).isTrue()
|
||||
assertThat(roomList2.currentFilter.value).isEqualTo(RoomListFilter.NormalizedMatchRoomName("query"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - forward mode with several accounts does not show the account switch`() = runTest {
|
||||
val presenter = createRoomSelectPresenter(
|
||||
@@ -386,18 +412,6 @@ class RoomSelectPresenterTest {
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `present - toggle search active`() = runTest {
|
||||
val presenter = createRoomSelectPresenter()
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(RoomSelectEvent.ToggleSearchActive)
|
||||
assertThat(awaitItem().isSearchActive).isTrue()
|
||||
initialState.eventSink(RoomSelectEvent.ToggleSearchActive)
|
||||
assertThat(awaitItem().isSearchActive).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - update query`() = runTest {
|
||||
val roomSummary = aRoomSummary()
|
||||
@@ -416,8 +430,6 @@ class RoomSelectPresenterTest {
|
||||
// Do not compare the lambda because they will be different. So copy the lambda from expectedRoomSummary to result
|
||||
val result = (awaitItem().resultState as SearchBarResultState.Results).results
|
||||
assertThat(result).isEqualTo(listOf(expectedRoomInfo))
|
||||
initialState.eventSink(RoomSelectEvent.ToggleSearchActive)
|
||||
skipItems(1)
|
||||
initialState.searchQuery.setTextAndPlaceCursorAtEnd("string not contained")
|
||||
assertThat(
|
||||
roomList.currentFilter.value
|
||||
|
||||
+50
@@ -13,8 +13,12 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsNotDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.swipeUp
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
@@ -29,6 +33,7 @@ import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.junit.Test
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@@ -123,6 +128,44 @@ class RoomSelectViewTest : RobolectricTest() {
|
||||
onNodeWithText(activity!!.getString(CommonStrings.common_switch_account)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the room list can be scrolled when there is no other account`() = runAndroidComposeUiTest {
|
||||
setRoomSelectView(
|
||||
aRoomSelectState(
|
||||
mode = RoomSelectMode.Share,
|
||||
resultState = SearchBarResultState.Results(aLongRoomList()),
|
||||
),
|
||||
)
|
||||
onNodeWithText("Room 0").assertIsDisplayed()
|
||||
onRoot().performTouchInput { swipeUp() }
|
||||
onNodeWithText("Room 0").assertIsNotDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the room list can be scrolled when there are other accounts`() = runAndroidComposeUiTest {
|
||||
setRoomSelectView(
|
||||
aRoomSelectState(
|
||||
mode = RoomSelectMode.Share,
|
||||
resultState = SearchBarResultState.Results(aLongRoomList()),
|
||||
otherAccounts = persistentListOf(aMatrixUser(id = "@bob:example.org", displayName = "Bob")),
|
||||
),
|
||||
)
|
||||
onNodeWithText("Room 0").assertIsDisplayed()
|
||||
onRoot().performTouchInput { swipeUp() }
|
||||
onNodeWithText("Room 0").assertIsNotDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a message is displayed when no room matches the search query`() = runAndroidComposeUiTest {
|
||||
setRoomSelectView(
|
||||
aRoomSelectState(
|
||||
searchQuery = "Test",
|
||||
resultState = SearchBarResultState.NoResultsFound,
|
||||
),
|
||||
)
|
||||
onNodeWithText(activity!!.getString(CommonStrings.common_no_results)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an error is displayed instead of the rooms if they cannot be loaded`() = runAndroidComposeUiTest<ComponentActivity> {
|
||||
setRoomSelectView(
|
||||
@@ -150,3 +193,10 @@ private fun AndroidComposeUiTest<ComponentActivity>.setRoomSelectView(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun aLongRoomList() = List(30) { index ->
|
||||
aSelectRoomInfo(
|
||||
roomId = RoomId("!room$index:domain"),
|
||||
name = "Room $index",
|
||||
)
|
||||
}.toImmutableList()
|
||||
|
||||
@@ -335,6 +335,7 @@ Reason: %1$s."</string>
|
||||
<string name="common_mute">"Mute"</string>
|
||||
<string name="common_name">"Name"</string>
|
||||
<string name="common_name_and_id">"%1$s (%2$s)"</string>
|
||||
<string name="common_no_results_for">"There are no results for “%1$s”. Try a new search term."</string>
|
||||
<string name="common_no_results">"No results"</string>
|
||||
<string name="common_no_room_name">"No room name"</string>
|
||||
<string name="common_no_space_name">"No space name"</string>
|
||||
@@ -381,6 +382,7 @@ Reason: %1$s."</string>
|
||||
<string name="common_replying_to">"Replying to %1$s"</string>
|
||||
<string name="common_report_a_problem">"Report a problem"</string>
|
||||
<string name="common_report_submitted">"Report submitted"</string>
|
||||
<string name="common_results_for">"Results for “%1$s”"</string>
|
||||
<string name="common_rich_text_editor">"Rich text editor"</string>
|
||||
<string name="common_role">"Role"</string>
|
||||
<string name="common_room">"Room"</string>
|
||||
|
||||
@@ -66,6 +66,7 @@ class KonsistClassNameTest {
|
||||
"EditableAvatarViewUriPreviewParam",
|
||||
"LoginModeViewErrorPreviewParam",
|
||||
"OverlapRatioPreviewParam",
|
||||
"SelectionCountPreviewParam",
|
||||
"TextFileContentPreviewParam",
|
||||
"HtmlMessageContentPreviewParam",
|
||||
)
|
||||
|
||||
@@ -110,6 +110,7 @@ class KonsistPreviewTest {
|
||||
"MessageComposerViewVoicePreview",
|
||||
"MessagesReactionButtonAddPreview",
|
||||
"MessagesReactionButtonExtraPreview",
|
||||
"MessagesViewSelectionModePreview",
|
||||
"MessagesViewWithIdentityChangePreview",
|
||||
"PendingMemberRowWithLongNamePreview",
|
||||
"PinUnlockViewInAppPreview",
|
||||
@@ -133,6 +134,7 @@ class KonsistPreviewTest {
|
||||
"SasEmojisPreview",
|
||||
"SecureBackupSetupViewChangePreview",
|
||||
"SelectedUserCannotRemovePreview",
|
||||
"SelectionIndicatorSelectedPreview",
|
||||
"SpaceMembersViewNoHeroesPreview",
|
||||
"SyncStateViewServerUnreachablePreview",
|
||||
"TextComposerAddCaptionPreview",
|
||||
@@ -179,6 +181,7 @@ class KonsistPreviewTest {
|
||||
"TimelineItemVoiceViewScanningContentPreview",
|
||||
"TimelineItemVoiceViewUnifiedPreview",
|
||||
"TimelineViewMessageShieldPreview",
|
||||
"TimelineViewSelectionModePreview",
|
||||
"TimelineViewWithReadMarkerBothIndicatorsPreview",
|
||||
"TimelineViewWithReadMarkerJumpToUnreadIndicatorOnlyPreview",
|
||||
"UserAvatarColorsPreview",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user