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