diff --git a/.github/renovate.json b/.github/renovate.json
index 00447475c9..0769cb8966 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -22,7 +22,8 @@
{
"versioning": "semver",
"matchPackageNames": [
- "/^org.maplibre/"
+ "/^org.maplibre/",
+ "/^org.jetbrains.kotlinx:kotlinx-datetime/"
]
}
]
diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml
new file mode 100644
index 0000000000..aeeebb3bfc
--- /dev/null
+++ b/.github/workflows/stale-issues.yml
@@ -0,0 +1,22 @@
+name: Close stale issues that are missing info.
+
+on:
+ schedule:
+ - cron: "30 1 * * *"
+
+jobs:
+ stale:
+ runs-on: ubuntu-latest
+ permissions:
+ issues: write
+ steps:
+ - uses: actions/stale@v10
+ with:
+ only-labels: "X-Needs-Info"
+ days-before-issue-stale: 30
+ days-before-issue-close: 7
+ days-before-pr-stale: -1
+ stale-issue-label: "stale"
+ labels-to-remove-when-unstale: "X-Needs-Info"
+ stale-issue-message: "This issue has been awaiting further information for the past 30 days so will now be marked as stale. Please provide the requested information within the next 7 days to keep it open."
+ close-issue-message: "This issue is being closed due to inactivity after further information was requested."
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index dbbf81b44b..5be13229a7 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
-
+
-
+
\ No newline at end of file
diff --git a/.maestro/tests/account/login.yaml b/.maestro/tests/account/login.yaml
index f3f584ef76..0661a30981 100644
--- a/.maestro/tests/account/login.yaml
+++ b/.maestro/tests/account/login.yaml
@@ -9,29 +9,35 @@ appId: ${MAESTRO_APP_ID}
id: "login-continue"
## MAS page
## Conditional workflow to pass the Chrome first launch welcome page.
-- runFlow:
- when:
- visible: 'Use without an account'
+- retry:
+ maxRetries: 3
commands:
- - tapOn: "Use without an account"
-## For older chrome versions
-- runFlow:
- when:
- visible: 'Accept & continue'
- commands:
- - tapOn: "Accept & continue"
-- runFlow:
- when:
- visible: 'No thanks'
- commands:
- - tapOn: "No thanks"
+ - runFlow:
+ when:
+ visible: 'Use without an account'
+ commands:
+ - tapOn: "Use without an account"
+ ## For older chrome versions
+ - runFlow:
+ when:
+ visible: 'Accept & continue'
+ commands:
+ - tapOn: "Accept & continue"
+ - runFlow:
+ when:
+ visible: 'No thanks'
+ commands:
+ - tapOn: "No thanks"
## Working when running Maestro locally, but not on the CI yet.
-- extendedWaitUntil:
- visible:
- id: "form-1"
- timeout: 10000
+- retry:
+ maxRetries: 3
+ commands:
+ - extendedWaitUntil:
+ visible:
+ id: "form-1"
+ timeout: 10000
- tapOn:
- id: "form-1"
+ id: "form-1"
- inputText: ${MAESTRO_USERNAME}
- pressKey: Enter
- tapOn:
diff --git a/.maestro/tests/roomList/createAndDeleteDM.yaml b/.maestro/tests/roomList/createAndDeleteDM.yaml
index 7e33fd1d15..e80dc377b5 100644
--- a/.maestro/tests/roomList/createAndDeleteDM.yaml
+++ b/.maestro/tests/roomList/createAndDeleteDM.yaml
@@ -13,3 +13,8 @@ appId: ${MAESTRO_APP_ID}
- scroll
- tapOn: "Leave room"
- tapOn: "Leave"
+- runFlow:
+ when:
+ visible: 'You need an invite in order to join'
+ commands:
+ - tapOn: "Back"
diff --git a/.maestro/tests/roomList/createAndDeleteRoom.yaml b/.maestro/tests/roomList/createAndDeleteRoom.yaml
index a72fb80075..d0b17133d5 100644
--- a/.maestro/tests/roomList/createAndDeleteRoom.yaml
+++ b/.maestro/tests/roomList/createAndDeleteRoom.yaml
@@ -3,9 +3,9 @@ appId: ${MAESTRO_APP_ID}
# Purpose: Test the creation and deletion of a room
- tapOn: "Create a new conversation or room"
- tapOn: "New room"
-- tapOn: "e.g. your project name"
+- tapOn: "Add name…"
- inputText: "aRoomName"
-- tapOn: "What is this room about?"
+- tapOn: "Add description…"
- inputText: "aRoomTopic"
- tapOn: "Create"
- takeScreenshot: build/maestro/320-createAndDeleteRoom
@@ -37,3 +37,8 @@ appId: ${MAESTRO_APP_ID}
- scroll
- tapOn: "Leave room"
- tapOn: "Leave"
+- runFlow:
+ when:
+ visible: 'You need an invite in order to join'
+ commands:
+ - tapOn: "Back"
diff --git a/CHANGES.md b/CHANGES.md
index 7d5f35a6ac..7419401245 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,70 @@
+Changes in Element X v26.01.2
+=============================
+
+
+
+## What's Changed
+### ✨ Features
+* Display a badge for messages decrypted using shared keys by @richvdh in https://github.com/element-hq/element-x-android/pull/6023
+* Add empty state view for HomeSpacesView by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6047
+* Create a new room in a space by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6061
+* Add an empty state for the space screen if the user can modify its graph by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6064
+* Add 'Create room' option to menu in space screen by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6095
+### 🙌 Improvements
+* Add suggestions section to InvitePeopleView by @ganfra in https://github.com/element-hq/element-x-android/pull/6045
+* Show an icon in the room header for shared history by @richvdh in https://github.com/element-hq/element-x-android/pull/6090
+* Remove "history may be shared" banner. by @kaylendog in https://github.com/element-hq/element-x-android/pull/6087
+### 🐛 Bugfixes
+* Make `relatedTo` in `RoomSendQueueUpdate.MediaUpload` a transaction id by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6002
+* Tweak the power levels when creating a space by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6012
+* Keep the child state in `AttachmentsPreviewPresenter` up to date by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6039
+* Ensure screenshot is up to date by @bmarty in https://github.com/element-hq/element-x-android/pull/6040
+* First try to resolve the room before checking for the alias validity by @bmarty in https://github.com/element-hq/element-x-android/pull/6066
+* Use `MediaPreviewValue.Private` to check if media should be displayed in notifications by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6038
+* Fix RoomDetailsEditView avatar picker for spaces by @ganfra in https://github.com/element-hq/element-x-android/pull/6074
+* Try fixing performance metrics by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6046
+* Fix rageshakes not uploading if they are too long by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6075
+* Display a confirmation dialog when ending a poll from the event bottom sheet by @bmarty in https://github.com/element-hq/element-x-android/pull/6092
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6033
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6042
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6085
+### 🚧 In development 🚧
+* Implement Space 'Add existing rooms' feature by @ganfra in https://github.com/element-hq/element-x-android/pull/6063
+### Dependency upgrades
+* Update dependency org.matrix.rustcomponents:sdk-android to v26.1.16 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6025
+* chore(deps): update plugin dependencycheck to v12.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5993
+* fix(deps): update wysiwyg to v2.41.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6037
+* fix(deps): update metro to v0.10.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6043
+* fix(deps): update dependency io.sentry:sentry-android to v8.31.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6057
+* chore(deps): update peter-evans/create-pull-request action to v8.1.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6055
+* fix(deps): update dependency org.robolectric:robolectric to v4.16.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6050
+* fix(deps): update kotlin by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6058
+* fix(deps): update dependency io.nlopez.compose.rules:detekt to v0.5.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6060
+* fix(deps): update dependency io.nlopez.compose.rules:detekt to v0.5.5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6062
+* fix(deps): update dependency com.posthog:posthog-android to v3.29.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6056
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v26.1.22 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6065
+* fix(deps): update metro to v0.10.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6077
+* fix(deps): update kotlin by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6076
+* fix(deps): update roborazzi to v1.57.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6080
+* fix(deps): update media3 to v1.9.1 - autoclosed by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6086
+* fix(deps): update dependency io.mockk:mockk to v1.14.9 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6089
+* Update dependency io.nlopez.compose.rules:detekt to v0.5.6 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6093
+* Update dependency net.zetetic:sqlcipher-android to v4.13.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6094
+* Update dependency org.matrix.rustcomponents:sdk-android to v26.1.27 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6096
+* Update dependency com.google.testparameterinjector:test-parameter-injector to v1.21 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6100
+### Others
+* Let rageshake description mention when a log file is missing by @bmarty in https://github.com/element-hq/element-x-android/pull/6027
+* Provide `ConfigureRoomState.availableVisibilityOptions` in presenter by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6024
+* Attempt to fix flaky test. by @bmarty in https://github.com/element-hq/element-x-android/pull/6016
+* sdk : allow passing coroutineScope to RoomList by @ganfra in https://github.com/element-hq/element-x-android/pull/6054
+* Let SearchBar/SearchField use TextFieldState by @ganfra in https://github.com/element-hq/element-x-android/pull/6072
+* Remove obfuscation in proguard by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6067
+* Sync all strings and fix compilation issue. by @bmarty in https://github.com/element-hq/element-x-android/pull/6088
+
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.01.1...v26.01.2
+
Changes in Element X v26.01.1
=============================
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingBaseRoomStateFlowFactoryTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingBaseRoomStateFlowFactoryTest.kt
index 14128ac33a..7a02591237 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingBaseRoomStateFlowFactoryTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingBaseRoomStateFlowFactoryTest.kt
@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
+import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.ui.room.LoadingRoomState
import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory
@@ -54,7 +55,8 @@ class LoadingBaseRoomStateFlowFactoryTest {
@Test
fun `flow should emit Loading and then Loaded when there is a room in cache after SS is loaded`() = runTest {
val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(sessionId = A_SESSION_ID, roomId = A_ROOM_ID))
- val roomListService = FakeRoomListService()
+ val roomList = FakeDynamicRoomList()
+ val roomListService = FakeRoomListService(allRooms = roomList)
val matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService)
val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
flowFactory
@@ -62,21 +64,22 @@ class LoadingBaseRoomStateFlowFactoryTest {
.test {
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
matrixClient.givenGetRoomResult(A_ROOM_ID, room)
- roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
+ roomList.loadingState.emit(RoomList.LoadingState.Loaded(1))
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room))
}
}
@Test
fun `flow should emit Loading and then Error when there is no room in cache after SS is loaded`() = runTest {
- val roomListService = FakeRoomListService()
+ val roomList = FakeDynamicRoomList()
+ val roomListService = FakeRoomListService(allRooms = roomList)
val matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService)
val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
flowFactory
.create(lifecycleScope = this, roomId = A_ROOM_ID, joinedRoom = null)
.test {
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
- roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
+ roomList.loadingState.emit(RoomList.LoadingState.Loaded(1))
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Error)
}
}
diff --git a/docs/_developer_onboarding.md b/docs/_developer_onboarding.md
index 6fb5229eeb..7770c016fc 100644
--- a/docs/_developer_onboarding.md
+++ b/docs/_developer_onboarding.md
@@ -24,6 +24,7 @@
* [Logging](#logging)
* [Translations](#translations)
* [Rageshake](#rageshake)
+ * [Developer options](#developer-options)
* [Tips](#tips)
* [Happy coding!](#happy-coding)
@@ -409,14 +410,31 @@ The data will be sent to an internal server, which is not publicly accessible. A
Rageshake can be very useful to get logs from a release version of the application.
+
+#### Developer options
+
+> [!WARNING]
+> Developer options can result in unexpected application behavior or destructive
+> actions. Use with caution and only if you are instructed by someone at Element or are
+> already familiar.
+
+These options provide advanced controls for testing and debugging. They are visible by
+default in debug and nightly builds but are hidden in release versions.
+
+**Enabling in release builds:** Navigate to application settings and tap the version
+number at the bottom 7 times. After tapping, a new "Developer options" entry will appear
+at the bottom of the list.
+
+The developer options include feature flags, notification/push history, Element call
+customization, Rust SDK log levels, per-feature tracing toggles, Showkase to debug UI
+components, rageshake controls, app crash controls, cache details/controls, persistent
+storage maintenance tasks.
+
+Keywords: Developer settings, developer mode
+
+
### Tips
-- Element Android has a `developer mode` in the `Settings/Advanced settings`. Other useful options are available here; (TODO Not supported yet!)
-- Show hidden Events can also help to debug feature. When developer mode is enabled, it is possible to view the source (= the Json content) of any Events; (TODO
- Not supported yet!)
-- Type `/devtools` in a Room composer to access a developer menu. There are some other entry points. Developer mode has to be enabled; (TODO Not supported yet!)
-- Hidden debug menu: when developer mode is enabled and on debug build, there are some extra screens that can be accessible using the green wheel. In those
- screens, it will be possible to toggle some feature flags; (TODO Not supported yet!)
- Using logcat, filtering with `Compositions` can help you to understand what screen are currently displayed on your device. Searching for string displayed on
the screen can also help to find the running code in the codebase.
- When this is possible, prefer using `sealed interface` instead of `sealed class`;
diff --git a/fastlane/metadata/android/en-US/changelogs/202602000.txt b/fastlane/metadata/android/en-US/changelogs/202602000.txt
new file mode 100644
index 0000000000..a4b397f1bb
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202602000.txt
@@ -0,0 +1,2 @@
+Main changes in this version: bug fixes and improvements.
+Full changelog: https://github.com/element-hq/element-x-android/releases
\ No newline at end of file
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt
index 7fbcf1ba71..497b121da5 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt
@@ -149,15 +149,18 @@ class CallScreenPresenter(
.launchIn(this)
}
- LaunchedEffect(Unit) {
- // Wait for the call to be joined, if it takes too long, we display an error
- delay(10.seconds)
+ if (callType is CallType.RoomCall) {
+ // Note: For external calls isWidgetLoaded will always be false
+ LaunchedEffect(Unit) {
+ // Wait for the call to be joined, if it takes too long, we display an error
+ delay(10.seconds)
- if (!isWidgetLoaded) {
- Timber.w("The call took too long to load. Displaying an error before exiting.")
+ if (!isWidgetLoaded) {
+ Timber.w("The call took too long to load. Displaying an error before exiting.")
- // This will display a simple 'Sorry, an error occurred' dialog and force the user to exit the call
- webViewError = ""
+ // This will display a simple 'Sorry, an error occurred' dialog and force the user to exit the call
+ webViewError = ""
+ }
}
}
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
index 85c64adc83..91cc660333 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
@@ -82,10 +82,6 @@ class ConfigureRoomPresenter(
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
private var pendingPermissionRequest = false
- init {
- dataStore.setIsSpace(isSpace)
- }
-
@Composable
override fun present(): ConfigureRoomState {
val canAddRoomToSpace by featureFlagService.isFeatureEnabledFlow(FeatureFlags.CreateSpaces).collectAsState(false)
@@ -123,9 +119,10 @@ class ConfigureRoomPresenter(
} else {
persistentListOf()
}
-
val parentSpace = spaces.find { it.roomId == initialParentSpaceId }
- parentSpace?.let { dataStore.setParentSpace(it) }
+ parentSpace?.let {
+ dataStore.setParentSpace(parentSpace = parentSpace, updateVisibility = true)
+ }
}
LaunchedEffect(cameraPermissionState.permissionGranted) {
@@ -152,21 +149,42 @@ class ConfigureRoomPresenter(
// 2. If it has a parent space.
// 3. If knocking is enabled.
val parentSpace = createRoomConfig.parentSpace
- val availableJoinRules = remember(createRoomConfig.parentSpace, isSpace, isKnockFeatureEnabled) {
+ val availableJoinRules = remember(parentSpace, isSpace, isKnockFeatureEnabled) {
when {
isSpace && parentSpace != null -> TODO("Adding a space to a parent space is not supported yet! How did you get here?")
parentSpace == null || parentSpace.joinRule == JoinRule.Public -> listOfNotNull(
JoinRuleItem.PublicVisibility.Public,
JoinRuleItem.PublicVisibility.AskToJoin.takeIf { !isSpace && isKnockFeatureEnabled },
- JoinRuleItem.Private,
+ JoinRuleItem.PrivateVisibility.Private,
).toImmutableList()
else -> listOfNotNull(
- JoinRuleItem.PublicVisibility.Restricted(parentSpace.roomId),
- JoinRuleItem.PublicVisibility.AskToJoinRestricted(parentSpace.roomId).takeIf { !isSpace && isKnockFeatureEnabled },
- JoinRuleItem.Private,
+ JoinRuleItem.PrivateVisibility.Restricted(parentSpace.roomId),
+ JoinRuleItem.PrivateVisibility.AskToJoinRestricted(parentSpace.roomId).takeIf { isKnockFeatureEnabled },
+ JoinRuleItem.PrivateVisibility.Private,
).toImmutableList()
}
}
+ val currentJoinRule = createRoomConfig.visibilityState.joinRuleItem
+ LaunchedEffect(availableJoinRules, currentJoinRule) {
+ // Find matching rule by type (ignoring parentSpaceId parameter for Restricted types)
+ val matchingRule = when (currentJoinRule) {
+ is JoinRuleItem.PrivateVisibility.Restricted ->
+ availableJoinRules.filterIsInstance().firstOrNull()
+ is JoinRuleItem.PrivateVisibility.AskToJoinRestricted ->
+ availableJoinRules.filterIsInstance().firstOrNull()
+ else -> availableJoinRules.find { it == currentJoinRule }
+ }
+ when {
+ matchingRule == null -> {
+ // No matching type fallback to Private (always available)
+ dataStore.setJoinRule(JoinRuleItem.PrivateVisibility.Private)
+ }
+ matchingRule != currentJoinRule -> {
+ // Same type but different params (e.g., different parentSpaceId), update
+ dataStore.setJoinRule(matchingRule)
+ }
+ }
+ }
fun createRoom(config: CreateRoomConfig) {
createRoomAction.value = AsyncAction.Uninitialized
@@ -193,7 +211,7 @@ class ConfigureRoomPresenter(
}
}
is ConfigureRoomEvents.SetParentSpace -> {
- dataStore.setParentSpace(event.space)
+ dataStore.setParentSpace(event.space, false)
}
ConfigureRoomEvents.CancelCreateRoom -> {
createRoomAction.value = AsyncAction.Uninitialized
@@ -210,6 +228,7 @@ class ConfigureRoomPresenter(
roomAddressValidity = roomAddressValidity.value,
availableJoinRules = availableJoinRules,
spaces = spaces,
+ isSpace = isSpace,
eventSink = ::handleEvent,
)
}
@@ -220,35 +239,41 @@ class ConfigureRoomPresenter(
) = launch {
suspend {
val avatarUrl = config.avatarUri?.let { uploadAvatar(it.toUri()) }
- val params = if (config.visibilityState is RoomVisibilityState.Public) {
- CreateRoomParameters(
- name = config.roomName,
- topic = config.topic,
- isEncrypted = false,
- isDirect = false,
- visibility = RoomVisibility.Public,
- joinRuleOverride = config.visibilityState.joinRuleItem.toJoinRule()
- // No need to specify the public join rule override, since the preset is already PUBLIC_CHAT
- .takeIf { it != JoinRule.Public },
- preset = RoomPreset.PUBLIC_CHAT,
- invite = config.invites.map { it.userId },
- avatar = avatarUrl,
- roomAliasName = config.visibilityState.roomAddress(),
- isSpace = isSpace,
- )
- } else {
- CreateRoomParameters(
- name = config.roomName,
- topic = config.topic,
- isEncrypted = config.visibilityState is RoomVisibilityState.Private,
- isDirect = false,
- visibility = RoomVisibility.Private,
- historyVisibilityOverride = RoomHistoryVisibility.Invited,
- preset = RoomPreset.PRIVATE_CHAT,
- invite = config.invites.map { it.userId },
- avatar = avatarUrl,
- isSpace = isSpace,
- )
+ val params = when (config.visibilityState) {
+ is RoomVisibilityState.Public -> {
+ CreateRoomParameters(
+ name = config.roomName,
+ topic = config.topic,
+ isEncrypted = false,
+ isDirect = false,
+ visibility = RoomVisibility.Public,
+ joinRuleOverride = config.visibilityState.joinRuleItem.toJoinRule()
+ // No need to specify the public join rule override, since the preset is already PUBLIC_CHAT
+ .takeIf { it != JoinRule.Public },
+ preset = RoomPreset.PUBLIC_CHAT,
+ invite = config.invites.map { it.userId },
+ avatar = avatarUrl,
+ roomAliasName = config.visibilityState.roomAddress(),
+ isSpace = isSpace,
+ )
+ }
+ is RoomVisibilityState.Private -> {
+ CreateRoomParameters(
+ name = config.roomName,
+ topic = config.topic,
+ isEncrypted = true,
+ isDirect = false,
+ visibility = RoomVisibility.Private,
+ historyVisibilityOverride = RoomHistoryVisibility.Invited,
+ joinRuleOverride = config.visibilityState.joinRuleItem.toJoinRule()
+ // No need to specify the Invite join rule override, since the preset is already PRIVATE_CHAT
+ .takeIf { it != JoinRule.Invite },
+ preset = RoomPreset.PRIVATE_CHAT,
+ invite = config.invites.map { it.userId },
+ avatar = avatarUrl,
+ isSpace = isSpace,
+ )
+ }
}
val roomId = matrixClient.createRoom(params)
.onFailure { failure ->
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt
index 9e88a7af79..0da931fa51 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt
@@ -17,6 +17,7 @@ import io.element.android.libraries.permissions.api.PermissionsState
import kotlinx.collections.immutable.ImmutableList
data class ConfigureRoomState(
+ val isSpace: Boolean,
val config: CreateRoomConfig,
val avatarActions: ImmutableList,
val createRoomAction: AsyncAction,
@@ -28,5 +29,6 @@ data class ConfigureRoomState(
val eventSink: (ConfigureRoomEvents) -> Unit
) {
val isValid: Boolean = config.roomName?.isNotEmpty() == true &&
- (config.visibilityState is RoomVisibilityState.Private || roomAddressValidity == RoomAddressValidity.Valid)
+ (config.visibilityState is RoomVisibilityState.Private || roomAddressValidity == RoomAddressValidity.Valid) &&
+ config.visibilityState.joinRuleItem in availableJoinRules
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt
index 68a1f4b43b..9c2ad76a9b 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt
@@ -82,8 +82,8 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider = emptyList(),
createRoomAction: AsyncAction = AsyncAction.Uninitialized,
@@ -134,21 +131,22 @@ fun aConfigureRoomState(
roomAddressValidity: RoomAddressValidity = RoomAddressValidity.Valid,
availableVisibilityOptions: List = if (config.parentSpace != null) {
listOfNotNull(
- JoinRuleItem.PublicVisibility.Restricted(config.parentSpace.roomId),
- JoinRuleItem.PublicVisibility.AskToJoinRestricted(config.parentSpace.roomId).takeIf { isKnockFeatureEnabled },
- JoinRuleItem.Private,
+ JoinRuleItem.PrivateVisibility.Restricted(config.parentSpace.roomId),
+ JoinRuleItem.PrivateVisibility.AskToJoinRestricted(config.parentSpace.roomId).takeIf { isKnockFeatureEnabled },
+ JoinRuleItem.PrivateVisibility.Private,
)
} else {
listOfNotNull(
JoinRuleItem.PublicVisibility.Public,
JoinRuleItem.PublicVisibility.AskToJoin.takeIf { isKnockFeatureEnabled },
- JoinRuleItem.Private,
+ JoinRuleItem.PrivateVisibility.Private,
)
},
spaces: List = emptyList(),
eventSink: (ConfigureRoomEvents) -> Unit = { },
) = ConfigureRoomState(
config = config,
+ isSpace = isSpace,
avatarActions = avatarActions.toImmutableList(),
createRoomAction = createRoomAction,
cameraPermissionState = cameraPermissionState,
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt
index 39849174dd..1ee4bcd2b5 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt
@@ -14,7 +14,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -76,7 +78,7 @@ fun ConfigureRoomView(
onCreateRoomSuccess: (RoomId) -> Unit,
modifier: Modifier = Modifier,
) {
- val isSpace = state.config.isSpace
+ val isSpace = state.isSpace
val focusManager = LocalFocusManager.current
val isAvatarActionsSheetVisible = remember { mutableStateOf(false) }
@@ -105,7 +107,6 @@ fun ConfigureRoomView(
.imePadding()
.verticalScroll(rememberScrollState())
.consumeWindowInsets(padding),
- verticalArrangement = Arrangement.spacedBy(16.dp),
) {
RoomNameWithAvatar(
isSpace = isSpace,
@@ -115,20 +116,20 @@ fun ConfigureRoomView(
onAvatarClick = ::onAvatarClick,
onChangeRoomName = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) },
)
+ Spacer(modifier = Modifier.height(16.dp))
RoomTopic(
modifier = Modifier.padding(horizontal = 16.dp),
topic = state.config.topic.orEmpty(),
onTopicChange = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
)
-
- if (!state.config.isSpace && state.spaces.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(16.dp))
+ if (!state.isSpace && state.spaces.isNotEmpty()) {
SelectParentSpaceOptions(
spaces = state.spaces,
selectedSpace = state.config.parentSpace,
onSelectSpace = { state.eventSink(ConfigureRoomEvents.SetParentSpace(it)) },
)
}
-
RoomJoinRuleOptions(
options = state.availableJoinRules,
selected = state.config.visibilityState.joinRuleItem,
@@ -138,20 +139,17 @@ fun ConfigureRoomView(
state.eventSink(ConfigureRoomEvents.JoinRuleChanged(it))
},
)
-
if (state.config.visibilityState !is RoomVisibilityState.Private) {
- Column {
- ListSectionHeader(title = stringResource(R.string.screen_create_room_room_address_section_title))
- RoomAddressField(
- modifier = Modifier.padding(horizontal = 16.dp),
- address = state.config.visibilityState.roomAddress().getOrNull().orEmpty(),
- homeserverName = state.homeserverName,
- addressValidity = state.roomAddressValidity,
- onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) },
- label = null,
- supportingText = stringResource(R.string.screen_create_room_room_address_section_footer),
- )
- }
+ ListSectionHeader(title = stringResource(R.string.screen_create_room_room_address_section_title))
+ RoomAddressField(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ address = state.config.visibilityState.roomAddress().getOrNull().orEmpty(),
+ homeserverName = state.homeserverName,
+ addressValidity = state.roomAddressValidity,
+ onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) },
+ label = null,
+ supportingText = stringResource(R.string.screen_create_room_room_address_section_footer),
+ )
}
}
}
@@ -217,7 +215,9 @@ private fun RoomNameWithAvatar(
verticalAlignment = Alignment.CenterVertically,
) {
Box(
- modifier = Modifier.padding(end = 8.dp).size(AvatarSize.EditRoomDetails.dp),
+ modifier = Modifier
+ .padding(end = 8.dp)
+ .size(AvatarSize.EditRoomDetails.dp),
contentAlignment = Alignment.Center,
) {
val avatarState = remember(avatarUri) {
@@ -272,12 +272,13 @@ private fun RoomTopic(
internal fun ConfigureRoomOptions(
title: String,
modifier: Modifier = Modifier,
+ hasDivider: Boolean = true,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier.selectableGroup()
) {
- ListSectionHeader(title = title)
+ ListSectionHeader(title = title, hasDivider = hasDivider)
content()
}
}
@@ -302,10 +303,10 @@ private fun RoomJoinRuleOptions(
size = RoundedIconAtomSize.Big,
imageVector = when (item) {
JoinRuleItem.PublicVisibility.Public -> CompoundIcons.Public()
- is JoinRuleItem.PublicVisibility.Restricted -> CompoundIcons.Space()
+ is JoinRuleItem.PrivateVisibility.Restricted -> CompoundIcons.Space()
JoinRuleItem.PublicVisibility.AskToJoin,
- is JoinRuleItem.PublicVisibility.AskToJoinRestricted -> CompoundIcons.UserAdd()
- JoinRuleItem.Private -> CompoundIcons.Lock()
+ is JoinRuleItem.PrivateVisibility.AskToJoinRestricted -> CompoundIcons.UserAdd()
+ JoinRuleItem.PrivateVisibility.Private -> CompoundIcons.Lock()
},
tint = if (isSelected) ElementTheme.colors.iconPrimary else ElementTheme.colors.iconSecondary,
backgroundTint = Color.Transparent,
@@ -314,28 +315,28 @@ private fun RoomJoinRuleOptions(
headlineContent = {
val title = when (item) {
JoinRuleItem.PublicVisibility.Public -> stringResource(R.string.screen_create_room_room_access_section_public_option_title)
- is JoinRuleItem.PublicVisibility.Restricted -> stringResource(R.string.screen_create_room_room_access_section_restricted_option_title)
+ is JoinRuleItem.PrivateVisibility.Restricted -> stringResource(R.string.screen_create_room_room_access_section_restricted_option_title)
JoinRuleItem.PublicVisibility.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_title)
- is JoinRuleItem.PublicVisibility.AskToJoinRestricted -> stringResource(
+ is JoinRuleItem.PrivateVisibility.AskToJoinRestricted -> stringResource(
R.string.screen_create_room_room_access_section_knocking_restricted_option_title
)
- JoinRuleItem.Private -> stringResource(R.string.screen_create_room_room_access_section_private_option_title)
+ JoinRuleItem.PrivateVisibility.Private -> stringResource(R.string.screen_create_room_room_access_section_private_option_title)
}
Text(text = title)
},
supportingContent = {
val description = when (item) {
JoinRuleItem.PublicVisibility.Public -> stringResource(R.string.screen_create_room_room_access_section_public_option_description)
- is JoinRuleItem.PublicVisibility.Restricted -> stringResource(
+ is JoinRuleItem.PrivateVisibility.Restricted -> stringResource(
R.string.screen_create_room_room_access_section_restricted_option_description,
parentSpace?.displayName.orEmpty()
)
JoinRuleItem.PublicVisibility.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_description)
- is JoinRuleItem.PublicVisibility.AskToJoinRestricted -> stringResource(
+ is JoinRuleItem.PrivateVisibility.AskToJoinRestricted -> stringResource(
R.string.screen_create_room_room_access_section_knocking_restricted_option_description,
parentSpace?.displayName.orEmpty()
)
- JoinRuleItem.Private -> stringResource(R.string.screen_create_room_room_access_section_private_option_description)
+ JoinRuleItem.PrivateVisibility.Private -> stringResource(R.string.screen_create_room_room_access_section_private_option_description)
}
Text(text = description)
},
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt
index dc24db1516..dc9321eb91 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt
@@ -14,11 +14,10 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
data class CreateRoomConfig(
- val isSpace: Boolean = false,
val roomName: String? = null,
val topic: String? = null,
val avatarUri: String? = null,
val invites: ImmutableList = persistentListOf(),
- val visibilityState: RoomVisibilityState = RoomVisibilityState.Private(),
+ val visibilityState: RoomVisibilityState = RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private),
val parentSpace: SpaceRoom? = null,
)
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt
index d1d4f22322..f1d2966cd0 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt
@@ -72,7 +72,9 @@ class CreateRoomConfigStore(
createRoomConfigFlow.getAndUpdate { config ->
config.copy(
visibilityState = when (joinRule) {
- JoinRuleItem.Private -> RoomVisibilityState.Private()
+ is JoinRuleItem.PrivateVisibility -> RoomVisibilityState.Private(
+ joinRuleItem = joinRule
+ )
is JoinRuleItem.PublicVisibility -> {
val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(config.roomName.orEmpty())
RoomVisibilityState.Public(
@@ -99,17 +101,16 @@ class CreateRoomConfigStore(
}
}
- fun setIsSpace(isSpace: Boolean) {
- createRoomConfigFlow.getAndUpdate { config ->
- config.copy(isSpace = isSpace)
- }
- }
-
- fun setParentSpace(parentSpace: SpaceRoom?) {
+ fun setParentSpace(parentSpace: SpaceRoom?, updateVisibility: Boolean) {
createRoomConfigFlow.getAndUpdate { config ->
+ val visibilityState = if (parentSpace != null && updateVisibility) {
+ RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Restricted(parentSpace.roomId))
+ } else {
+ config.visibilityState
+ }
config.copy(
parentSpace = parentSpace,
- visibilityState = RoomVisibilityState.Private(),
+ visibilityState = visibilityState
)
}
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/JoinRuleItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/JoinRuleItem.kt
index 428cf648ec..72e0e1e501 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/JoinRuleItem.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/JoinRuleItem.kt
@@ -18,7 +18,11 @@ import kotlinx.collections.immutable.persistentListOf
*/
@Immutable
sealed interface JoinRuleItem {
- data object Private : JoinRuleItem
+ sealed interface PrivateVisibility : JoinRuleItem {
+ data object Private : PrivateVisibility
+ data class Restricted(val parentSpaceId: RoomId) : PrivateVisibility
+ data class AskToJoinRestricted(val parentSpaceId: RoomId) : PrivateVisibility
+ }
/**
* Those join rule items that represent public visibility of the room/space.
@@ -27,18 +31,16 @@ sealed interface JoinRuleItem {
sealed interface PublicVisibility : JoinRuleItem {
data object Public : PublicVisibility
data object AskToJoin : PublicVisibility
- data class Restricted(val parentSpaceId: RoomId) : PublicVisibility
- data class AskToJoinRestricted(val parentSpaceId: RoomId) : PublicVisibility
}
/**
* Transforms a [JoinRuleItem] option into a [JoinRule].
*/
fun toJoinRule(): JoinRule = when (this) {
- Private -> JoinRule.Private
+ PrivateVisibility.Private -> JoinRule.Invite
+ is PrivateVisibility.Restricted -> JoinRule.Restricted(persistentListOf(AllowRule.RoomMembership(parentSpaceId)))
+ is PrivateVisibility.AskToJoinRestricted -> JoinRule.KnockRestricted(persistentListOf(AllowRule.RoomMembership(parentSpaceId)))
PublicVisibility.Public -> JoinRule.Public
PublicVisibility.AskToJoin -> JoinRule.Knock
- is PublicVisibility.Restricted -> JoinRule.Restricted(persistentListOf(AllowRule.RoomMembership(parentSpaceId)))
- is PublicVisibility.AskToJoinRestricted -> JoinRule.KnockRestricted(persistentListOf(AllowRule.RoomMembership(parentSpaceId)))
}
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityState.kt
index 7fd8bd888c..f312fcfa00 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityState.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityState.kt
@@ -12,7 +12,7 @@ import java.util.Optional
sealed interface RoomVisibilityState {
val joinRuleItem: JoinRuleItem
- data class Private(override val joinRuleItem: JoinRuleItem.Private = JoinRuleItem.Private) : RoomVisibilityState
+ data class Private(override val joinRuleItem: JoinRuleItem.PrivateVisibility) : RoomVisibilityState
data class Public(
val roomAddress: RoomAddress,
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/SelectParentSpaceOptions.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/SelectParentSpaceOptions.kt
index 26c36f3e60..6b7b66b897 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/SelectParentSpaceOptions.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/SelectParentSpaceOptions.kt
@@ -8,7 +8,6 @@
package io.element.android.features.createroom.impl.configureroom
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -21,7 +20,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
-import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.compound.theme.ElementTheme
import io.element.android.features.createroom.impl.R
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@@ -30,7 +29,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.designsystem.theme.components.IconSource
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.ModalBottomSheet
@@ -55,6 +53,7 @@ internal fun SelectParentSpaceOptions(
var displaySelectSpaceBottomSheet by remember { mutableStateOf(false) }
ConfigureRoomOptions(
title = stringResource(CommonStrings.common_space),
+ hasDivider = false,
modifier = modifier
) {
ListItem(
@@ -62,22 +61,16 @@ internal fun SelectParentSpaceOptions(
Text(
text = selectedSpace?.displayName
?: stringResource(R.string.screen_create_room_space_selection_no_space_title),
- maxLines = 1
+ maxLines = 1,
+ color = ElementTheme.colors.textPrimary
)
},
- supportingContent = {
- Text(
- text = if (selectedSpace != null) {
- selectedSpace.canonicalAlias?.value.orEmpty()
- } else {
- stringResource(R.string.screen_create_room_space_selection_no_space_description)
- },
- maxLines = 1
- )
+ supportingContent = selectedSpace?.canonicalAlias?.let { alias ->
+ {
+ Text(text = alias.value, maxLines = 1)
+ }
},
- leadingContent = if (selectedSpace == null) {
- ListItemContent.Icon(IconSource.Vector(CompoundIcons.Home()))
- } else {
+ leadingContent = selectedSpace?.let {
ListItemContent.Custom({
val avatarData = AvatarData(
id = selectedSpace.roomId.value,
@@ -119,7 +112,7 @@ internal fun SelectParentSpaceOptions(
}
@Composable
-private fun ColumnScope.SelectParentSpaceBottomSheet(
+private fun SelectParentSpaceBottomSheet(
spaces: ImmutableList,
selectedSpace: SpaceRoom?,
onSelectSpace: (SpaceRoom?) -> Unit,
@@ -133,19 +126,10 @@ private fun ColumnScope.SelectParentSpaceBottomSheet(
ListItem(
headlineContent = {
Text(
- stringResource(R.string.screen_create_room_space_selection_no_space_title),
+ text = stringResource(R.string.screen_create_room_space_selection_no_space_option),
maxLines = 1
)
},
- supportingContent = {
- Text(
- stringResource(R.string.screen_create_room_space_selection_no_space_description),
- maxLines = 1
- )
- },
- leadingContent = ListItemContent.Icon(
- IconSource.Vector(CompoundIcons.Home())
- ),
trailingContent = ListItemContent.RadioButton(
selected = selectedSpace == null
),
@@ -157,29 +141,31 @@ private fun ColumnScope.SelectParentSpaceBottomSheet(
ListItem(
headlineContent = {
Text(
- space.displayName,
+ text = space.displayName,
maxLines = 1
)
},
- supportingContent = {
- Text(
- space.canonicalAlias?.value.orEmpty(),
- maxLines = 1
- )
+ supportingContent = space.canonicalAlias?.let { alias ->
+ {
+ Text(
+ text = alias.value,
+ maxLines = 1
+ )
+ }
},
leadingContent = ListItemContent.Custom({
- val avatarData =
- AvatarData(
- id = space.roomId.value,
- name = space.displayName,
- url = space.avatarUrl,
- size = AvatarSize.SelectParentSpace,
- )
- Avatar(
- avatarData = avatarData,
- avatarType = AvatarType.Space()
+ val avatarData =
+ AvatarData(
+ id = space.roomId.value,
+ name = space.displayName,
+ url = space.avatarUrl,
+ size = AvatarSize.SelectParentSpace,
)
- }),
+ Avatar(
+ avatarData = avatarData,
+ avatarType = AvatarType.Space()
+ )
+ }),
trailingContent = ListItemContent.RadioButton(
selected = selectedSpace == space
),
@@ -201,7 +187,8 @@ internal fun SelectParentSpaceBottomSheetPreview() =
canonicalAlias = RoomAlias(
"#a-room-alias:example.org"
)
- )
+ ),
+ aSpaceRoom()
),
selectedSpace = null,
) {}
diff --git a/features/createroom/impl/src/main/res/values-cs/translations.xml b/features/createroom/impl/src/main/res/values-cs/translations.xml
index 39f9d573e0..d7be6a84b1 100644
--- a/features/createroom/impl/src/main/res/values-cs/translations.xml
+++ b/features/createroom/impl/src/main/res/values-cs/translations.xml
@@ -3,16 +3,33 @@
"Nová místnost"
"Pozvat přátele"
"Při vytváření místnosti došlo k chybě"
- "Do této místnosti mají přístup pouze pozvaní lidé. Všechny zprávy jsou koncově šifrovány."
+ "Prostor se nepodařilo vytvořit kvůli neznámé chybě. Zkuste to znovu později."
+ "Přidat název…"
+ "Nová místnost"
+ "Nový prostor"
+ "Do této místnosti mohou vstoupit pouze pozvaní."
+ "Soukromý"
"Tuto místnost může najít kdokoli.
To můžete kdykoli změnit v nastavení místnosti."
+ "Vstoupit může kdokoli."
"Veřejná místnost"
- "Kdokoli může požádat o vstup do místnosti, ale správce nebo moderátor bude muset žádost přijmout"
- "Požádat o připojení"
- "Do této místnosti může vstoupit kdokoli"
+ "Kdokoli může požádat o vstup do místnosti, ale správce nebo moderátor bude muset žádost přijmout."
+ "Povolit žádost o vstup"
+ "Kdokoli v %1$s může vstoupit, ale všichni ostatní si musí o přístup požádat."
+ "Požádat o vstup"
+ "Vstoupit mohou pouze pozvaní lidé."
+ "Soukromý"
+ "Vstoupit může kdokoli."
"Kdokoliv"
- "Aby byla tato místnost viditelná v adresáři veřejných místností, budete potřebovat adresu místnosti."
- "Adresa místnosti"
+ "Kdokoli může vstoupit do %1$s."
+ "Standard"
+ "Kdo má přístup"
+ "Budete potřebovat adresu, aby se zobrazovala ve veřejném adresáři."
+ "Adresa"
"Viditelnost místnosti"
+ "(bez prostoru)"
+ "Domov"
+ "Přidat do prostoru"
"Téma (nepovinné)"
+ "Přidat popis…"
diff --git a/features/createroom/impl/src/main/res/values-de/translations.xml b/features/createroom/impl/src/main/res/values-de/translations.xml
index 298cae3a8f..33063e6538 100644
--- a/features/createroom/impl/src/main/res/values-de/translations.xml
+++ b/features/createroom/impl/src/main/res/values-de/translations.xml
@@ -28,6 +28,7 @@ Du kannst dies jederzeit in den Einstellungen des Chats ändern."
"Adresse"
" Sichtbarkeit des Chats"
"(kein Space)"
+ "Home"
"Space hinzufügen"
"Thema (optional)"
"Beschreibung hinzufügen…"
diff --git a/features/createroom/impl/src/main/res/values-et/translations.xml b/features/createroom/impl/src/main/res/values-et/translations.xml
index 0527302e0e..c0a4963bf0 100644
--- a/features/createroom/impl/src/main/res/values-et/translations.xml
+++ b/features/createroom/impl/src/main/res/values-et/translations.xml
@@ -3,16 +3,33 @@
"Uus jututuba"
"Kutsu osalejaid"
"Jututoa loomisel tekkis viga"
- "Ligipääs siia jututuppa on vaid kutse alusel. Kõik sõnumid siin jututoas on läbivalt krüptitud."
+ "Kogukonda polnud tundmatu vea tõttu võimalik luua. Palun proovi hiljem uuesti."
+ "Sisesta nimi…"
+ "Uus jututuba"
+ "Uus kogukond"
+ "Ligipääs siia jututuppa on vaid kutse alusel."
+ "Privaatne"
"Kõik saavad seda jututuba leida.
Sa võid seda jututoa seadistustest alati muuta."
+ "Kõik võivad selle jututoaga liituda."
"Avalik jututuba"
- "Kõik võivad paluda selle jututoaga liitumist, kuid peakasutaja või moderaator peavad selle kinnitama"
- "Küsi võimalust liitumiseks"
- "Kõik võivad selle jututoaga liituda"
+ "Kõik võivad paluda selle jututoaga liitumist, kuid peakasutaja või moderaator peavad selle kinnitama."
+ "Luba küsida liitumisvõimalust"
+ "Kõik „%1$s“ kogukonna liikmed võivad liituda, kuid kõik teised peavad liitumiseks küsima luba."
+ "Küsi võimalust liitumiseks"
+ "Ligipääs siia jututuppa on vaid kutse alusel."
+ "Privaatne"
+ "Kõik võivad selle jututoaga liituda."
"Kõik kasutajad"
- "Selleks, et see jututuba oleks nähtav jututubade avalikus kataloogis, sa vajad jututoa aadressi."
- "Jututoa aadress"
+ "Liituda võivad kõik „%1$s“ kogukonna liikmed."
+ "Standardne"
+ "Kellel on ligipääs"
+ "Selleks, et jututuba oleks nähtav jututubade avalikus kataloogis, vajab ta aadressi."
+ "Aadress"
"Jututoa nähtavus"
+ "(kogukonda pole)"
+ "Avaleht"
+ "Lisa kogukonda"
"Teema (kui soovid lisada)"
+ "Lisa kirjeldus…"
diff --git a/features/createroom/impl/src/main/res/values-fr/translations.xml b/features/createroom/impl/src/main/res/values-fr/translations.xml
index 37e19a4b79..ef85ab1ad3 100644
--- a/features/createroom/impl/src/main/res/values-fr/translations.xml
+++ b/features/createroom/impl/src/main/res/values-fr/translations.xml
@@ -20,7 +20,7 @@ Vous pouvez modifier cela à tout moment dans les paramètres du salon.""Seules les personnes invitées peuvent joindre."
"Privé"
"Tout le monde peut joindre"
- "Tout le monde"
+ "Public"
"Toute membre de %1$s peut joindre le salon."
"Standard"
"Qui a accès"
@@ -28,7 +28,8 @@ Vous pouvez modifier cela à tout moment dans les paramètres du salon.""Adresse"
"Visibilité du salon"
"(pas d’espace)"
- "Accueil"
+ "Ne pas ajouter à un espace"
+ "Aucun espace sélectionné"
"Ajouter à l’espace"
"Sujet (facultatif)"
"Ajouter une description…"
diff --git a/features/createroom/impl/src/main/res/values-nb/translations.xml b/features/createroom/impl/src/main/res/values-nb/translations.xml
index dda611e8a7..47ed3daff6 100644
--- a/features/createroom/impl/src/main/res/values-nb/translations.xml
+++ b/features/createroom/impl/src/main/res/values-nb/translations.xml
@@ -3,17 +3,25 @@
"Nytt rom"
"Inviter folk"
"Det oppsto en feil under opprettelsen av rommet"
+ "Legg til navn…"
+ "Nytt rom"
+ "Nytt område"
"Bare inviterte personer kan bli med."
"Privat"
"Alle kan finne dette rommet.
Du kan endre dette når som helst i rominnstillingene."
+ "Alle kan bli med."
"Offentlig rom"
"Alle kan be om å få bli med, men en administrator eller moderator må godta forespørselen."
"Be om å bli med"
+ "Bare inviterte personer kan bli med."
+ "Privat"
"Alle kan bli med."
"Alle"
+ "Hvem har tilgang"
"Du trenger en adresse for å gjøre den synlig i den offentlige katalogen."
"Adresse"
"Romsynlighet"
"Emne (valgfritt)"
+ "Legg til beskrivelse…"
diff --git a/features/createroom/impl/src/main/res/values-sv/translations.xml b/features/createroom/impl/src/main/res/values-sv/translations.xml
index 779bd893ea..0718806ac6 100644
--- a/features/createroom/impl/src/main/res/values-sv/translations.xml
+++ b/features/createroom/impl/src/main/res/values-sv/translations.xml
@@ -3,16 +3,16 @@
"Nytt rum"
"Bjud in personer"
"Ett fel uppstod när rummet skapades"
- "Endast inbjudna personer har tillgång till detta rum. Alla meddelanden är totalsträckskrypterade."
+ "Endast inbjudna personer kan gå med."
"Vem som helst kan hitta det här rummet.
Du kan ändra detta när som helst i rumsinställningarna."
"Offentligt rum"
- "Vem som helst kan be om att gå med i rummet men en administratör eller en moderator måste acceptera begäran"
- "Be om att gå med"
- "Vem som helst kan gå med i det här rummet"
- "Vem som helst"
- "För att detta rum ska vara synligt i den allmänna rumskatalogen behöver du en rumsadress."
- "Rumsadress"
+ "Vem som helst kan be om att gå med men en administratör eller en moderator måste acceptera begäran"
+ "Tillåt att be om att gå med"
+ "Vem som helst kan gå med."
+ "Offentligt"
+ "Du behöver en adress för att den ska synas i den offentliga katalogen."
+ "Adress"
"Rumssynlighet"
"Ämne (valfritt)"
diff --git a/features/createroom/impl/src/main/res/values/localazy.xml b/features/createroom/impl/src/main/res/values/localazy.xml
index b2a0f1d5be..8ce0d0a1e5 100644
--- a/features/createroom/impl/src/main/res/values/localazy.xml
+++ b/features/createroom/impl/src/main/res/values/localazy.xml
@@ -7,8 +7,6 @@
"Add name…"
"New room"
"New space"
- "(no space)"
- "Home"
"Only people invited can join."
"Private"
"Anyone can find this room.
@@ -30,7 +28,8 @@ You can change this anytime in room settings."
"Address"
"Room visibility"
"(no space)"
- "Home"
+ "Do not add to a space"
+ "No space selected"
"Add to space"
"Topic (optional)"
"Add description…"
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt
index d3150ea517..fcedcb2367 100644
--- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt
@@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
+import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
@@ -89,7 +90,7 @@ class ConfigureRoomPresenterTest {
assertThat(initialState.config.topic).isNull()
assertThat(initialState.config.invites).isEmpty()
assertThat(initialState.config.avatarUri).isNull()
- assertThat(initialState.config.visibilityState).isEqualTo(RoomVisibilityState.Private())
+ assertThat(initialState.config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private))
assertThat(initialState.createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(initialState.homeserverName).isEqualTo("matrix.org")
}
@@ -218,6 +219,7 @@ class ConfigureRoomPresenterTest {
fun `present - when creating a room in a space if the room doesn't receive the power levels value it can't be added to the space`() = runTest {
val addChildToSpaceResult = lambdaRecorder> { _, _ -> Result.success(Unit) }
val spaceService = FakeSpaceService(
+ editableSpacesResult = { Result.success(emptyList()) },
addChildToSpaceResult = addChildToSpaceResult,
)
val roomInfoFlow = MutableStateFlow>(Optional.empty())
@@ -234,7 +236,8 @@ class ConfigureRoomPresenterTest {
matrixClient.givenCreateRoomResult(createRoomResult)
- val parentSpace = aSpaceRoom()
+ // Use a public parent space so AskToJoin is a valid option
+ val parentSpace = aSpaceRoom(joinRule = JoinRule.Public)
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(parentSpace))
assertThat(awaitItem().config.parentSpace).isEqualTo(parentSpace)
@@ -259,6 +262,7 @@ class ConfigureRoomPresenterTest {
fun `present - creating a room and adding it into a parent space works when all the data is available`() = runTest {
val addChildToSpaceResult = lambdaRecorder> { _, _ -> Result.success(Unit) }
val spaceService = FakeSpaceService(
+ editableSpacesResult = { Result.success(emptyList()) },
addChildToSpaceResult = addChildToSpaceResult,
)
val roomInfoFlow = MutableStateFlow>(Optional.empty())
@@ -275,7 +279,8 @@ class ConfigureRoomPresenterTest {
matrixClient.givenCreateRoomResult(createRoomResult)
- val parentSpace = aSpaceRoom()
+ // Use a public parent space so AskToJoin is a valid option
+ val parentSpace = aSpaceRoom(joinRule = JoinRule.Public)
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(parentSpace))
assertThat(awaitItem().config.parentSpace).isEqualTo(parentSpace)
@@ -484,16 +489,19 @@ class ConfigureRoomPresenterTest {
assertThat(awaitItem().config.visibilityState).isInstanceOf(RoomVisibilityState.Public::class.java)
// Then check changing the parent space resets it to private
+ // (via LaunchedEffect fallback since Public is not in availableJoinRules for non-public parent)
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(aSpaceRoom()))
- assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private())
+ skipItems(1) // Skip intermediate state
+ assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private))
// If we change the join rule back to public
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
- assertThat(awaitItem().config.visibilityState).isInstanceOf(RoomVisibilityState.Public::class.java)
+ skipItems(1) // Skip intermediate state (Public is still invalid)
+ assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private))
- // Then remove the parent space, it'll be private again
+ // Then remove the parent space, the join rule stays private
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(null))
- assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private())
+ assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private))
}
}
@@ -516,7 +524,9 @@ class ConfigureRoomPresenterTest {
private fun createMatrixClient(
isAliasAvailable: Boolean = true,
- spaceService: FakeSpaceService = FakeSpaceService(),
+ spaceService: FakeSpaceService = FakeSpaceService(
+ editableSpacesResult = { Result.success(emptyList()) }
+ ),
) = FakeMatrixClient(
userIdServerNameLambda = { "matrix.org" },
resolveRoomAliasResult = {
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/JoinRuleItemTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/JoinRuleItemTest.kt
index 1d6083e050..70dfd3b54f 100644
--- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/JoinRuleItemTest.kt
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/JoinRuleItemTest.kt
@@ -18,12 +18,12 @@ import org.junit.Test
class JoinRuleItemTest {
@Test
fun `toJoinRule works as expected`() {
- assertThat(JoinRuleItem.Private.toJoinRule()).isEqualTo(JoinRule.Private)
+ assertThat(JoinRuleItem.PrivateVisibility.Private.toJoinRule()).isEqualTo(JoinRule.Invite)
assertThat(JoinRuleItem.PublicVisibility.Public.toJoinRule()).isEqualTo(JoinRule.Public)
assertThat(JoinRuleItem.PublicVisibility.AskToJoin.toJoinRule()).isEqualTo(JoinRule.Knock)
- assertThat(JoinRuleItem.PublicVisibility.Restricted(A_ROOM_ID).toJoinRule())
+ assertThat(JoinRuleItem.PrivateVisibility.Restricted(A_ROOM_ID).toJoinRule())
.isEqualTo(JoinRule.Restricted(persistentListOf(AllowRule.RoomMembership(A_ROOM_ID))))
- assertThat(JoinRuleItem.PublicVisibility.AskToJoinRestricted(A_ROOM_ID).toJoinRule())
+ assertThat(JoinRuleItem.PrivateVisibility.AskToJoinRestricted(A_ROOM_ID).toJoinRule())
.isEqualTo(JoinRule.KnockRestricted(persistentListOf(AllowRule.RoomMembership(A_ROOM_ID))))
}
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvents.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt
similarity index 88%
rename from features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvents.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt
index db9dafba63..7d2bb09d30 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvents.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt
@@ -10,7 +10,7 @@ package io.element.android.features.home.impl
import io.element.android.libraries.matrix.api.core.SessionId
-sealed interface HomeEvents {
- data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvents
- data class SwitchToAccount(val sessionId: SessionId) : HomeEvents
+sealed interface HomeEvent {
+ data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent
+ data class SwitchToAccount(val sessionId: SessionId) : HomeEvent
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt
index 0e92761bd5..81e7969080 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt
@@ -34,7 +34,7 @@ import io.element.android.annotations.ContributesNode
import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.home.impl.components.RoomListMenuAction
import io.element.android.features.home.impl.model.RoomListRoomSummary
-import io.element.android.features.home.impl.roomlist.RoomListEvents
+import io.element.android.features.home.impl.roomlist.RoomListEvent
import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView
import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint
@@ -159,7 +159,7 @@ class HomeFlowNode(
}
private fun onNewOwnersSelected(roomId: RoomId) {
- stateFlow.value.roomListState.eventSink(RoomListEvents.LeaveRoom(roomId, needsConfirmation = false))
+ stateFlow.value.roomListState.eventSink(RoomListEvent.LeaveRoom(roomId, needsConfirmation = false))
}
private fun rootNode(buildContext: BuildContext): Node {
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt
index e03b40e840..6878f4d53c 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt
@@ -80,15 +80,15 @@ class HomePresenter(
val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator()
val directLogoutState = logoutPresenter.present()
- fun handleEvent(event: HomeEvents) {
+ fun handleEvent(event: HomeEvent) {
when (event) {
- is HomeEvents.SelectHomeNavigationBarItem -> coroutineState.launch {
+ is HomeEvent.SelectHomeNavigationBarItem -> coroutineState.launch {
if (event.item == HomeNavigationBarItem.Spaces) {
announcementService.showAnnouncement(Announcement.Space)
}
currentHomeNavigationBarItemOrdinal = event.item.ordinal
}
- is HomeEvents.SwitchToAccount -> coroutineState.launch {
+ is HomeEvent.SwitchToAccount -> coroutineState.launch {
sessionStore.setLatestSession(event.sessionId.value)
}
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt
index 1850d2d4cc..dbb994e23e 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt
@@ -9,6 +9,7 @@
package io.element.android.features.home.impl
import io.element.android.features.home.impl.roomlist.RoomListState
+import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.home.impl.spaces.HomeSpacesState
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@@ -29,8 +30,9 @@ data class HomeState(
val snackbarMessage: SnackbarMessage?,
val canReportBug: Boolean,
val directLogoutState: DirectLogoutState,
- val eventSink: (HomeEvents) -> Unit,
+ val eventSink: (HomeEvent) -> Unit,
) {
+ val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected
val displayActions = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats
val displayRoomListFilters = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats && roomListState.displayFilters
val showNavigationBar = homeSpacesState.canCreateSpaces || homeSpacesState.spaceRooms.isNotEmpty()
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt
index e68ff7aa1f..2c4c76fabe 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt
@@ -59,7 +59,7 @@ internal fun aHomeState(
homeSpacesState: HomeSpacesState = aHomeSpacesState(),
canReportBug: Boolean = true,
directLogoutState: DirectLogoutState = aDirectLogoutState(),
- eventSink: (HomeEvents) -> Unit = {}
+ eventSink: (HomeEvent) -> Unit = {}
) = HomeState(
currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(),
showAvatarIndicator = showAvatarIndicator,
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt
index 52745108f6..eee6f49db3 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt
@@ -47,9 +47,12 @@ import io.element.android.features.home.impl.components.RoomListMenuAction
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.roomlist.RoomListContextMenu
import io.element.android.features.home.impl.roomlist.RoomListDeclineInviteMenu
-import io.element.android.features.home.impl.roomlist.RoomListEvents
+import io.element.android.features.home.impl.roomlist.RoomListEvent
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.search.RoomListSearchView
+import io.element.android.features.home.impl.spacefilters.SpaceFiltersEvent
+import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
+import io.element.android.features.home.impl.spacefilters.SpaceFiltersView
import io.element.android.features.home.impl.spaces.HomeSpacesView
import io.element.android.libraries.androidutils.throttler.FirstThrottler
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -153,10 +156,15 @@ private fun HomeScaffold(
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
val roomListState: RoomListState = state.roomListState
- BackHandler(
- enabled = state.currentHomeNavigationBarItem != HomeNavigationBarItem.Chats,
- ) {
- state.eventSink(HomeEvents.SelectHomeNavigationBarItem(HomeNavigationBarItem.Chats))
+ BackHandler(enabled = state.isBackHandlerEnabled) {
+ if (state.currentHomeNavigationBarItem != HomeNavigationBarItem.Chats) {
+ state.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Chats))
+ } else {
+ val spaceFiltersState = state.roomListState.spaceFiltersState
+ if (spaceFiltersState is SpaceFiltersState.Selected) {
+ spaceFiltersState.eventSink(SpaceFiltersEvent.Selected.ClearSelection)
+ }
+ }
}
val hazeState = rememberHazeState()
@@ -168,20 +176,20 @@ private fun HomeScaffold(
topBar = {
HomeTopBar(
selectedNavigationItem = state.currentHomeNavigationBarItem,
- title = stringResource(state.currentHomeNavigationBarItem.labelRes),
currentUserAndNeighbors = state.currentUserAndNeighbors,
showAvatarIndicator = state.showAvatarIndicator,
areSearchResultsDisplayed = roomListState.searchState.isSearchActive,
- onToggleSearch = { roomListState.eventSink(RoomListEvents.ToggleSearchResults) },
+ onToggleSearch = { roomListState.eventSink(RoomListEvent.ToggleSearchResults) },
onMenuActionClick = onMenuActionClick,
onOpenSettings = onOpenSettings,
onAccountSwitch = {
- state.eventSink(HomeEvents.SwitchToAccount(it))
+ state.eventSink(HomeEvent.SwitchToAccount(it))
},
onCreateSpace = onCreateSpaceClick,
scrollBehavior = scrollBehavior,
displayFilters = state.displayRoomListFilters,
filtersState = roomListState.filtersState,
+ spaceFiltersState = roomListState.spaceFiltersState,
canCreateSpaces = state.homeSpacesState.canCreateSpaces,
canReportBug = state.canReportBug,
modifier = Modifier.hazeEffect(
@@ -211,7 +219,7 @@ private fun HomeScaffold(
lazyListStateTarget.animateScrollToItem(0)
}
} else {
- state.eventSink(HomeEvents.SelectHomeNavigationBarItem(item))
+ state.eventSink(HomeEvent.SelectHomeNavigationBarItem(item))
}
},
modifier = Modifier.hazeEffect(
@@ -227,6 +235,7 @@ private fun HomeScaffold(
RoomListContentView(
contentState = roomListState.contentState,
filtersState = roomListState.filtersState,
+ spaceFiltersState = roomListState.spaceFiltersState,
lazyListState = roomsLazyListState,
hideInvitesAvatars = roomListState.hideInvitesAvatars,
eventSink = roomListState.eventSink,
@@ -256,6 +265,7 @@ private fun HomeScaffold(
.consumeWindowInsets(padding)
.hazeSource(state = hazeState)
)
+ SpaceFiltersView(roomListState.spaceFiltersState)
}
HomeNavigationBarItem.Spaces -> {
HomeSpacesView(
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt
index c92a5b9fb8..69547db58a 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberTopAppBarState
@@ -44,6 +45,10 @@ import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.filters.RoomListFiltersView
import io.element.android.features.home.impl.filters.aRoomListFiltersState
+import io.element.android.features.home.impl.spacefilters.SpaceFiltersEvent
+import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
+import io.element.android.features.home.impl.spacefilters.aSelectedSpaceFiltersState
+import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState
import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom
import io.element.android.libraries.designsystem.components.TopAppBarScrollBehaviorLayout
import io.element.android.libraries.designsystem.components.avatar.Avatar
@@ -75,7 +80,6 @@ import kotlinx.collections.immutable.toImmutableList
@Composable
fun HomeTopBar(
selectedNavigationItem: HomeNavigationBarItem,
- title: String,
currentUserAndNeighbors: ImmutableList,
showAvatarIndicator: Boolean,
areSearchResultsDisplayed: Boolean,
@@ -89,6 +93,7 @@ fun HomeTopBar(
canReportBug: Boolean,
displayFilters: Boolean,
filtersState: RoomListFiltersState,
+ spaceFiltersState: SpaceFiltersState,
modifier: Modifier = Modifier,
) {
Column(modifier) {
@@ -103,12 +108,21 @@ fun HomeTopBar(
scrolledContainerColor = Color.Transparent,
),
title = {
+ val displayTitle = when (selectedNavigationItem) {
+ HomeNavigationBarItem.Chats -> {
+ when (spaceFiltersState) {
+ is SpaceFiltersState.Selected -> spaceFiltersState.selectedFilter.spaceRoom.displayName
+ else -> stringResource(selectedNavigationItem.labelRes)
+ }
+ }
+ HomeNavigationBarItem.Spaces -> stringResource(selectedNavigationItem.labelRes)
+ }
Text(
modifier = Modifier.semantics {
heading()
},
style = ElementTheme.typography.aliasScreenTitle,
- text = title,
+ text = displayTitle,
)
},
navigationIcon = {
@@ -124,7 +138,8 @@ fun HomeTopBar(
HomeNavigationBarItem.Chats -> RoomListMenuItems(
onToggleSearch = onToggleSearch,
onMenuActionClick = onMenuActionClick,
- canReportBug = canReportBug
+ canReportBug = canReportBug,
+ spaceFiltersState = spaceFiltersState,
)
HomeNavigationBarItem.Spaces -> SpacesMenuItems(
canCreateSpaces = canCreateSpaces,
@@ -154,6 +169,7 @@ private fun RoomListMenuItems(
onToggleSearch: () -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
canReportBug: Boolean,
+ spaceFiltersState: SpaceFiltersState,
) {
IconButton(
onClick = onToggleSearch,
@@ -163,6 +179,7 @@ private fun RoomListMenuItems(
contentDescription = stringResource(CommonStrings.action_search),
)
}
+ SpaceFilterButton(spaceFiltersState = spaceFiltersState)
if (RoomListConfig.HAS_DROP_DOWN_MENU) {
var showMenu by remember { mutableStateOf(false) }
IconButton(
@@ -228,6 +245,38 @@ private fun SpacesMenuItems(
}
}
+@Composable
+private fun SpaceFilterButton(
+ spaceFiltersState: SpaceFiltersState,
+) {
+ if (spaceFiltersState == SpaceFiltersState.Disabled) return
+
+ fun onClick() {
+ when (spaceFiltersState) {
+ is SpaceFiltersState.Unselected -> spaceFiltersState.eventSink(SpaceFiltersEvent.Unselected.ShowFilters)
+ is SpaceFiltersState.Selected -> spaceFiltersState.eventSink(SpaceFiltersEvent.Selected.ClearSelection)
+ else -> Unit
+ }
+ }
+ val isSelected = spaceFiltersState is SpaceFiltersState.Selected
+ IconButton(
+ onClick = ::onClick,
+ colors = if (isSelected) {
+ IconButtonDefaults.iconButtonColors(
+ containerColor = ElementTheme.colors.bgActionPrimaryRest,
+ contentColor = ElementTheme.colors.iconOnSolidPrimary,
+ )
+ } else {
+ IconButtonDefaults.iconButtonColors()
+ },
+ ) {
+ Icon(
+ imageVector = CompoundIcons.Filter(),
+ contentDescription = stringResource(R.string.screen_roomlist_your_spaces),
+ )
+ }
+}
+
@Composable
private fun NavigationIcon(
currentUserAndNeighbors: ImmutableList,
@@ -309,7 +358,6 @@ private fun AccountIcon(
internal fun HomeTopBarPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats,
- title = stringResource(R.string.screen_roomlist_main_space_title),
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
@@ -322,6 +370,30 @@ internal fun HomeTopBarPreview() = ElementPreview {
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
+ spaceFiltersState = anUnselectedSpaceFiltersState(),
+ onMenuActionClick = {},
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@PreviewsDayNight
+@Composable
+internal fun HomeTopBarSpaceFiltersSelectedPreview() = ElementPreview {
+ HomeTopBar(
+ selectedNavigationItem = HomeNavigationBarItem.Chats,
+ currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
+ showAvatarIndicator = false,
+ areSearchResultsDisplayed = false,
+ scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
+ onOpenSettings = {},
+ onAccountSwitch = {},
+ onToggleSearch = {},
+ onCreateSpace = {},
+ canCreateSpaces = true,
+ canReportBug = true,
+ displayFilters = true,
+ filtersState = aRoomListFiltersState(),
+ spaceFiltersState = aSelectedSpaceFiltersState(),
onMenuActionClick = {},
)
}
@@ -332,7 +404,6 @@ internal fun HomeTopBarPreview() = ElementPreview {
internal fun HomeTopBarSpacesPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Spaces,
- title = stringResource(R.string.screen_home_tab_spaces),
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
@@ -345,6 +416,7 @@ internal fun HomeTopBarSpacesPreview() = ElementPreview {
canReportBug = true,
displayFilters = false,
filtersState = aRoomListFiltersState(),
+ spaceFiltersState = anUnselectedSpaceFiltersState(),
onMenuActionClick = {},
)
}
@@ -355,7 +427,6 @@ internal fun HomeTopBarSpacesPreview() = ElementPreview {
internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats,
- title = stringResource(R.string.screen_roomlist_main_space_title),
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
showAvatarIndicator = true,
areSearchResultsDisplayed = false,
@@ -368,6 +439,7 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
+ spaceFiltersState = anUnselectedSpaceFiltersState(),
onMenuActionClick = {},
)
}
@@ -378,7 +450,6 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
internal fun HomeTopBarMultiAccountPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats,
- title = stringResource(R.string.screen_roomlist_main_space_title),
currentUserAndNeighbors = aMatrixUserList().take(3).toImmutableList(),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
@@ -391,6 +462,7 @@ internal fun HomeTopBarMultiAccountPreview() = ElementPreview {
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
+ spaceFiltersState = anUnselectedSpaceFiltersState(),
onMenuActionClick = {},
)
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt
index 364fe596f8..a03399baf7 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt
@@ -23,11 +23,7 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -47,14 +43,17 @@ import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.model.RoomSummaryDisplayType
import io.element.android.features.home.impl.roomlist.RoomListContentState
import io.element.android.features.home.impl.roomlist.RoomListContentStateProvider
-import io.element.android.features.home.impl.roomlist.RoomListEvents
+import io.element.android.features.home.impl.roomlist.RoomListEvent
import io.element.android.features.home.impl.roomlist.SecurityBannerState
+import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
+import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@@ -62,9 +61,10 @@ import kotlinx.collections.immutable.ImmutableList
fun RoomListContentView(
contentState: RoomListContentState,
filtersState: RoomListFiltersState,
+ spaceFiltersState: SpaceFiltersState,
lazyListState: LazyListState,
hideInvitesAvatars: Boolean,
- eventSink: (RoomListEvents) -> Unit,
+ eventSink: (RoomListEvent) -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit,
@@ -96,6 +96,7 @@ fun RoomListContentView(
state = contentState,
hideInvitesAvatars = hideInvitesAvatars,
filtersState = filtersState,
+ spaceFiltersState = spaceFiltersState,
eventSink = eventSink,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
@@ -131,7 +132,7 @@ private fun SkeletonView(
@Composable
private fun EmptyView(
state: RoomListContentState.Empty,
- eventSink: (RoomListEvents) -> Unit,
+ eventSink: (RoomListEvent) -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onCreateRoomClick: () -> Unit,
@@ -155,13 +156,13 @@ private fun EmptyView(
SecurityBannerState.SetUpRecovery -> {
SetUpRecoveryKeyBanner(
onContinueClick = onSetUpRecoveryClick,
- onDismissClick = { eventSink(RoomListEvents.DismissBanner) },
+ onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
)
}
SecurityBannerState.RecoveryKeyConfirmation -> {
ConfirmRecoveryKeyBanner(
onContinueClick = onConfirmRecoveryKeyClick,
- onDismissClick = { eventSink(RoomListEvents.DismissBanner) },
+ onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
)
}
SecurityBannerState.None -> Unit
@@ -175,7 +176,8 @@ private fun RoomsView(
state: RoomListContentState.Rooms,
hideInvitesAvatars: Boolean,
filtersState: RoomListFiltersState,
- eventSink: (RoomListEvents) -> Unit,
+ spaceFiltersState: SpaceFiltersState,
+ eventSink: (RoomListEvent) -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit,
@@ -183,9 +185,12 @@ private fun RoomsView(
lazyListState: LazyListState,
modifier: Modifier = Modifier,
) {
- if (state.summaries.isEmpty() && filtersState.hasAnyFilterSelected) {
+ val isSpaceFilterSelected = spaceFiltersState is SpaceFiltersState.Selected
+ val hasAnyFilterSelected = filtersState.hasAnyFilterSelected || isSpaceFilterSelected
+ if (state.summaries.isEmpty() && hasAnyFilterSelected) {
EmptyViewForFilterStates(
selectedFilters = filtersState.selectedFilters(),
+ isSpaceFilterSelected = isSpaceFilterSelected,
modifier = modifier.fillMaxSize()
)
} else {
@@ -207,7 +212,7 @@ private fun RoomsView(
private fun RoomsViewList(
state: RoomListContentState.Rooms,
hideInvitesAvatars: Boolean,
- eventSink: (RoomListEvents) -> Unit,
+ eventSink: (RoomListEvent) -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit,
@@ -215,17 +220,8 @@ private fun RoomsViewList(
lazyListState: LazyListState,
modifier: Modifier = Modifier,
) {
- val visibleRange by remember {
- derivedStateOf {
- val layoutInfo = lazyListState.layoutInfo
- val firstItemIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: 0
- val size = layoutInfo.visibleItemsInfo.size
- firstItemIndex until firstItemIndex + size
- }
- }
- val updatedEventSink by rememberUpdatedState(newValue = eventSink)
- LaunchedEffect(visibleRange) {
- updatedEventSink(RoomListEvents.UpdateVisibleRange(visibleRange))
+ OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
+ eventSink(RoomListEvent.UpdateVisibleRange(visibleRange))
}
LazyColumn(
state = lazyListState,
@@ -237,7 +233,7 @@ private fun RoomsViewList(
item {
SetUpRecoveryKeyBanner(
onContinueClick = onSetUpRecoveryClick,
- onDismissClick = { updatedEventSink(RoomListEvents.DismissBanner) },
+ onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
)
}
}
@@ -245,7 +241,7 @@ private fun RoomsViewList(
item {
ConfirmRecoveryKeyBanner(
onContinueClick = onConfirmRecoveryKeyClick,
- onDismissClick = { updatedEventSink(RoomListEvents.DismissBanner) },
+ onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
)
}
}
@@ -260,7 +256,7 @@ private fun RoomsViewList(
} else if (state.showNewNotificationSoundBanner) {
item {
NewNotificationSoundBanner(
- onDismissClick = { updatedEventSink(RoomListEvents.DismissNewNotificationSoundBanner) },
+ onDismissClick = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) },
)
}
}
@@ -290,9 +286,10 @@ private fun RoomsViewList(
@Composable
private fun EmptyViewForFilterStates(
selectedFilters: ImmutableList,
+ isSpaceFilterSelected: Boolean,
modifier: Modifier = Modifier,
) {
- val emptyStateResources = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters) ?: return
+ val emptyStateResources = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected) ?: return
EmptyScaffold(
title = emptyStateResources.title,
subtitle = emptyStateResources.subtitle,
@@ -343,6 +340,7 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr
)
}
),
+ spaceFiltersState = anUnselectedSpaceFiltersState(),
hideInvitesAvatars = false,
eventSink = {},
onSetUpRecoveryClick = {},
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt
index d8426b5321..e2598a9e1c 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt
@@ -44,7 +44,7 @@ import io.element.android.features.home.impl.model.LatestEvent
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.model.RoomListRoomSummaryProvider
import io.element.android.features.home.impl.model.RoomSummaryDisplayType
-import io.element.android.features.home.impl.roomlist.RoomListEvents
+import io.element.android.features.home.impl.roomlist.RoomListEvent
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.core.extensions.toSafeLength
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
@@ -74,7 +74,7 @@ internal fun RoomSummaryRow(
hideInviteAvatars: Boolean,
isInviteSeen: Boolean,
onClick: (RoomListRoomSummary) -> Unit,
- eventSink: (RoomListEvents) -> Unit,
+ eventSink: (RoomListEvent) -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
@@ -104,10 +104,10 @@ internal fun RoomSummaryRow(
Spacer(modifier = Modifier.height(12.dp))
InviteButtonsRowMolecule(
onAcceptClick = {
- eventSink(RoomListEvents.AcceptInvite(room))
+ eventSink(RoomListEvent.AcceptInvite(room))
},
onDeclineClick = {
- eventSink(RoomListEvents.ShowDeclineInviteMenu(room))
+ eventSink(RoomListEvent.ShowDeclineInviteMenu(room))
}
)
}
@@ -117,7 +117,7 @@ internal fun RoomSummaryRow(
room = room,
onClick = onClick,
onLongClick = {
- eventSink(RoomListEvents.ShowContextMenu(room))
+ eventSink(RoomListEvent.ShowContextMenu(room))
},
) {
NameAndTimestampRow(
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt
index c17923fb04..3ff4339bb2 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt
@@ -9,33 +9,48 @@
package io.element.android.features.home.impl.datasource
import dev.zacsweers.metro.Inject
+import dev.zacsweers.metro.SingleIn
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.libraries.androidutils.diff.DiffCacheUpdater
import io.element.android.libraries.androidutils.diff.MutableListDiffCache
import io.element.android.libraries.androidutils.system.DateTimeObserver
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
+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.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
+import io.element.android.libraries.matrix.api.roomlist.updateVisibleRange
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
-import java.lang.IllegalStateException
import kotlin.time.Duration.Companion.seconds
+private const val PAGE_SIZE = 20
+private const val EXTENDED_VISIBILITY_RANGE_SIZE = 40
+private const val SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS = 300L
+private const val PAGINATION_THRESHOLD = 3 * PAGE_SIZE
+
@Inject
+@SingleIn(SessionScope::class)
class RoomListDataSource(
private val roomListService: RoomListService,
private val roomListRoomSummaryFactory: RoomListRoomSummaryFactory,
@@ -51,7 +66,12 @@ class RoomListDataSource(
observeDateTimeChanges()
}
- private val _allRooms = MutableSharedFlow>(replay = 1)
+ private val roomList = roomListService.createRoomList(
+ pageSize = PAGE_SIZE,
+ source = RoomList.Source.All,
+ coroutineScope = sessionCoroutineScope
+ )
+ private val _roomSummariesFlow = MutableSharedFlow>(replay = 1)
private val lock = Mutex()
private val diffCache = MutableListDiffCache()
@@ -59,22 +79,49 @@ class RoomListDataSource(
old?.roomId == new?.roomId
}
- val allRooms: Flow> = _allRooms
+ val roomSummariesFlow: Flow> = _roomSummariesFlow
- val loadingState = roomListService.allRooms.loadingState
+ val loadingState = roomList.loadingState
fun launchIn(coroutineScope: CoroutineScope) {
- roomListService
- .allRooms
- .filteredSummaries
+ roomList
+ .summaries
.onEach { roomSummaries ->
replaceWith(roomSummaries)
}
.launchIn(coroutineScope)
}
- suspend fun subscribeToVisibleRooms(roomIds: List) {
- roomListService.subscribeToVisibleRooms(roomIds)
+ suspend fun updateFilter(filter: RoomListFilter) {
+ roomList.updateFilter(filter)
+ }
+
+ suspend fun updateVisibleRange(visibleRange: IntRange) = coroutineScope {
+ launch {
+ roomList.updateVisibleRange(visibleRange, PAGINATION_THRESHOLD)
+ }
+ launch {
+ subscribeToVisibleRoomsIfNeeded(visibleRange)
+ }
+ }
+
+ private var currentSubscribeToVisibleRoomsJob: Job? = null
+ private fun CoroutineScope.subscribeToVisibleRoomsIfNeeded(range: IntRange) {
+ currentSubscribeToVisibleRoomsJob?.cancel()
+ currentSubscribeToVisibleRoomsJob = launch {
+ // Debounce the subscription to avoid subscribing to too many rooms
+ delay(SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS)
+
+ if (range.isEmpty()) return@launch
+ val currentRoomList = roomSummariesFlow.first()
+ // Use extended range to 'prefetch' the next rooms info
+ val midExtendedRangeSize = EXTENDED_VISIBILITY_RANGE_SIZE / 2
+ val extendedRange = range.first until range.last + midExtendedRangeSize
+ val roomIds = extendedRange.mapNotNull { index ->
+ currentRoomList.getOrNull(index)?.roomId
+ }
+ roomListService.subscribeToVisibleRooms(roomIds)
+ }
}
@OptIn(FlowPreview::class)
@@ -82,7 +129,7 @@ class RoomListDataSource(
notificationSettingsService.notificationSettingsChangeFlow
.debounce(0.5.seconds)
.onEach {
- roomListService.allRooms.rebuildSummaries()
+ roomList.rebuildSummaries()
}
.launchIn(sessionCoroutineScope)
}
@@ -108,6 +155,7 @@ class RoomListDataSource(
private suspend fun buildAndEmitAllRooms(roomSummaries: List, useCache: Boolean = true) {
// Used to detect duplicates in the room list summaries - see comment below
data class CacheResult(val index: Int, val fromCache: Boolean)
+
val cachingResults = mutableMapOf>()
val roomListRoomSummaries = diffCache.indices().mapNotNull { index ->
@@ -144,14 +192,14 @@ class RoomListDataSource(
analyticsService.trackError(
IllegalStateException(
"Found duplicates in room summaries after a local UI update: $duplicates. " +
- "This could be a race condition/caching issue of some kind"
+ "This could be a race condition/caching issue of some kind"
)
)
// Remove duplicates before emitting the new values
- _allRooms.emit(roomListRoomSummaries.distinctBy { it.roomId }.toImmutableList())
+ _roomSummariesFlow.emit(roomListRoomSummaries.distinctBy { it.roomId }.toImmutableList())
} else {
- _allRooms.emit(roomListRoomSummaries.toImmutableList())
+ _roomSummariesFlow.emit(roomListRoomSummaries.toImmutableList())
}
}
@@ -163,7 +211,7 @@ class RoomListDataSource(
private suspend fun rebuildAllRoomSummaries() {
lock.withLock {
- roomListService.allRooms.filteredSummaries.replayCache.firstOrNull()?.let { roomSummaries ->
+ roomList.summaries.replayCache.firstOrNull()?.let { roomSummaries ->
buildAndEmitAllRooms(roomSummaries, useCache = false)
}
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactory.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactory.kt
index 0200f495ef..d723d1a424 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactory.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactory.kt
@@ -95,6 +95,7 @@ class RoomListRoomSummaryFactory(
content = content,
)
}
+ is LatestEventValue.RoomInvite -> LatestEvent.None
}
}
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt
index ea80c1b3cb..1eeba4fff7 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt
@@ -17,6 +17,8 @@ import io.element.android.features.home.impl.roomlist.RoomListPresenter
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.search.RoomListSearchPresenter
import io.element.android.features.home.impl.search.RoomListSearchState
+import io.element.android.features.home.impl.spacefilters.SpaceFiltersPresenter
+import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.SessionScope
@@ -31,4 +33,7 @@ interface RoomListModule {
@Binds
fun bindFiltersPresenter(presenter: RoomListFiltersPresenter): Presenter
+
+ @Binds
+ fun bindSpaceFiltersPresenter(presenter: SpaceFiltersPresenter): Presenter
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFilter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFilter.kt
index 1f627eca4e..3e07c565db 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFilter.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFilter.kt
@@ -9,6 +9,7 @@
package io.element.android.features.home.impl.filters
import io.element.android.features.home.impl.R
+import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter
/**
* Enum class representing the different filters that can be applied to the room list.
@@ -30,3 +31,13 @@ enum class RoomListFilter(val stringResource: Int) {
Invites -> setOf(Rooms, People, Unread, Favourites)
}
}
+
+fun RoomListFilter.into(): MatrixRoomListFilter {
+ return when (this) {
+ RoomListFilter.Rooms -> MatrixRoomListFilter.Category.Group
+ RoomListFilter.People -> MatrixRoomListFilter.Category.People
+ RoomListFilter.Unread -> MatrixRoomListFilter.Unread
+ RoomListFilter.Favourites -> MatrixRoomListFilter.Favorite
+ RoomListFilter.Invites -> MatrixRoomListFilter.Invite
+ }
+}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEmptyStateResources.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEmptyStateResources.kt
index 7381ac308e..084c3c9c0c 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEmptyStateResources.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEmptyStateResources.kt
@@ -24,8 +24,12 @@ data class RoomListFiltersEmptyStateResources(
/**
* Create a [RoomListFiltersEmptyStateResources] from a list of selected filters.
*/
- fun fromSelectedFilters(selectedFilters: List): RoomListFiltersEmptyStateResources? {
+ fun fromSelectedFilters(selectedFilters: List, isSpaceFilterSelected: Boolean): RoomListFiltersEmptyStateResources? {
return when {
+ isSpaceFilterSelected -> RoomListFiltersEmptyStateResources(
+ title = R.string.screen_roomlist_filter_mixed_empty_state_title,
+ subtitle = R.string.screen_roomlist_filter_mixed_empty_state_subtitle
+ )
selectedFilters.isEmpty() -> null
selectedFilters.size == 1 -> {
when (selectedFilters.first()) {
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEvents.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEvent.kt
similarity index 75%
rename from features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEvents.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEvent.kt
index 8b1906d28f..a34e91e089 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEvents.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEvent.kt
@@ -8,7 +8,7 @@
package io.element.android.features.home.impl.filters
-sealed interface RoomListFiltersEvents {
- data class ToggleFilter(val filter: RoomListFilter) : RoomListFiltersEvents
- data object ClearSelectedFilters : RoomListFiltersEvents
+sealed interface RoomListFiltersEvent {
+ data class ToggleFilter(val filter: RoomListFilter) : RoomListFiltersEvent
+ data object ClearSelectedFilters : RoomListFiltersEvent
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenter.kt
index 4b20c69751..e73660219c 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenter.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenter.kt
@@ -9,61 +9,33 @@
package io.element.android.features.home.impl.filters
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.produceState
import dev.zacsweers.metro.Inject
import io.element.android.features.home.impl.filters.selection.FilterSelectionStrategy
import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.matrix.api.roomlist.RoomListService
import kotlinx.collections.immutable.toImmutableList
-import kotlinx.coroutines.flow.map
-import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter
@Inject
class RoomListFiltersPresenter(
- private val roomListService: RoomListService,
private val filterSelectionStrategy: FilterSelectionStrategy,
) : Presenter {
- private val initialFilters = filterSelectionStrategy.filterSelectionStates.value.toImmutableList()
-
@Composable
override fun present(): RoomListFiltersState {
- fun handleEvent(event: RoomListFiltersEvents) {
+ fun handleEvent(event: RoomListFiltersEvent) {
when (event) {
- RoomListFiltersEvents.ClearSelectedFilters -> {
+ RoomListFiltersEvent.ClearSelectedFilters -> {
filterSelectionStrategy.clear()
}
- is RoomListFiltersEvents.ToggleFilter -> {
+ is RoomListFiltersEvent.ToggleFilter -> {
filterSelectionStrategy.toggle(event.filter)
}
}
}
- val filters by produceState(initialValue = initialFilters) {
- filterSelectionStrategy.filterSelectionStates
- .map { filters ->
- value = filters.toImmutableList()
- filters.mapNotNull { filterState ->
- if (!filterState.isSelected) {
- return@mapNotNull null
- }
- when (filterState.filter) {
- RoomListFilter.Rooms -> MatrixRoomListFilter.Category.Group
- RoomListFilter.People -> MatrixRoomListFilter.Category.People
- RoomListFilter.Unread -> MatrixRoomListFilter.Unread
- RoomListFilter.Favourites -> MatrixRoomListFilter.Favorite
- RoomListFilter.Invites -> MatrixRoomListFilter.Invite
- }
- }
- }
- .collect { filters ->
- val result = MatrixRoomListFilter.All(filters)
- roomListService.allRooms.updateFilter(result)
- }
- }
-
+ val filters by filterSelectionStrategy.filterSelectionStates.collectAsState()
return RoomListFiltersState(
- filterSelectionStates = filters,
+ filterSelectionStates = filters.toImmutableList(),
eventSink = ::handleEvent,
)
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersState.kt
index 104a99cfbd..50cd0decd6 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersState.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersState.kt
@@ -14,7 +14,7 @@ import kotlinx.collections.immutable.toImmutableList
data class RoomListFiltersState(
val filterSelectionStates: ImmutableList,
- val eventSink: (RoomListFiltersEvents) -> Unit,
+ val eventSink: (RoomListFiltersEvent) -> Unit,
) {
val hasAnyFilterSelected = filterSelectionStates.any { it.isSelected }
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersStateProvider.kt
index 7d73727484..7e095512fe 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersStateProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersStateProvider.kt
@@ -24,7 +24,7 @@ class RoomListFiltersStateProvider : PreviewParameterProvider = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = false) },
- eventSink: (RoomListFiltersEvents) -> Unit = {},
+ eventSink: (RoomListFiltersEvent) -> Unit = {},
) = RoomListFiltersState(
filterSelectionStates = filterSelectionStates.toImmutableList(),
eventSink = eventSink,
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersView.kt
index 588da6b9db..9931a98c4e 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersView.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersView.kt
@@ -60,11 +60,11 @@ fun RoomListFiltersView(
modifier: Modifier = Modifier
) {
fun onClearFiltersClick() {
- state.eventSink(RoomListFiltersEvents.ClearSelectedFilters)
+ state.eventSink(RoomListFiltersEvent.ClearSelectedFilters)
}
fun onToggleFilter(filter: RoomListFilter) {
- state.eventSink(RoomListFiltersEvents.ToggleFilter(filter))
+ state.eventSink(RoomListFiltersEvent.ToggleFilter(filter))
}
var scrollToStart by remember { mutableIntStateOf(0) }
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt
index 877e934727..847dbc2c39 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt
@@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
@ContributesBinding(SessionScope::class)
class DefaultFilterSelectionStrategy : FilterSelectionStrategy {
private val selectedFilters = LinkedHashSet()
+ private val availableFilters
+ get() = RoomListFilter.entries.toSet()
override val filterSelectionStates = MutableStateFlow(buildFilters())
@@ -45,7 +47,7 @@ class DefaultFilterSelectionStrategy : FilterSelectionStrategy {
isSelected = true
)
}
- val unselectedFilters = RoomListFilter.entries - selectedFilters - selectedFilters.flatMap { it.incompatibleFilters }.toSet()
+ val unselectedFilters = availableFilters - selectedFilters - selectedFilters.flatMap { it.incompatibleFilters }.toSet()
val unselectedFilterStates = unselectedFilters.map {
FilterSelectionState(
filter = it,
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/FilterSelectionStrategy.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/FilterSelectionStrategy.kt
index f0877b5e0d..ebdb58fa2b 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/FilterSelectionStrategy.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/FilterSelectionStrategy.kt
@@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.StateFlow
interface FilterSelectionStrategy {
val filterSelectionStates: StateFlow>
-
fun select(filter: RoomListFilter)
fun deselect(filter: RoomListFilter)
fun isSelected(filter: RoomListFilter): Boolean
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt
index 5c7ef158fd..30e3aaf0b7 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt
@@ -37,41 +37,41 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun RoomListContextMenu(
contextMenu: RoomListState.ContextMenu.Shown,
canReportRoom: Boolean,
- eventSink: (RoomListEvents.ContextMenuEvents) -> Unit,
+ eventSink: (RoomListEvent.ContextMenuEvent) -> Unit,
onRoomSettingsClick: (roomId: RoomId) -> Unit,
onReportRoomClick: (roomId: RoomId) -> Unit
) {
ModalBottomSheet(
- onDismissRequest = { eventSink(RoomListEvents.HideContextMenu) },
+ onDismissRequest = { eventSink(RoomListEvent.HideContextMenu) },
) {
RoomListModalBottomSheetContent(
contextMenu = contextMenu,
canReportRoom = canReportRoom,
onRoomMarkReadClick = {
- eventSink(RoomListEvents.HideContextMenu)
- eventSink(RoomListEvents.MarkAsRead(contextMenu.roomId))
+ eventSink(RoomListEvent.HideContextMenu)
+ eventSink(RoomListEvent.MarkAsRead(contextMenu.roomId))
},
onRoomMarkUnreadClick = {
- eventSink(RoomListEvents.HideContextMenu)
- eventSink(RoomListEvents.MarkAsUnread(contextMenu.roomId))
+ eventSink(RoomListEvent.HideContextMenu)
+ eventSink(RoomListEvent.MarkAsUnread(contextMenu.roomId))
},
onRoomSettingsClick = {
- eventSink(RoomListEvents.HideContextMenu)
+ eventSink(RoomListEvent.HideContextMenu)
onRoomSettingsClick(contextMenu.roomId)
},
onLeaveRoomClick = {
- eventSink(RoomListEvents.HideContextMenu)
- eventSink(RoomListEvents.LeaveRoom(contextMenu.roomId, needsConfirmation = true))
+ eventSink(RoomListEvent.HideContextMenu)
+ eventSink(RoomListEvent.LeaveRoom(contextMenu.roomId, needsConfirmation = true))
},
onFavoriteChange = { isFavorite ->
- eventSink(RoomListEvents.SetRoomIsFavorite(contextMenu.roomId, isFavorite))
+ eventSink(RoomListEvent.SetRoomIsFavorite(contextMenu.roomId, isFavorite))
},
onClearCacheRoomClick = {
- eventSink(RoomListEvents.HideContextMenu)
- eventSink(RoomListEvents.ClearCacheOfRoom(contextMenu.roomId))
+ eventSink(RoomListEvent.HideContextMenu)
+ eventSink(RoomListEvent.ClearCacheOfRoom(contextMenu.roomId))
},
onReportRoomClick = {
- eventSink(RoomListEvents.HideContextMenu)
+ eventSink(RoomListEvent.HideContextMenu)
onReportRoomClick(contextMenu.roomId)
},
)
@@ -131,16 +131,21 @@ private fun RoomListModalBottomSheetContent(
style = ListItemStyle.Primary,
)
}
+ val (textResId, icon) = if (contextMenu.isFavorite) {
+ CommonStrings.common_favourited to CompoundIcons.FavouriteSolid()
+ } else {
+ CommonStrings.common_favourite to CompoundIcons.Favourite()
+ }
ListItem(
headlineContent = {
Text(
- text = stringResource(id = CommonStrings.common_favourite),
+ text = stringResource(id = textResId),
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(
- CompoundIcons.Favourite(),
+ icon,
)
),
trailingContent = ListItemContent.Switch(
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenu.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenu.kt
index 4c91e14dcd..523e677a57 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenu.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenu.kt
@@ -38,27 +38,27 @@ fun RoomListDeclineInviteMenu(
menu: RoomListState.DeclineInviteMenu.Shown,
canReportRoom: Boolean,
onDeclineAndBlockClick: (RoomListRoomSummary) -> Unit,
- eventSink: (RoomListEvents) -> Unit,
+ eventSink: (RoomListEvent) -> Unit,
) {
ModalBottomSheet(
- onDismissRequest = { eventSink(RoomListEvents.HideDeclineInviteMenu) },
+ onDismissRequest = { eventSink(RoomListEvent.HideDeclineInviteMenu) },
) {
RoomListDeclineInviteMenuContent(
roomName = menu.roomSummary.name ?: menu.roomSummary.roomId.value,
onDeclineClick = {
- eventSink(RoomListEvents.HideDeclineInviteMenu)
- eventSink(RoomListEvents.DeclineInvite(menu.roomSummary, false))
+ eventSink(RoomListEvent.HideDeclineInviteMenu)
+ eventSink(RoomListEvent.DeclineInvite(menu.roomSummary, false))
},
onDeclineAndBlockClick = {
- eventSink(RoomListEvents.HideDeclineInviteMenu)
+ eventSink(RoomListEvent.HideDeclineInviteMenu)
if (canReportRoom) {
onDeclineAndBlockClick(menu.roomSummary)
} else {
- eventSink(RoomListEvents.DeclineInvite(menu.roomSummary, true))
+ eventSink(RoomListEvent.DeclineInvite(menu.roomSummary, true))
}
},
onCancelClick = {
- eventSink(RoomListEvents.HideDeclineInviteMenu)
+ eventSink(RoomListEvent.HideDeclineInviteMenu)
}
)
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvents.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt
similarity index 70%
rename from features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvents.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt
index 36953224c3..3e82ff9809 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvents.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt
@@ -11,24 +11,24 @@ package io.element.android.features.home.impl.roomlist
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.libraries.matrix.api.core.RoomId
-sealed interface RoomListEvents {
- data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
- data object DismissRequestVerificationPrompt : RoomListEvents
- data object DismissBanner : RoomListEvents
- data object DismissNewNotificationSoundBanner : RoomListEvents
- data object ToggleSearchResults : RoomListEvents
- data class ShowContextMenu(val roomSummary: RoomListRoomSummary) : RoomListEvents
+sealed interface RoomListEvent {
+ data class UpdateVisibleRange(val range: IntRange) : RoomListEvent
+ data object DismissRequestVerificationPrompt : RoomListEvent
+ data object DismissBanner : RoomListEvent
+ data object DismissNewNotificationSoundBanner : RoomListEvent
+ data object ToggleSearchResults : RoomListEvent
+ data class ShowContextMenu(val roomSummary: RoomListRoomSummary) : RoomListEvent
- data class AcceptInvite(val roomSummary: RoomListRoomSummary) : RoomListEvents
- data class DeclineInvite(val roomSummary: RoomListRoomSummary, val blockUser: Boolean) : RoomListEvents
- data class ShowDeclineInviteMenu(val roomSummary: RoomListRoomSummary) : RoomListEvents
- data object HideDeclineInviteMenu : RoomListEvents
+ data class AcceptInvite(val roomSummary: RoomListRoomSummary) : RoomListEvent
+ data class DeclineInvite(val roomSummary: RoomListRoomSummary, val blockUser: Boolean) : RoomListEvent
+ data class ShowDeclineInviteMenu(val roomSummary: RoomListRoomSummary) : RoomListEvent
+ data object HideDeclineInviteMenu : RoomListEvent
- sealed interface ContextMenuEvents : RoomListEvents
- data object HideContextMenu : ContextMenuEvents
- data class LeaveRoom(val roomId: RoomId, val needsConfirmation: Boolean) : ContextMenuEvents
- data class MarkAsRead(val roomId: RoomId) : ContextMenuEvents
- data class MarkAsUnread(val roomId: RoomId) : ContextMenuEvents
- data class SetRoomIsFavorite(val roomId: RoomId, val isFavorite: Boolean) : ContextMenuEvents
- data class ClearCacheOfRoom(val roomId: RoomId) : ContextMenuEvents
+ sealed interface ContextMenuEvent : RoomListEvent
+ data object HideContextMenu : ContextMenuEvent
+ data class LeaveRoom(val roomId: RoomId, val needsConfirmation: Boolean) : ContextMenuEvent
+ data class MarkAsRead(val roomId: RoomId) : ContextMenuEvent
+ data class MarkAsUnread(val roomId: RoomId) : ContextMenuEvent
+ data class SetRoomIsFavorite(val roomId: RoomId, val isFavorite: Boolean) : ContextMenuEvent
+ data class ClearCacheOfRoom(val roomId: RoomId) : ContextMenuEvent
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt
index 5fc07ab21c..2010555cd7 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt
@@ -28,9 +28,14 @@ import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.home.impl.datasource.RoomListDataSource
+import io.element.android.features.home.impl.filters.RoomListFilter.Rooms
import io.element.android.features.home.impl.filters.RoomListFiltersState
-import io.element.android.features.home.impl.search.RoomListSearchEvents
+import io.element.android.features.home.impl.filters.into
+import io.element.android.features.home.impl.search.RoomListSearchEvent
import io.element.android.features.home.impl.search.RoomListSearchState
+import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
+import io.element.android.features.home.impl.spacefilters.into
+import io.element.android.features.home.impl.spacefilters.selectedFilter
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.AcceptInvite
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.DeclineInvite
@@ -44,6 +49,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.encryption.RecoveryState
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.timeline.ReceiptType
import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
@@ -57,8 +63,6 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
@@ -68,9 +72,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
-private const val EXTENDED_RANGE_SIZE = 40
-private const val SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS = 300L
-
@Inject
class RoomListPresenter(
private val client: MatrixClient,
@@ -88,6 +89,7 @@ class RoomListPresenter(
private val seenInvitesStore: SeenInvitesStore,
private val announcementService: AnnouncementService,
private val coldStartWatcher: AnalyticsColdStartWatcher,
+ private val spaceFiltersPresenter: Presenter,
) : Presenter {
private val encryptionService = client.encryptionService
@@ -97,6 +99,7 @@ class RoomListPresenter(
val leaveRoomState = leaveRoomPresenter.present()
val filtersState = filtersPresenter.present()
val searchState = searchPresenter.present()
+ val spaceFiltersState = spaceFiltersPresenter.present()
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
LaunchedEffect(Unit) {
@@ -116,45 +119,52 @@ class RoomListPresenter(
val contextMenu = remember { mutableStateOf(RoomListState.ContextMenu.Hidden) }
val declineInviteMenu = remember { mutableStateOf(RoomListState.DeclineInviteMenu.Hidden) }
- fun handleEvent(event: RoomListEvents) {
+ fun handleEvent(event: RoomListEvent) {
when (event) {
- is RoomListEvents.UpdateVisibleRange -> coroutineScope.launch {
- updateVisibleRange(event.range)
+ is RoomListEvent.UpdateVisibleRange -> coroutineScope.launch {
+ roomListDataSource.updateVisibleRange(event.range)
}
- RoomListEvents.DismissRequestVerificationPrompt -> securityBannerDismissed = true
- RoomListEvents.DismissBanner -> securityBannerDismissed = true
- RoomListEvents.DismissNewNotificationSoundBanner -> coroutineScope.launch {
+ RoomListEvent.DismissRequestVerificationPrompt -> securityBannerDismissed = true
+ RoomListEvent.DismissBanner -> securityBannerDismissed = true
+ RoomListEvent.DismissNewNotificationSoundBanner -> coroutineScope.launch {
announcementService.onAnnouncementDismissed(Announcement.NewNotificationSound)
}
- RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
- is RoomListEvents.ShowContextMenu -> {
+ RoomListEvent.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvent.ToggleSearchVisibility)
+ is RoomListEvent.ShowContextMenu -> {
coroutineScope.showContextMenu(event, contextMenu)
}
- is RoomListEvents.HideContextMenu -> {
+ is RoomListEvent.HideContextMenu -> {
contextMenu.value = RoomListState.ContextMenu.Hidden
}
- is RoomListEvents.LeaveRoom -> {
+ is RoomListEvent.LeaveRoom -> {
leaveRoomState.eventSink(LeaveRoomEvent.LeaveRoom(event.roomId, needsConfirmation = event.needsConfirmation))
}
- is RoomListEvents.SetRoomIsFavorite -> coroutineScope.setRoomIsFavorite(event.roomId, event.isFavorite)
- is RoomListEvents.MarkAsRead -> coroutineScope.markAsRead(event.roomId)
- is RoomListEvents.MarkAsUnread -> coroutineScope.markAsUnread(event.roomId)
- is RoomListEvents.AcceptInvite -> {
+ is RoomListEvent.SetRoomIsFavorite -> coroutineScope.setRoomIsFavorite(event.roomId, event.isFavorite)
+ is RoomListEvent.MarkAsRead -> coroutineScope.markAsRead(event.roomId)
+ is RoomListEvent.MarkAsUnread -> coroutineScope.markAsUnread(event.roomId)
+ is RoomListEvent.AcceptInvite -> {
acceptDeclineInviteState.eventSink(
AcceptInvite(event.roomSummary.toInviteData())
)
}
- is RoomListEvents.DeclineInvite -> {
+ is RoomListEvent.DeclineInvite -> {
acceptDeclineInviteState.eventSink(
DeclineInvite(event.roomSummary.toInviteData(), blockUser = event.blockUser, shouldConfirm = false)
)
}
- is RoomListEvents.ShowDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Shown(event.roomSummary)
- RoomListEvents.HideDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Hidden
- is RoomListEvents.ClearCacheOfRoom -> coroutineScope.clearCacheOfRoom(event.roomId)
+ is RoomListEvent.ShowDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Shown(event.roomSummary)
+ RoomListEvent.HideDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Hidden
+ is RoomListEvent.ClearCacheOfRoom -> coroutineScope.clearCacheOfRoom(event.roomId)
}
}
+ LaunchedEffect(filtersState.filterSelectionStates, spaceFiltersState.selectedFilter()) {
+ val selectedFilters = filtersState.selectedFilters().map { filter -> filter.into() }
+ val selectedSpaceFilter = spaceFiltersState.selectedFilter().into()
+ val allFilters = RoomListFilter.All(selectedFilters + listOfNotNull(selectedSpaceFilter))
+ roomListDataSource.updateFilter(allFilters)
+ }
+
val contentState = roomListContentState(
securityBannerDismissed,
showNewNotificationSoundBanner,
@@ -168,6 +178,7 @@ class RoomListPresenter(
leaveRoomState = leaveRoomState,
filtersState = filtersState,
searchState = searchState,
+ spaceFiltersState = spaceFiltersState,
contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
hideInvitesAvatars = hideInvitesAvatar,
@@ -217,7 +228,7 @@ class RoomListPresenter(
showNewNotificationSoundBanner: Boolean,
): RoomListContentState {
val roomSummaries by produceState(initialValue = AsyncData.Loading()) {
- roomListDataSource.allRooms.collect { value = AsyncData.Success(it) }
+ roomListDataSource.roomSummariesFlow.collect { value = AsyncData.Success(it) }
}
val loadingState by roomListDataSource.loadingState.collectAsState()
val showEmpty by remember {
@@ -253,7 +264,7 @@ class RoomListPresenter(
}
@OptIn(ExperimentalCoroutinesApi::class)
- private fun CoroutineScope.showContextMenu(event: RoomListEvents.ShowContextMenu, contextMenuState: MutableState) = launch {
+ private fun CoroutineScope.showContextMenu(event: RoomListEvent.ShowContextMenu, contextMenuState: MutableState) = launch {
val initialState = RoomListState.ContextMenu.Shown(
roomId = event.roomSummary.roomId,
roomName = event.roomSummary.name,
@@ -322,23 +333,4 @@ class RoomListPresenter(
room.clearEventCacheStorage()
}
}
-
- private var currentUpdateVisibleRangeJob: Job? = null
- private fun CoroutineScope.updateVisibleRange(range: IntRange) {
- currentUpdateVisibleRangeJob?.cancel()
- currentUpdateVisibleRangeJob = launch {
- // Debounce the subscription to avoid subscribing to too many rooms
- delay(SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS)
-
- if (range.isEmpty()) return@launch
- val currentRoomList = roomListDataSource.allRooms.first()
- // Use extended range to 'prefetch' the next rooms info
- val midExtendedRangeSize = EXTENDED_RANGE_SIZE / 2
- val extendedRange = range.first until range.last + midExtendedRangeSize
- val roomIds = extendedRange.mapNotNull { index ->
- currentRoomList.getOrNull(index)?.roomId
- }
- roomListDataSource.subscribeToVisibleRooms(roomIds)
- }
- }
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt
index 2b5ca0391e..e0f4943621 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt
@@ -12,6 +12,7 @@ import androidx.compose.runtime.Immutable
import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.search.RoomListSearchState
+import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
@@ -26,11 +27,12 @@ data class RoomListState(
val leaveRoomState: LeaveRoomState,
val filtersState: RoomListFiltersState,
val searchState: RoomListSearchState,
+ val spaceFiltersState: SpaceFiltersState,
val contentState: RoomListContentState,
val acceptDeclineInviteState: AcceptDeclineInviteState,
val hideInvitesAvatars: Boolean,
val canReportRoom: Boolean,
- val eventSink: (RoomListEvents) -> Unit,
+ val eventSink: (RoomListEvent) -> Unit,
) {
val displayFilters = contentState is RoomListContentState.Rooms
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateContextMenuShownProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateContextMenuShownProvider.kt
index 3f2499657e..a1624e4917 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateContextMenuShownProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateContextMenuShownProvider.kt
@@ -15,7 +15,7 @@ open class RoomListStateContextMenuShownProvider : PreviewParameterProvider
get() = sequenceOf(
aContextMenuShown(hasNewContent = true),
- aContextMenuShown(isDm = true),
+ aContextMenuShown(isDm = true, isFavorite = true),
aContextMenuShown(roomName = null)
)
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt
index 7f58e05c7e..97600e0247 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt
@@ -18,6 +18,8 @@ import io.element.android.features.home.impl.model.aRoomListRoomSummary
import io.element.android.features.home.impl.model.anInviteSender
import io.element.android.features.home.impl.search.RoomListSearchState
import io.element.android.features.home.impl.search.aRoomListSearchState
+import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
+import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomEvent
@@ -52,17 +54,19 @@ internal fun aRoomListState(
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
searchState: RoomListSearchState = aRoomListSearchState(),
filtersState: RoomListFiltersState = aRoomListFiltersState(),
+ spaceFiltersState: SpaceFiltersState = anUnselectedSpaceFiltersState(),
contentState: RoomListContentState = aRoomsContentState(),
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
hideInvitesAvatars: Boolean = false,
canReportRoom: Boolean = true,
- eventSink: (RoomListEvents) -> Unit = {}
+ eventSink: (RoomListEvent) -> Unit = {}
) = RoomListState(
contextMenu = contextMenu,
declineInviteMenu = declineInviteMenu,
leaveRoomState = leaveRoomState,
filtersState = filtersState,
searchState = searchState,
+ spaceFiltersState = spaceFiltersState,
contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
hideInvitesAvatars = hideInvitesAvatars,
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt
index 8496a425d2..03fcb5520f 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt
@@ -17,7 +17,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
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.RoomListService
-import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally
+import io.element.android.libraries.matrix.api.roomlist.updateVisibleRange
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
@@ -42,12 +42,11 @@ class RoomListSearchDataSource(
private val roomList = roomListService.createRoomList(
pageSize = PAGE_SIZE,
- initialFilter = RoomListFilter.None,
source = RoomList.Source.All,
coroutineScope = coroutineScope
)
- val roomSummaries: Flow> = roomList.filteredSummaries
+ val roomSummaries: Flow> = roomList.summaries
.map { roomSummaries ->
roomSummaries
.map(roomSummaryFactory::create)
@@ -55,12 +54,8 @@ class RoomListSearchDataSource(
}
.flowOn(coroutineDispatchers.computation)
- suspend fun setIsActive(isActive: Boolean) = coroutineScope {
- if (isActive) {
- roomList.loadAllIncrementally(this)
- } else {
- roomList.reset()
- }
+ suspend fun updateVisibleRange(visibleRange: IntRange) {
+ roomList.updateVisibleRange(visibleRange)
}
suspend fun setSearchQuery(searchQuery: String) = coroutineScope {
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvents.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvent.kt
similarity index 56%
rename from features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvents.kt
rename to features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvent.kt
index d8269fbc04..38ffea8d25 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvents.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvent.kt
@@ -8,7 +8,8 @@
package io.element.android.features.home.impl.search
-sealed interface RoomListSearchEvents {
- data object ToggleSearchVisibility : RoomListSearchEvents
- data object ClearQuery : RoomListSearchEvents
+sealed interface RoomListSearchEvent {
+ data object ToggleSearchVisibility : RoomListSearchEvent
+ data object ClearQuery : RoomListSearchEvent
+ data class UpdateVisibleRange(val range: IntRange) : RoomListSearchEvent
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt
index 05aa88b7a4..763fbb23d0 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt
@@ -21,6 +21,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter
import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.launch
@Inject
class RoomListSearchPresenter(
@@ -37,23 +38,22 @@ class RoomListSearchPresenter(
val coroutineScope = rememberCoroutineScope()
val dataSource = remember { dataSourceFactory.create(coroutineScope) }
- LaunchedEffect(isSearchActive) {
- dataSource.setIsActive(isSearchActive)
- }
-
LaunchedEffect(searchQuery.text) {
dataSource.setSearchQuery(searchQuery.text.toString())
}
- fun handleEvent(event: RoomListSearchEvents) {
+ fun handleEvent(event: RoomListSearchEvent) {
when (event) {
- RoomListSearchEvents.ClearQuery -> {
+ RoomListSearchEvent.ClearQuery -> {
searchQuery.clearText()
}
- RoomListSearchEvents.ToggleSearchVisibility -> {
+ RoomListSearchEvent.ToggleSearchVisibility -> {
isSearchActive = !isSearchActive
searchQuery.clearText()
}
+ is RoomListSearchEvent.UpdateVisibleRange -> coroutineScope.launch {
+ dataSource.updateVisibleRange(visibleRange = event.range)
+ }
}
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchState.kt
index c2d889388b..dd6c406b85 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchState.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchState.kt
@@ -16,5 +16,5 @@ data class RoomListSearchState(
val isSearchActive: Boolean,
val query: TextFieldState,
val results: ImmutableList,
- val eventSink: (RoomListSearchEvents) -> Unit
+ val eventSink: (RoomListSearchEvent) -> Unit
)
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt
index 645eb791ba..e6ec92f68b 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt
@@ -31,7 +31,7 @@ fun aRoomListSearchState(
isSearchActive: Boolean = false,
query: String = "",
results: ImmutableList = persistentListOf(),
- eventSink: (RoomListSearchEvents) -> Unit = { },
+ eventSink: (RoomListSearchEvent) -> Unit = { },
) = RoomListSearchState(
isSearchActive = isSearchActive,
query = TextFieldState(initialText = query),
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt
index f013b602dd..942e924820 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
@@ -38,7 +39,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.components.RoomSummaryRow
import io.element.android.features.home.impl.contentType
import io.element.android.features.home.impl.model.RoomListRoomSummary
-import io.element.android.features.home.impl.roomlist.RoomListEvents
+import io.element.android.features.home.impl.roomlist.RoomListEvent
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -47,6 +48,7 @@ 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.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
@@ -54,12 +56,12 @@ import io.element.android.libraries.ui.strings.CommonStrings
internal fun RoomListSearchView(
state: RoomListSearchState,
hideInvitesAvatars: Boolean,
- eventSink: (RoomListEvents) -> Unit,
+ eventSink: (RoomListEvent) -> Unit,
onRoomClick: (RoomId) -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler(enabled = state.isSearchActive) {
- state.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
+ state.eventSink(RoomListSearchEvent.ToggleSearchVisibility)
}
AnimatedVisibility(
@@ -83,13 +85,13 @@ internal fun RoomListSearchView(
private fun RoomListSearchContent(
state: RoomListSearchState,
hideInvitesAvatars: Boolean,
- eventSink: (RoomListEvents) -> Unit,
+ eventSink: (RoomListEvent) -> Unit,
onRoomClick: (RoomId) -> Unit,
) {
val borderColor = MaterialTheme.colorScheme.tertiary
val strokeWidth = 1.dp
fun onBackButtonClick() {
- state.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
+ state.eventSink(RoomListSearchEvent.ToggleSearchVisibility)
}
fun onRoomClick(room: RoomListRoomSummary) {
@@ -127,7 +129,7 @@ private fun RoomListSearchContent(
),
trailingIcon = if (state.query.text.isNotEmpty()) {
@Composable {
- IconButton(onClick = { state.eventSink(RoomListSearchEvents.ClearQuery) }) {
+ IconButton(onClick = { state.eventSink(RoomListSearchEvent.ClearQuery) }) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_cancel)
@@ -154,7 +156,12 @@ private fun RoomListSearchContent(
.padding(padding)
.consumeWindowInsets(padding)
) {
+ val lazyListState = rememberLazyListState()
+ OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
+ state.eventSink(RoomListSearchEvent.UpdateVisibleRange(visibleRange))
+ }
LazyColumn(
+ state = lazyListState,
modifier = Modifier.weight(1f),
) {
items(
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersEvent.kt
new file mode 100644
index 0000000000..b57b274cd5
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersEvent.kt
@@ -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.home.impl.spacefilters
+
+import io.element.android.libraries.matrix.api.spaces.SpaceServiceFilter
+
+sealed interface SpaceFiltersEvent {
+ // Only valid in Unselected state
+ sealed interface Unselected : SpaceFiltersEvent {
+ data object ShowFilters : Unselected
+ }
+
+ // Only valid in Selecting state
+ sealed interface Selecting : SpaceFiltersEvent {
+ data object Cancel : Selecting
+ data class SelectFilter(val spaceFilter: SpaceServiceFilter) : Selecting
+ }
+
+ // Only valid in Selected state
+ sealed interface Selected : SpaceFiltersEvent {
+ data object ClearSelection : Selected
+ }
+}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersPresenter.kt
new file mode 100644
index 0000000000..9813732bdb
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersPresenter.kt
@@ -0,0 +1,113 @@
+/*
+ * 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.home.impl.spacefilters
+
+import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import dev.zacsweers.metro.Inject
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.spaces.SpaceServiceFilter
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.flow.map
+
+@Inject
+class SpaceFiltersPresenter(
+ private val featureFlagService: FeatureFlagService,
+ private val matrixClient: MatrixClient,
+) : Presenter {
+ @Composable
+ override fun present(): SpaceFiltersState {
+ val isFeatureEnabled by featureFlagService
+ .isFeatureEnabledFlow(FeatureFlags.RoomListSpaceFilters)
+ .collectAsState(initial = false)
+
+ val availableFilters by remember {
+ matrixClient.spaceService.spaceFiltersFlow.map { it.toImmutableList() }
+ }.collectAsState(initial = persistentListOf())
+
+ if (!isFeatureEnabled || availableFilters.isEmpty()) {
+ return SpaceFiltersState.Disabled
+ }
+
+ var selectionMode by remember { mutableStateOf(SelectionMode.Unselected) }
+
+ fun handleUnselectedEvent(event: SpaceFiltersEvent.Unselected) {
+ when (event) {
+ SpaceFiltersEvent.Unselected.ShowFilters -> {
+ selectionMode = SelectionMode.Selecting
+ }
+ }
+ }
+
+ fun handleSelectingEvent(event: SpaceFiltersEvent.Selecting) {
+ when (event) {
+ SpaceFiltersEvent.Selecting.Cancel -> {
+ selectionMode = SelectionMode.Unselected
+ }
+ is SpaceFiltersEvent.Selecting.SelectFilter -> {
+ selectionMode = SelectionMode.Selected(event.spaceFilter)
+ }
+ }
+ }
+
+ fun handleSelectedEvent(event: SpaceFiltersEvent.Selected) {
+ when (event) {
+ SpaceFiltersEvent.Selected.ClearSelection -> {
+ selectionMode = SelectionMode.Unselected
+ }
+ }
+ }
+
+ return when (val mode = selectionMode) {
+ SelectionMode.Unselected -> SpaceFiltersState.Unselected(
+ eventSink = ::handleUnselectedEvent,
+ )
+ SelectionMode.Selecting -> {
+ val searchQuery = rememberTextFieldState()
+ SpaceFiltersState.Selecting(
+ availableFilters = availableFilters,
+ searchQuery = searchQuery,
+ eventSink = ::handleSelectingEvent,
+ )
+ }
+ is SelectionMode.Selected -> {
+ var selectedFilter by remember { mutableStateOf(mode.filter) }
+ // Makes sure the selectedFilter stays in sync with the available filters
+ LaunchedEffect(availableFilters) {
+ val upToDateFilter = availableFilters
+ .firstOrNull { it.spaceRoom.roomId == mode.filter.spaceRoom.roomId }
+ if (upToDateFilter == null) {
+ selectionMode = SelectionMode.Unselected
+ } else {
+ selectedFilter = upToDateFilter
+ }
+ }
+ SpaceFiltersState.Selected(
+ selectedFilter = selectedFilter,
+ eventSink = ::handleSelectedEvent,
+ )
+ }
+ }
+ }
+}
+
+private sealed interface SelectionMode {
+ data object Unselected : SelectionMode
+ data object Selecting : SelectionMode
+ data class Selected(val filter: SpaceServiceFilter) : SelectionMode
+}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersState.kt
new file mode 100644
index 0000000000..347439e853
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersState.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.home.impl.spacefilters
+
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.runtime.Immutable
+import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
+import io.element.android.libraries.matrix.api.spaces.SpaceServiceFilter
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+
+@Immutable
+sealed interface SpaceFiltersState {
+ data object Disabled : SpaceFiltersState
+
+ data class Unselected(
+ val eventSink: (SpaceFiltersEvent.Unselected) -> Unit,
+ ) : SpaceFiltersState
+
+ data class Selecting(
+ val availableFilters: ImmutableList,
+ val searchQuery: TextFieldState,
+ val eventSink: (SpaceFiltersEvent.Selecting) -> Unit,
+ ) : SpaceFiltersState {
+ val visibleFilters: ImmutableList
+ get() {
+ val query = searchQuery.text.toString()
+ if (query.isBlank()) return availableFilters
+ return availableFilters.filter { filter ->
+ filter.spaceRoom.displayName.contains(query, ignoreCase = true) ||
+ (filter.spaceRoom.canonicalAlias?.value ?: "").contains(query, ignoreCase = true)
+ }.toImmutableList()
+ }
+ }
+
+ data class Selected(
+ val selectedFilter: SpaceServiceFilter,
+ val eventSink: (SpaceFiltersEvent.Selected) -> Unit,
+ ) : SpaceFiltersState
+}
+
+fun SpaceFiltersState.selectedFilter(): SpaceServiceFilter? {
+ return when (this) {
+ is SpaceFiltersState.Selected -> this.selectedFilter
+ else -> null
+ }
+}
+
+fun SpaceServiceFilter?.into(): RoomListFilter? {
+ return this?.let { RoomListFilter.Identifiers(descendants) }
+}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersStateProvider.kt
new file mode 100644
index 0000000000..264d122836
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersStateProvider.kt
@@ -0,0 +1,81 @@
+/*
+ * 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.home.impl.spacefilters
+
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.matrix.api.core.RoomAlias
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.spaces.SpaceServiceFilter
+import io.element.android.libraries.previewutils.room.aSpaceRoom
+import kotlinx.collections.immutable.toImmutableList
+
+class SpaceFiltersStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aSelectingSpaceFiltersState(),
+ aSelectingSpaceFiltersState(searchQuery = "Pr")
+ )
+}
+
+fun aDisabledSpaceFiltersState() = SpaceFiltersState.Disabled
+
+fun anUnselectedSpaceFiltersState(
+ eventSink: (SpaceFiltersEvent.Unselected) -> Unit = {},
+) = SpaceFiltersState.Unselected(
+ eventSink = eventSink,
+)
+
+fun aSelectingSpaceFiltersState(
+ availableFilters: List = listOf(
+ aSpaceServiceFilter(
+ displayName = "Work",
+ canonicalAlias = RoomAlias("#work:example.com"),
+ ),
+ aSpaceServiceFilter(
+ displayName = "Personal",
+ roomId = RoomId("!personal:example.com"),
+ ),
+ aSpaceServiceFilter(
+ displayName = "Projects",
+ roomId = RoomId("!projects:example.com"),
+ canonicalAlias = RoomAlias("#projects:example.com"),
+ level = 1,
+ ),
+ aSpaceServiceFilter(
+ displayName = "Gaming",
+ roomId = RoomId("!gaming:example.com"),
+ ),
+ ),
+ searchQuery: String = "",
+ eventSink: (SpaceFiltersEvent.Selecting) -> Unit = {},
+) = SpaceFiltersState.Selecting(
+ availableFilters = availableFilters.toImmutableList(),
+ searchQuery = TextFieldState(searchQuery),
+ eventSink = eventSink,
+)
+
+fun aSelectedSpaceFiltersState(
+ selectedFilter: SpaceServiceFilter = aSpaceServiceFilter(displayName = "Work"),
+ eventSink: (SpaceFiltersEvent.Selected) -> Unit = {},
+) = SpaceFiltersState.Selected(
+ selectedFilter = selectedFilter,
+ eventSink = eventSink,
+)
+
+fun aSpaceServiceFilter(
+ displayName: String = "Space",
+ roomId: RoomId = RoomId("!space:example.com"),
+ canonicalAlias: RoomAlias? = null,
+ level: Int = 0,
+ descendants: List = emptyList(),
+) = SpaceServiceFilter(
+ spaceRoom = aSpaceRoom(displayName = displayName, roomId = roomId, canonicalAlias = canonicalAlias),
+ level = level,
+ descendants = descendants,
+)
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersView.kt
new file mode 100644
index 0000000000..fb77c74203
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersView.kt
@@ -0,0 +1,190 @@
+/*
+ * 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.home.impl.spacefilters
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.SheetValue
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.features.home.impl.R
+import io.element.android.libraries.designsystem.components.avatar.Avatar
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.designsystem.components.avatar.AvatarType
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
+import io.element.android.libraries.designsystem.theme.components.SearchField
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.matrix.api.spaces.SpaceServiceFilter
+import io.element.android.libraries.matrix.ui.model.getAvatarData
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SpaceFiltersView(
+ state: SpaceFiltersState,
+ modifier: Modifier = Modifier
+) {
+ val isSelecting by rememberUpdatedState(state is SpaceFiltersState.Selecting)
+ val sheetState = rememberModalBottomSheetState(
+ skipPartiallyExpanded = true,
+ confirmValueChange = { sheetValueTarget ->
+ // This ensures the hide animation is not cancelled
+ when (sheetValueTarget) {
+ SheetValue.Expanded -> isSelecting
+ else -> true
+ }
+ }
+ )
+ LaunchedEffect(isSelecting) {
+ if (!isSelecting) {
+ sheetState.hide()
+ }
+ }
+ if (sheetState.isVisible || isSelecting) {
+ ModalBottomSheet(
+ modifier = modifier
+ .systemBarsPadding()
+ .navigationBarsPadding(),
+ sheetState = sheetState,
+ onDismissRequest = {
+ if (state is SpaceFiltersState.Selecting) {
+ state.eventSink(SpaceFiltersEvent.Selecting.Cancel)
+ }
+ }
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight(0.9f)
+ ) {
+ if (state is SpaceFiltersState.Selecting) {
+ SpaceFiltersBottomSheetContent(
+ filters = state.visibleFilters,
+ searchQuery = state.searchQuery,
+ onFilterSelected = { filter ->
+ state.eventSink(SpaceFiltersEvent.Selecting.SelectFilter(filter))
+ }
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SpaceFiltersBottomSheetContent(
+ filters: List,
+ searchQuery: TextFieldState,
+ onFilterSelected: (SpaceServiceFilter) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier.padding(vertical = 16.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.screen_roomlist_your_spaces),
+ style = ElementTheme.typography.fontHeadingSmMedium,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ SearchField(
+ state = searchQuery,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ placeholder = stringResource(CommonStrings.action_search),
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ LazyColumn {
+ items(filters) { filter ->
+ SpaceFilterItem(
+ filter = filter,
+ onClick = { onFilterSelected(filter) }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SpaceFilterItem(
+ filter: SpaceServiceFilter,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val spaceRoom = filter.spaceRoom
+ val supportingText = spaceRoom.canonicalAlias?.value
+
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Level-based indentation
+ Spacer(modifier = Modifier.width((16 * filter.level).dp))
+ Avatar(
+ avatarData = spaceRoom.getAvatarData(AvatarSize.RoomSelectRoomListItem),
+ avatarType = AvatarType.Space(),
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Column {
+ Text(
+ text = spaceRoom.displayName,
+ style = ElementTheme.typography.fontBodyLgMedium,
+ color = ElementTheme.colors.textPrimary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (supportingText != null) {
+ Text(
+ text = supportingText,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = ElementTheme.colors.textSecondary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun SpaceFiltersViewPreview(@PreviewParameter(SpaceFiltersStateProvider::class) state: SpaceFiltersState) = ElementPreview {
+ SpaceFiltersView(state = state)
+}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt
index d9e6aaa4d3..707ac73261 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt
@@ -36,7 +36,7 @@ class HomeSpacesPresenter(
val canCreateSpaces by featureFlagsService.isFeatureEnabledFlow(FeatureFlags.CreateSpaces).collectAsState(false)
val hideInvitesAvatar by client.rememberHideInvitesAvatar()
val spaceRooms by remember {
- client.spaceService.spaceRoomsFlow.map { it.toImmutableList() }
+ client.spaceService.topLevelSpacesFlow.map { it.toImmutableList() }
}.collectAsState(persistentListOf())
val seenSpaceInvites by remember {
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt
index 7d7deab688..b1000ecc39 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt
@@ -76,6 +76,7 @@ fun HomeSpacesView(
item {
SpaceHeaderView(
avatarData = space.spaceRoom.getAvatarData(AvatarSize.SpaceHeader),
+ alias = space.spaceRoom.canonicalAlias,
name = space.spaceRoom.displayName,
topic = space.spaceRoom.topic,
visibility = space.spaceRoom.visibility,
diff --git a/features/home/impl/src/main/res/values-cs/translations.xml b/features/home/impl/src/main/res/values-cs/translations.xml
index 1eab814e06..16f9e6b801 100644
--- a/features/home/impl/src/main/res/values-cs/translations.xml
+++ b/features/home/impl/src/main/res/values-cs/translations.xml
@@ -50,6 +50,7 @@ Nemáte žádné nepřečtené zprávy!"
"Označit jako přečtené"
"Označit jako nepřečtené"
"Tato místnost byla aktualizována"
+ "Vaše prostory"
"Zdá se, že používáte nové zařízení. Ověřte přihlášení, abyste měli přístup k zašifrovaným zprávám."
"Ověřte, že jste to vy"
diff --git a/features/home/impl/src/main/res/values-et/translations.xml b/features/home/impl/src/main/res/values-et/translations.xml
index d5b6fa06d4..c1f61bfa29 100644
--- a/features/home/impl/src/main/res/values-et/translations.xml
+++ b/features/home/impl/src/main/res/values-et/translations.xml
@@ -50,6 +50,7 @@ Sul pole ühtegi lugemata sõnumit!"
"Märgi loetuks"
"Märgi mitteloetuks"
"See jututuba on uuendatud"
+ "Sinu kogukonnad"
"Tundub, et kasutad uut seadet. Oma krüptitud sõnumite lugemiseks verifitseeri ta mõne muu oma seadmega."
"Verifitseeri, et see oled sina"
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt
index 37ed6e6909..4002844947 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt
@@ -8,9 +8,6 @@
package io.element.android.features.home.impl
-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
-import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.api.AnnouncementService
@@ -70,9 +67,7 @@ class HomePresenterTest {
),
),
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo(
MatrixUser(A_USER_ID, null, null)
@@ -96,9 +91,7 @@ class HomePresenterTest {
updateUserProfileResult = { _, _, _ -> },
),
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
assertThat(initialState.canReportBug).isFalse()
val finalState = awaitItem()
@@ -115,9 +108,7 @@ class HomePresenterTest {
updateUserProfileResult = { _, _, _ -> },
),
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
assertThat(initialState.showAvatarIndicator).isFalse()
indicatorService.setShowRoomListTopBarIndicator(true)
@@ -139,9 +130,7 @@ class HomePresenterTest {
updateUserProfileResult = { _, _, _ -> },
),
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo(MatrixUser(matrixClient.sessionId))
// No new state is coming
@@ -159,12 +148,10 @@ class HomePresenterTest {
showAnnouncementResult = showAnnouncementResult,
)
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
assertThat(initialState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats)
- initialState.eventSink(HomeEvents.SelectHomeNavigationBarItem(HomeNavigationBarItem.Spaces))
+ initialState.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Spaces))
val finalState = awaitItem()
assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces)
showAnnouncementResult.assertions().isCalledOnce()
@@ -189,7 +176,7 @@ class HomePresenterTest {
assertThat(initialState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats)
assertThat(initialState.showNavigationBar).isTrue()
// User navigate to Spaces
- initialState.eventSink(HomeEvents.SelectHomeNavigationBarItem(HomeNavigationBarItem.Spaces))
+ initialState.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Spaces))
val spaceState = awaitItem()
assertThat(spaceState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces)
// The last space is left
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt
index aaecd90c56..1ce5061356 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt
@@ -16,9 +16,11 @@ import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.aRoomSummary
+import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.testCoroutineDispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -27,9 +29,13 @@ import java.time.Instant
class RoomListDataSourceTest {
@Test
fun `when DateTimeObserver gets a date change, the room summaries are refreshed`() = runTest {
- val roomListService = FakeRoomListService().apply {
+ val roomList = FakeDynamicRoomList().apply {
+ summaries.emit(listOf(aRoomSummary()))
+ }
+ val roomListService = FakeRoomListService(
+ createRoomListLambda = { roomList }
+ ).apply {
postState(RoomListService.State.Running)
- postAllRooms(listOf(aRoomSummary()))
}
val dateTimeObserver = FakeDateTimeObserver()
var dateFormatterResult = "Today"
@@ -42,7 +48,7 @@ class RoomListDataSourceTest {
dateTimeObserver = dateTimeObserver,
)
- roomListDataSource.allRooms.test {
+ roomListDataSource.roomSummariesFlow.test {
// Observe room list items changes
roomListDataSource.launchIn(backgroundScope)
// Get the initial room list
@@ -61,9 +67,11 @@ class RoomListDataSourceTest {
@Test
fun `when DateTimeObserver gets a time zone change, the room summaries are refreshed`() = runTest {
- val roomListService = FakeRoomListService().apply {
+ val roomList = FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary())))
+ val roomListService = FakeRoomListService(
+ createRoomListLambda = { roomList }
+ ).apply {
postState(RoomListService.State.Running)
- postAllRooms(listOf(aRoomSummary()))
}
val dateTimeObserver = FakeDateTimeObserver()
var dateFormatterResult = "Today"
@@ -75,7 +83,7 @@ class RoomListDataSourceTest {
),
dateTimeObserver = dateTimeObserver,
)
- roomListDataSource.allRooms.test {
+ roomListDataSource.roomSummariesFlow.test {
// Observe room list items changes
roomListDataSource.launchIn(backgroundScope)
// Get the initial room list
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEmptyStateResourcesTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEmptyStateResourcesTest.kt
index 250f43ee8f..1762d0d6bf 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEmptyStateResourcesTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEmptyStateResourcesTest.kt
@@ -16,14 +16,14 @@ class RoomListFiltersEmptyStateResourcesTest {
@Test
fun `fromSelectedFilters should return null when selectedFilters is empty`() {
val selectedFilters = emptyList()
- val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
+ val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected = false)
assertThat(result).isNull()
}
@Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only unread filter`() {
val selectedFilters = listOf(RoomListFilter.Unread)
- val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
+ val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected = false)
assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_unreads_empty_state_title)
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
@@ -32,7 +32,7 @@ class RoomListFiltersEmptyStateResourcesTest {
@Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only people filter`() {
val selectedFilters = listOf(RoomListFilter.People)
- val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
+ val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected = false)
assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_people_empty_state_title)
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
@@ -41,7 +41,7 @@ class RoomListFiltersEmptyStateResourcesTest {
@Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only rooms filter`() {
val selectedFilters = listOf(RoomListFilter.Rooms)
- val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
+ val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected = false)
assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_rooms_empty_state_title)
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
@@ -50,7 +50,7 @@ class RoomListFiltersEmptyStateResourcesTest {
@Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only favourites filter`() {
val selectedFilters = listOf(RoomListFilter.Favourites)
- val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
+ val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected = false)
assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_favourites_empty_state_title)
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_favourites_empty_state_subtitle)
@@ -59,7 +59,7 @@ class RoomListFiltersEmptyStateResourcesTest {
@Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only invites filter`() {
val selectedFilters = listOf(RoomListFilter.Invites)
- val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
+ val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected = false)
assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_invites_empty_state_title)
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
@@ -68,7 +68,15 @@ class RoomListFiltersEmptyStateResourcesTest {
@Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has multiple filters`() {
val selectedFilters = listOf(RoomListFilter.Unread, RoomListFilter.People, RoomListFilter.Rooms, RoomListFilter.Favourites)
- val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
+ val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected = false)
+ assertThat(result).isNotNull()
+ assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_title)
+ assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
+ }
+
+ @Test
+ fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when isSpaceFilterSelected is true`() {
+ val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(emptyList(), isSpaceFilterSelected = true)
assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_title)
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt
index df1bc18107..9fb87c0eec 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt
@@ -8,26 +8,22 @@
package io.element.android.features.home.impl.filters
-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
-import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.impl.filters.selection.DefaultFilterSelectionStrategy
import io.element.android.features.home.impl.filters.selection.FilterSelectionState
-import io.element.android.libraries.matrix.api.roomlist.RoomListService
-import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.tests.testutils.awaitLastSequentialItem
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
-import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter
class RoomListFiltersPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createRoomListFiltersPresenter()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
awaitItem().let { state ->
assertThat(state.hasAnyFilterSelected).isFalse()
assertThat(state.filterSelectionStates).containsExactly(
@@ -43,15 +39,12 @@ class RoomListFiltersPresenterTest {
}
@Test
+ @OptIn(ExperimentalCoroutinesApi::class)
fun `present - toggle rooms filter`() = runTest {
- val roomListService = FakeRoomListService()
- val presenter = createRoomListFiltersPresenter(roomListService)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- awaitItem().eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms))
+ val presenter = createRoomListFiltersPresenter()
+ presenter.test {
+ awaitItem().eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms))
awaitLastSequentialItem().let { state ->
-
assertThat(state.hasAnyFilterSelected).isTrue()
assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.Rooms, true),
@@ -62,12 +55,9 @@ class RoomListFiltersPresenterTest {
assertThat(state.selectedFilters()).containsExactly(
RoomListFilter.Rooms,
)
- val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
- assertThat(roomListCurrentFilter.filters).containsExactly(
- MatrixRoomListFilter.Category.Group,
- )
- state.eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms))
+ state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms))
}
+ advanceUntilIdle()
awaitLastSequentialItem().let { state ->
assertThat(state.hasAnyFilterSelected).isFalse()
assertThat(state.filterSelectionStates).containsExactly(
@@ -78,24 +68,21 @@ class RoomListFiltersPresenterTest {
filterSelectionState(RoomListFilter.Invites, false),
).inOrder()
assertThat(state.selectedFilters()).isEmpty()
- val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
- assertThat(roomListCurrentFilter.filters).isEmpty()
}
}
}
@Test
+ @OptIn(ExperimentalCoroutinesApi::class)
fun `present - clear filters event`() = runTest {
- val roomListService = FakeRoomListService()
- val presenter = createRoomListFiltersPresenter(roomListService)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- awaitItem().eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms))
+ val presenter = createRoomListFiltersPresenter()
+ presenter.test {
+ awaitItem().eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms))
awaitLastSequentialItem().let { state ->
assertThat(state.hasAnyFilterSelected).isTrue()
- state.eventSink.invoke(RoomListFiltersEvents.ClearSelectedFilters)
+ state.eventSink.invoke(RoomListFiltersEvent.ClearSelectedFilters)
}
+ advanceUntilIdle()
awaitLastSequentialItem().let { state ->
assertThat(state.hasAnyFilterSelected).isFalse()
}
@@ -108,11 +95,8 @@ private fun filterSelectionState(filter: RoomListFilter, selected: Boolean) = Fi
isSelected = selected,
)
-private fun createRoomListFiltersPresenter(
- roomListService: RoomListService = FakeRoomListService(),
-): RoomListFiltersPresenter {
+private fun TestScope.createRoomListFiltersPresenter(): RoomListFiltersPresenter {
return RoomListFiltersPresenter(
- roomListService = roomListService,
filterSelectionStrategy = DefaultFilterSelectionStrategy(),
)
}
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersViewTest.kt
index 9e99220885..4c361b47f3 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersViewTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersViewTest.kt
@@ -27,7 +27,7 @@ class RoomListFiltersViewTest {
@Test
fun `clicking on filters generates expected Event`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
rule.setContent {
RoomListFiltersView(
state = aRoomListFiltersState(eventSink = eventsRecorder),
@@ -36,14 +36,14 @@ class RoomListFiltersViewTest {
rule.clickOn(R.string.screen_roomlist_filter_rooms)
eventsRecorder.assertList(
listOf(
- RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms),
+ RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms),
)
)
}
@Test
fun `clicking on clear filters generates expected Event`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
rule.setContent {
RoomListFiltersView(
state = aRoomListFiltersState(
@@ -55,7 +55,7 @@ class RoomListFiltersViewTest {
rule.pressTag(TestTags.homeScreenClearFilters.value)
eventsRecorder.assertList(
listOf(
- RoomListFiltersEvents.ClearSelectedFilters,
+ RoomListFiltersEvent.ClearSelectedFilters,
)
)
}
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenuTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenuTest.kt
index b692d49d1e..6be5fe4c16 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenuTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenuTest.kt
@@ -30,7 +30,7 @@ class RoomListContextMenuTest {
@Test
fun `clicking on Mark as read generates expected Events`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
val contextMenu = aContextMenuShown(hasNewContent = true)
rule.setRoomListContextMenu(
contextMenu = contextMenu,
@@ -39,15 +39,15 @@ class RoomListContextMenuTest {
rule.clickOn(R.string.screen_roomlist_mark_as_read)
eventsRecorder.assertList(
listOf(
- RoomListEvents.HideContextMenu,
- RoomListEvents.MarkAsRead(contextMenu.roomId),
+ RoomListEvent.HideContextMenu,
+ RoomListEvent.MarkAsRead(contextMenu.roomId),
)
)
}
@Test
fun `clicking on Mark as unread generates expected Events`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
val contextMenu = aContextMenuShown(hasNewContent = false)
rule.setRoomListContextMenu(
contextMenu = contextMenu,
@@ -56,15 +56,15 @@ class RoomListContextMenuTest {
rule.clickOn(R.string.screen_roomlist_mark_as_unread)
eventsRecorder.assertList(
listOf(
- RoomListEvents.HideContextMenu,
- RoomListEvents.MarkAsUnread(contextMenu.roomId),
+ RoomListEvent.HideContextMenu,
+ RoomListEvent.MarkAsUnread(contextMenu.roomId),
)
)
}
@Test
fun `clicking on Leave room generates expected Events`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
val contextMenu = aContextMenuShown(isDm = false)
rule.setRoomListContextMenu(
contextMenu = contextMenu,
@@ -73,15 +73,15 @@ class RoomListContextMenuTest {
rule.clickOn(CommonStrings.action_leave_room)
eventsRecorder.assertList(
listOf(
- RoomListEvents.HideContextMenu,
- RoomListEvents.LeaveRoom(contextMenu.roomId, needsConfirmation = true),
+ RoomListEvent.HideContextMenu,
+ RoomListEvent.LeaveRoom(contextMenu.roomId, needsConfirmation = true),
)
)
}
@Test
fun `clicking on Report room invokes the expected callback and generates expected Event`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
val contextMenu = aContextMenuShown()
val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit)
rule.setRoomListContextMenu(
@@ -92,13 +92,13 @@ class RoomListContextMenuTest {
onReportRoomClick = callback,
)
rule.clickOn(CommonStrings.action_report_room)
- eventsRecorder.assertSingle(RoomListEvents.HideContextMenu)
+ eventsRecorder.assertSingle(RoomListEvent.HideContextMenu)
callback.assertSuccess()
}
@Test
fun `clicking on Settings invokes the expected callback and generates expected Event`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
val contextMenu = aContextMenuShown()
val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit)
rule.setRoomListContextMenu(
@@ -107,13 +107,13 @@ class RoomListContextMenuTest {
onRoomSettingsClick = callback,
)
rule.clickOn(CommonStrings.common_settings)
- eventsRecorder.assertSingle(RoomListEvents.HideContextMenu)
+ eventsRecorder.assertSingle(RoomListEvent.HideContextMenu)
callback.assertSuccess()
}
@Test
fun `clicking on Favourites generates expected Event`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
val contextMenu = aContextMenuShown(isDm = false, isFavorite = false)
val callback = EnsureNeverCalledWithParam()
rule.setRoomListContextMenu(
@@ -124,7 +124,7 @@ class RoomListContextMenuTest {
rule.clickOn(CommonStrings.common_favourite)
eventsRecorder.assertList(
listOf(
- RoomListEvents.SetRoomIsFavorite(contextMenu.roomId, true),
+ RoomListEvent.SetRoomIsFavorite(contextMenu.roomId, true),
)
)
}
@@ -132,7 +132,7 @@ class RoomListContextMenuTest {
private fun AndroidComposeTestRule<*, *>.setRoomListContextMenu(
contextMenu: RoomListState.ContextMenu.Shown,
canReportRoom: Boolean = false,
- eventSink: (RoomListEvents) -> Unit,
+ eventSink: (RoomListEvent) -> Unit,
onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
) {
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenuTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenuTest.kt
index 0803993a0b..d7f509fda4 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenuTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenuTest.kt
@@ -28,7 +28,7 @@ class RoomListDeclineInviteMenuTest {
@Test
fun `clicking on decline emits the expected Events`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setSafeContent {
RoomListDeclineInviteMenu(
@@ -41,15 +41,15 @@ class RoomListDeclineInviteMenuTest {
rule.clickOn(CommonStrings.action_decline)
eventsRecorder.assertList(
listOf(
- RoomListEvents.HideDeclineInviteMenu,
- RoomListEvents.DeclineInvite(menu.roomSummary, blockUser = false),
+ RoomListEvent.HideDeclineInviteMenu,
+ RoomListEvent.DeclineInvite(menu.roomSummary, blockUser = false),
)
)
}
@Test
fun `clicking on decline and block when canReportRoom=true, it emits the expected Events and callback`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setSafeContent {
RoomListDeclineInviteMenu(
@@ -60,13 +60,13 @@ class RoomListDeclineInviteMenuTest {
)
}
rule.clickOn(CommonStrings.action_decline_and_block)
- val expectedEvents = listOf(RoomListEvents.HideDeclineInviteMenu)
+ val expectedEvents = listOf(RoomListEvent.HideDeclineInviteMenu)
eventsRecorder.assertList(expectedEvents)
}
@Test
fun `clicking on decline and block when canReportRoom=false, it emits the expected Events`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setSafeContent {
RoomListDeclineInviteMenu(
@@ -78,15 +78,15 @@ class RoomListDeclineInviteMenuTest {
}
rule.clickOn(CommonStrings.action_decline_and_block)
val expectedEvents = listOf(
- RoomListEvents.HideDeclineInviteMenu,
- RoomListEvents.DeclineInvite(menu.roomSummary, blockUser = true),
+ RoomListEvent.HideDeclineInviteMenu,
+ RoomListEvent.DeclineInvite(menu.roomSummary, blockUser = true),
)
eventsRecorder.assertList(expectedEvents)
}
@Test
fun `clicking on cancel emits the expected Event`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setSafeContent {
RoomListDeclineInviteMenu(
@@ -97,6 +97,6 @@ class RoomListDeclineInviteMenuTest {
)
}
rule.clickOn(CommonStrings.action_cancel)
- eventsRecorder.assertList(listOf(RoomListEvents.HideDeclineInviteMenu))
+ eventsRecorder.assertList(listOf(RoomListEvent.HideDeclineInviteMenu))
}
}
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt
index a97c189947..99a5550a08 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt
@@ -8,9 +8,6 @@
package io.element.android.features.home.impl.roomlist
-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
-import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.announcement.api.Announcement
@@ -21,9 +18,11 @@ import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFact
import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.filters.aRoomListFiltersState
import io.element.android.features.home.impl.model.createRoomListRoomSummary
-import io.element.android.features.home.impl.search.RoomListSearchEvents
+import io.element.android.features.home.impl.search.RoomListSearchEvent
import io.element.android.features.home.impl.search.RoomListSearchState
import io.element.android.features.home.impl.search.aRoomListSearchState
+import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
+import io.element.android.features.home.impl.spacefilters.aDisabledSpaceFiltersState
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
@@ -58,6 +57,7 @@ import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.aRoomSummary
+import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
@@ -80,6 +80,7 @@ import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
@@ -94,7 +95,10 @@ class RoomListPresenterTest {
@Test
fun `present - load 1 room with success`() = runTest {
- val roomListService = FakeRoomListService()
+ val roomList = FakeDynamicRoomList()
+ val roomListService = FakeRoomListService(
+ createRoomListLambda = { roomList }
+ )
val matrixClient = FakeMatrixClient(
roomListService = roomListService
)
@@ -105,8 +109,8 @@ class RoomListPresenterTest {
presenter.test {
val initialState = consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Skeleton }.last()
assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Skeleton::class.java)
- roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
- roomListService.postAllRooms(
+ roomList.loadingState.emit(RoomList.LoadingState.Loaded(1))
+ roomList.summaries.emit(
listOf(
aRoomSummary(
numUnreadMentions = 1,
@@ -131,9 +135,12 @@ class RoomListPresenterTest {
@Test
fun `present - handle DismissRequestVerificationPrompt`() = runTest {
- val roomListService = FakeRoomListService().apply {
- postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
- }
+ val roomList = FakeDynamicRoomList(
+ loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
+ )
+ val roomListService = FakeRoomListService(
+ createRoomListLambda = { roomList }
+ )
val encryptionService = FakeEncryptionService().apply {
emitRecoveryState(RecoveryState.INCOMPLETE)
}
@@ -141,15 +148,13 @@ class RoomListPresenterTest {
val presenter = createRoomListPresenter(
client = FakeMatrixClient(roomListService = roomListService, encryptionService = encryptionService, syncService = syncService),
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val eventWithContentAsRooms = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
}.last()
val eventSink = eventWithContentAsRooms.eventSink
assertThat(eventWithContentAsRooms.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation)
- eventSink(RoomListEvents.DismissRequestVerificationPrompt)
+ eventSink(RoomListEvent.DismissRequestVerificationPrompt)
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
}
}
@@ -159,9 +164,12 @@ class RoomListPresenterTest {
val encryptionService = FakeEncryptionService().apply {
recoveryStateStateFlow.emit(RecoveryState.DISABLED)
}
- val roomListService = FakeRoomListService().apply {
- postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
- }
+ val roomList = FakeDynamicRoomList(
+ loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
+ )
+ val roomListService = FakeRoomListService(
+ createRoomListLambda = { roomList }
+ )
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
encryptionService = encryptionService,
@@ -173,9 +181,7 @@ class RoomListPresenterTest {
val presenter = createRoomListPresenter(
client = matrixClient,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
}.last()
@@ -194,7 +200,7 @@ class RoomListPresenterTest {
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
encryptionService.emitRecoveryState(RecoveryState.DISABLED)
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.SetUpRecovery)
- nextState.eventSink(RoomListEvents.DismissBanner)
+ nextState.eventSink(RoomListEvent.DismissBanner)
val finalState = awaitItem()
assertThat(finalState.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
}
@@ -207,12 +213,10 @@ class RoomListPresenterTest {
givenGetRoomResult(A_ROOM_ID, room)
}
val presenter = createRoomListPresenter(client = client)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
val summary = createRoomListRoomSummary()
- initialState.eventSink(RoomListEvents.ShowContextMenu(summary))
+ initialState.eventSink(RoomListEvent.ShowContextMenu(summary))
awaitItem().also { state ->
assertThat(state.contextMenu)
@@ -257,7 +261,7 @@ class RoomListPresenterTest {
presenter.test {
val initialState = awaitItem()
val summary = createRoomListRoomSummary()
- initialState.eventSink(RoomListEvents.ShowContextMenu(summary))
+ initialState.eventSink(RoomListEvent.ShowContextMenu(summary))
awaitItem().also { state ->
assertThat(state.contextMenu)
.isEqualTo(
@@ -282,12 +286,10 @@ class RoomListPresenterTest {
givenGetRoomResult(A_ROOM_ID, room)
}
val presenter = createRoomListPresenter(client = client)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
val summary = createRoomListRoomSummary()
- initialState.eventSink(RoomListEvents.ShowContextMenu(summary))
+ initialState.eventSink(RoomListEvent.ShowContextMenu(summary))
val shownState = awaitItem()
assertThat(shownState.contextMenu)
@@ -302,7 +304,7 @@ class RoomListPresenterTest {
)
)
- shownState.eventSink(RoomListEvents.HideContextMenu)
+ shownState.eventSink(RoomListEvent.HideContextMenu)
val hiddenState = awaitItem()
assertThat(hiddenState.contextMenu).isEqualTo(RoomListState.ContextMenu.Hidden)
@@ -315,11 +317,9 @@ class RoomListPresenterTest {
val presenter = createRoomListPresenter(
leaveRoomState = aLeaveRoomState(eventSink = leaveRoomEventsRecorder),
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
- initialState.eventSink(RoomListEvents.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
+ initialState.eventSink(RoomListEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
leaveRoomEventsRecorder.assertSingle(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
cancelAndIgnoreRemainingEvents()
}
@@ -327,7 +327,7 @@ class RoomListPresenterTest {
@Test
fun `present - toggle search menu`() = runTest {
- val eventRecorder = EventsRecorder()
+ val eventRecorder = EventsRecorder()
val searchPresenter: Presenter = Presenter {
aRoomListSearchState(
eventSink = eventRecorder
@@ -336,20 +336,18 @@ class RoomListPresenterTest {
val presenter = createRoomListPresenter(
searchPresenter = searchPresenter,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
eventRecorder.assertEmpty()
- initialState.eventSink(RoomListEvents.ToggleSearchResults)
+ initialState.eventSink(RoomListEvent.ToggleSearchResults)
eventRecorder.assertSingle(
- RoomListSearchEvents.ToggleSearchVisibility
+ RoomListSearchEvent.ToggleSearchVisibility
)
- initialState.eventSink(RoomListEvents.ToggleSearchResults)
+ initialState.eventSink(RoomListEvent.ToggleSearchResults)
eventRecorder.assertList(
listOf(
- RoomListSearchEvents.ToggleSearchVisibility,
- RoomListSearchEvents.ToggleSearchVisibility
+ RoomListSearchEvent.ToggleSearchVisibility,
+ RoomListSearchEvent.ToggleSearchVisibility
)
)
}
@@ -359,17 +357,19 @@ class RoomListPresenterTest {
fun `present - change in notification settings updates the summary for decorations`() = runTest {
val userDefinedMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
val notificationSettingsService = FakeNotificationSettingsService()
- val roomListService = FakeRoomListService()
- roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
- roomListService.postAllRooms(listOf(aRoomSummary(userDefinedNotificationMode = userDefinedMode)))
+ val roomList = FakeDynamicRoomList(
+ summaries = MutableStateFlow(listOf(aRoomSummary(userDefinedNotificationMode = userDefinedMode))),
+ loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
+ )
+ val roomListService = FakeRoomListService(
+ createRoomListLambda = { roomList }
+ )
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
notificationSettingsService = notificationSettingsService
)
val presenter = createRoomListPresenter(client = matrixClient)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
notificationSettingsService.setRoomNotificationMode(A_ROOM_ID, userDefinedMode)
val updatedState = consumeItemsUntilPredicate { state ->
(state.contentState as? RoomListContentState.Rooms)?.summaries.orEmpty().any { summary ->
@@ -394,13 +394,11 @@ class RoomListPresenterTest {
givenGetRoomResult(A_ROOM_ID, room)
}
val presenter = createRoomListPresenter(client = client, analyticsService = analyticsService)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
- initialState.eventSink(RoomListEvents.SetRoomIsFavorite(A_ROOM_ID, true))
+ initialState.eventSink(RoomListEvent.SetRoomIsFavorite(A_ROOM_ID, true))
setIsFavoriteResult.assertions().isCalledOnce().with(value(true))
- initialState.eventSink(RoomListEvents.SetRoomIsFavorite(A_ROOM_ID, false))
+ initialState.eventSink(RoomListEvent.SetRoomIsFavorite(A_ROOM_ID, false))
setIsFavoriteResult.assertions().isCalledExactly(2)
.withSequence(
listOf(value(true)),
@@ -416,17 +414,19 @@ class RoomListPresenterTest {
@Test
fun `present - when room service returns no room, then contentState is Empty`() = runTest {
- val roomListService = FakeRoomListService()
- roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(0))
+ val roomList = FakeDynamicRoomList(
+ loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(0))
+ )
+ val roomListService = FakeRoomListService(
+ createRoomListLambda = { roomList }
+ )
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val presenter = createRoomListPresenter(
client = matrixClient,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
assertThat(awaitItem().contentState).isInstanceOf(RoomListContentState.Empty::class.java)
}
}
@@ -463,23 +463,21 @@ class RoomListPresenterTest {
analyticsService = analyticsService,
notificationCleaner = notificationCleaner,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
allRooms.forEach {
assertThat(it.setUnreadFlagCalls).isEmpty()
}
- initialState.eventSink.invoke(RoomListEvents.MarkAsRead(A_ROOM_ID))
+ initialState.eventSink.invoke(RoomListEvent.MarkAsRead(A_ROOM_ID))
markAsReadResult.assertions().isCalledOnce().with(value(ReceiptType.READ))
assertThat(room.setUnreadFlagCalls).isEqualTo(listOf(false))
clearMessagesForRoomLambda.assertions().isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID))
- initialState.eventSink.invoke(RoomListEvents.MarkAsUnread(A_ROOM_ID_2))
+ initialState.eventSink.invoke(RoomListEvent.MarkAsUnread(A_ROOM_ID_2))
assertThat(room2.setUnreadFlagCalls).isEqualTo(listOf(true))
// Test again with private read receipts
sessionPreferencesStore.setSendPublicReadReceipts(false)
- initialState.eventSink.invoke(RoomListEvents.MarkAsRead(A_ROOM_ID_3))
+ initialState.eventSink.invoke(RoomListEvent.MarkAsRead(A_ROOM_ID_3))
markAsReadResult3.assertions().isCalledOnce().with(value(ReceiptType.READ_PRIVATE))
assertThat(room3.setUnreadFlagCalls).isEqualTo(listOf(false))
clearMessagesForRoomLambda.assertions().isCalledExactly(2)
@@ -502,16 +500,21 @@ class RoomListPresenterTest {
val acceptDeclinePresenter = Presenter {
anAcceptDeclineInviteState(eventSink = eventSinkRecorder)
}
- val roomListService = FakeRoomListService()
- val matrixClient = FakeMatrixClient(
- roomListService = roomListService,
- )
+
val roomSummary = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED,
inviter = aRoomMember(),
)
- roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
- roomListService.postAllRooms(listOf(roomSummary))
+ val roomList = FakeDynamicRoomList(
+ summaries = MutableStateFlow(listOf(roomSummary)),
+ loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
+ )
+ val roomListService = FakeRoomListService(
+ createRoomListLambda = { roomList }
+ )
+ val matrixClient = FakeMatrixClient(
+ roomListService = roomListService,
+ )
val presenter = createRoomListPresenter(
client = matrixClient,
acceptDeclineInvitePresenter = acceptDeclinePresenter
@@ -525,8 +528,8 @@ class RoomListPresenterTest {
it.id == roomSummary.roomId.value
}
- state.eventSink(RoomListEvents.AcceptInvite(roomListRoomSummary))
- state.eventSink(RoomListEvents.DeclineInvite(roomListRoomSummary, blockUser = false))
+ state.eventSink(RoomListEvent.AcceptInvite(roomListRoomSummary))
+ state.eventSink(RoomListEvent.DeclineInvite(roomListRoomSummary, blockUser = false))
val inviteData = roomListRoomSummary.toInviteData()
assert(eventSinkRecorder)
@@ -542,15 +545,20 @@ class RoomListPresenterTest {
@Test
fun `present - UpdateVisibleRange will cancel the previous subscription if called too soon`() = runTest {
val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List -> }
- val roomListService = FakeRoomListService(subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda)
- val matrixClient = FakeMatrixClient(
- roomListService = roomListService,
- )
val roomSummary = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED
)
- roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
- roomListService.postAllRooms(listOf(roomSummary))
+ val roomList = FakeDynamicRoomList(
+ summaries = MutableStateFlow(listOf(roomSummary)),
+ loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
+ )
+ val roomListService = FakeRoomListService(
+ createRoomListLambda = { roomList },
+ subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda,
+ )
+ val matrixClient = FakeMatrixClient(
+ roomListService = roomListService,
+ )
val presenter = createRoomListPresenter(
client = matrixClient,
)
@@ -559,9 +567,9 @@ class RoomListPresenterTest {
it.contentState is RoomListContentState.Rooms
}.last()
- state.eventSink(RoomListEvents.UpdateVisibleRange(IntRange(0, 10)))
+ state.eventSink(RoomListEvent.UpdateVisibleRange(IntRange(0, 10)))
// If called again, it will cancel the current one, which should not result in a test failure
- state.eventSink(RoomListEvents.UpdateVisibleRange(IntRange(0, 11)))
+ state.eventSink(RoomListEvent.UpdateVisibleRange(IntRange(0, 11)))
advanceTimeBy(1.seconds)
subscribeToVisibleRoomsLambda.assertions().isCalledOnce()
}
@@ -571,15 +579,20 @@ class RoomListPresenterTest {
@Test
fun `present - UpdateVisibleRange subscribes to rooms in visible range`() = runTest {
val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List -> }
- val roomListService = FakeRoomListService(subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda)
- val matrixClient = FakeMatrixClient(
- roomListService = roomListService,
- )
val roomSummary = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED
)
- roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
- roomListService.postAllRooms(listOf(roomSummary))
+ val roomList = FakeDynamicRoomList(
+ summaries = MutableStateFlow(listOf(roomSummary)),
+ loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
+ )
+ val roomListService = FakeRoomListService(
+ createRoomListLambda = { roomList },
+ subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda,
+ )
+ val matrixClient = FakeMatrixClient(
+ roomListService = roomListService,
+ )
val presenter = createRoomListPresenter(
client = matrixClient,
)
@@ -588,12 +601,12 @@ class RoomListPresenterTest {
it.contentState is RoomListContentState.Rooms
}.last()
- state.eventSink(RoomListEvents.UpdateVisibleRange(IntRange(0, 10)))
+ state.eventSink(RoomListEvent.UpdateVisibleRange(IntRange(0, 10)))
advanceTimeBy(1.seconds)
subscribeToVisibleRoomsLambda.assertions().isCalledOnce()
// If called again, it will subscribe to the next items
- state.eventSink(RoomListEvents.UpdateVisibleRange(IntRange(0, 11)))
+ state.eventSink(RoomListEvent.UpdateVisibleRange(IntRange(0, 11)))
advanceTimeBy(1.seconds)
subscribeToVisibleRoomsLambda.assertions().isCalledExactly(2)
}
@@ -602,15 +615,20 @@ class RoomListPresenterTest {
@Test
fun `present - notification sound banner`() = runTest {
val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List -> }
- val roomListService = FakeRoomListService(subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda)
- val matrixClient = FakeMatrixClient(
- roomListService = roomListService,
- )
val roomSummary = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED
)
- roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
- roomListService.postAllRooms(listOf(roomSummary))
+ val roomList = FakeDynamicRoomList(
+ summaries = MutableStateFlow(listOf(roomSummary)),
+ loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
+ )
+ val roomListService = FakeRoomListService(
+ createRoomListLambda = { roomList },
+ subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda,
+ )
+ val matrixClient = FakeMatrixClient(
+ roomListService = roomListService,
+ )
val onAnnouncementDismissedResult = lambdaRecorder { }
val announcementService = FakeAnnouncementService(
onAnnouncementDismissedResult = onAnnouncementDismissedResult,
@@ -626,7 +644,7 @@ class RoomListPresenterTest {
assertThat(state.contentAsRooms().showNewNotificationSoundBanner).isFalse()
announcementService.emitAnnouncementsToShow(listOf(Announcement.NewNotificationSound))
assertThat(awaitItem().contentAsRooms().showNewNotificationSoundBanner).isTrue()
- state.eventSink(RoomListEvents.DismissNewNotificationSoundBanner)
+ state.eventSink(RoomListEvent.DismissNewNotificationSoundBanner)
onAnnouncementDismissedResult.assertions().isCalledOnce()
.with(value(Announcement.NewNotificationSound))
// Simulate service updating the value
@@ -644,6 +662,7 @@ class RoomListPresenterTest {
analyticsService: AnalyticsService = FakeAnalyticsService(),
filtersPresenter: Presenter = Presenter { aRoomListFiltersState() },
searchPresenter: Presenter = Presenter { aRoomListSearchState() },
+ spaceFiltersPresenter: Presenter = Presenter { aDisabledSpaceFiltersState() },
acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() },
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
@@ -667,6 +686,7 @@ class RoomListPresenterTest {
searchPresenter = searchPresenter,
sessionPreferencesStore = sessionPreferencesStore,
filtersPresenter = filtersPresenter,
+ spaceFiltersPresenter = spaceFiltersPresenter,
analyticsService = analyticsService,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() },
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt
index a07093aa9f..587e4bc894 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt
@@ -46,7 +46,7 @@ class RoomListViewTest {
@Config(qualifiers = "h1024dp")
@Test
fun `displaying the view automatically sends a couple of UpdateVisibleRangeEvents`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
rule.setRoomListView(
state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
@@ -56,15 +56,15 @@ class RoomListViewTest {
eventsRecorder.assertList(
listOf(
- RoomListEvents.UpdateVisibleRange(IntRange.EMPTY),
- RoomListEvents.UpdateVisibleRange(0..5),
+ RoomListEvent.UpdateVisibleRange(IntRange.EMPTY),
+ RoomListEvent.UpdateVisibleRange(0..5),
)
)
}
@Test
fun `clicking on close recovery key banner emits the expected Event`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
rule.setRoomListView(
state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
@@ -77,12 +77,12 @@ class RoomListViewTest {
val close = rule.activity.getString(CommonStrings.action_close)
rule.onNodeWithContentDescription(close).performClick()
- eventsRecorder.assertSingle(RoomListEvents.DismissBanner)
+ eventsRecorder.assertSingle(RoomListEvent.DismissBanner)
}
@Test
fun `clicking on close setup key banner emits the expected Event`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
rule.setRoomListView(
state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery),
@@ -95,12 +95,12 @@ class RoomListViewTest {
val close = rule.activity.getString(CommonStrings.action_close)
rule.onNodeWithContentDescription(close).performClick()
- eventsRecorder.assertSingle(RoomListEvents.DismissBanner)
+ eventsRecorder.assertSingle(RoomListEvent.DismissBanner)
}
@Test
fun `clicking on continue recovery key banner invokes the expected callback`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
ensureCalledOnce { callback ->
rule.setRoomListView(
state = aRoomListState(
@@ -121,7 +121,7 @@ class RoomListViewTest {
@Test
fun `clicking on continue setup key banner invokes the expected callback`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
ensureCalledOnce { callback ->
rule.setRoomListView(
state = aRoomListState(
@@ -139,7 +139,7 @@ class RoomListViewTest {
@Test
fun `clicking on start chat when the session has no room invokes the expected callback`() {
- val eventsRecorder = EventsRecorder(expectEvents = false)
+ val eventsRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce { callback ->
rule.setRoomListView(
state = aRoomListState(
@@ -154,7 +154,7 @@ class RoomListViewTest {
@Test
fun `clicking on a room invokes the expected callback`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
val state = aRoomListState(
eventSink = eventsRecorder,
)
@@ -178,7 +178,7 @@ class RoomListViewTest {
@Test
fun `clicking on a room twice invokes the expected callback only once`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
val state = aRoomListState(
eventSink = eventsRecorder,
)
@@ -201,7 +201,7 @@ class RoomListViewTest {
@Test
fun `long clicking on a room emits the expected Event`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
val state = aRoomListState(
eventSink = eventsRecorder,
)
@@ -215,12 +215,12 @@ class RoomListViewTest {
eventsRecorder.clear()
rule.onNodeWithText(room0.latestEvent.content().toString()).performTouchInput { longClick() }
- eventsRecorder.assertSingle(RoomListEvents.ShowContextMenu(room0))
+ eventsRecorder.assertSingle(RoomListEvent.ShowContextMenu(room0))
}
@Test
fun `clicking on a room setting invokes the expected callback and emits expected Event`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
val state = aRoomListState(
contextMenu = aContextMenuShown(),
eventSink = eventsRecorder,
@@ -238,12 +238,12 @@ class RoomListViewTest {
rule.clickOn(CommonStrings.common_settings)
}
- eventsRecorder.assertSingle(RoomListEvents.HideContextMenu)
+ eventsRecorder.assertSingle(RoomListEvent.HideContextMenu)
}
@Test
fun `clicking on accept and decline invite emits the expected Events`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
val state = aRoomListState(
eventSink = eventsRecorder,
)
@@ -259,8 +259,8 @@ class RoomListViewTest {
rule.clickOn(CommonStrings.action_decline)
eventsRecorder.assertList(
listOf(
- RoomListEvents.AcceptInvite(invitedRoom),
- RoomListEvents.ShowDeclineInviteMenu(invitedRoom),
+ RoomListEvent.AcceptInvite(invitedRoom),
+ RoomListEvent.ShowDeclineInviteMenu(invitedRoom),
)
)
}
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt
index bf453ea0d9..04a12f8591 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt
@@ -8,9 +8,6 @@
package io.element.android.features.home.impl.search
-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
-import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
@@ -18,7 +15,11 @@ import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventForma
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.room.aRoomSummary
+import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
+import io.element.android.tests.testutils.lambda.assert
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.TestScope
@@ -29,9 +30,7 @@ class RoomListSearchPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createRoomListSearchPresenter()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse()
assertThat(state.query.text.toString()).isEmpty()
@@ -43,16 +42,14 @@ class RoomListSearchPresenterTest {
@Test
fun `present - toggle search visibility`() = runTest {
val presenter = createRoomListSearchPresenter()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse()
- state.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
+ state.eventSink(RoomListSearchEvent.ToggleSearchVisibility)
}
awaitItem().let { state ->
assertThat(state.isSearchActive).isTrue()
- state.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
+ state.eventSink(RoomListSearchEvent.ToggleSearchVisibility)
}
awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse()
@@ -62,14 +59,15 @@ class RoomListSearchPresenterTest {
@Test
fun `present - query search changes`() = runTest {
- val roomListService = FakeRoomListService()
+ val roomList = FakeDynamicRoomList()
+ val roomListService = FakeRoomListService(
+ createRoomListLambda = { roomList }
+ )
val presenter = createRoomListSearchPresenter(roomListService)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
awaitItem().let { state ->
assertThat(
- roomListService.allRooms.currentFilter.value
+ roomList.currentFilter.value
).isEqualTo(
RoomListFilter.None
)
@@ -78,16 +76,16 @@ class RoomListSearchPresenterTest {
awaitItem().let { state ->
assertThat(state.query.text).isEqualTo("Search")
assertThat(
- roomListService.allRooms.currentFilter.value
+ roomList.currentFilter.value
).isEqualTo(
RoomListFilter.NormalizedMatchRoomName("Search")
)
- state.eventSink(RoomListSearchEvents.ClearQuery)
+ state.eventSink(RoomListSearchEvent.ClearQuery)
}
awaitItem().let { state ->
assertThat(state.query.text.toString()).isEmpty()
assertThat(
- roomListService.allRooms.currentFilter.value
+ roomList.currentFilter.value
).isEqualTo(
RoomListFilter.None
)
@@ -97,26 +95,51 @@ class RoomListSearchPresenterTest {
@Test
fun `present - room list changes`() = runTest {
- val roomListService = FakeRoomListService()
+ val roomList = FakeDynamicRoomList()
+ val roomListService = FakeRoomListService(
+ createRoomListLambda = { roomList }
+ )
val presenter = createRoomListSearchPresenter(roomListService)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
awaitItem().let { state ->
assertThat(state.results).isEmpty()
}
- roomListService.postAllRooms(
+ roomList.summaries.emit(
listOf(aRoomSummary())
)
awaitItem().let { state ->
assertThat(state.results).hasSize(1)
}
- roomListService.postAllRooms(emptyList())
+ roomList.summaries.emit(emptyList())
awaitItem().let { state ->
assertThat(state.results).isEmpty()
}
}
}
+
+ @Test
+ fun `present - UpdateVisibleRange triggers pagination when near end`() = runTest {
+ val loadMoreLambda = lambdaRecorder { }
+ val roomList = FakeDynamicRoomList(loadMoreLambda = loadMoreLambda)
+ val roomListService = FakeRoomListService(
+ createRoomListLambda = { roomList }
+ )
+ val presenter = createRoomListSearchPresenter(roomListService)
+ presenter.test {
+ val initialState = awaitItem()
+ // Post some rooms to simulate loaded content
+ val rooms = (1..10).map { aRoomSummary() }
+ roomList.summaries.emit(rooms)
+ skipItems(1)
+
+ // UpdateVisibleRange near end should trigger loadMore
+ initialState.eventSink(RoomListSearchEvent.UpdateVisibleRange(IntRange(0, 9)))
+ // Give time for the coroutine to complete
+ testScheduler.advanceUntilIdle()
+
+ assert(loadMoreLambda).isCalledOnce()
+ }
+ }
}
fun TestScope.createRoomListSearchPresenter(
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersPresenterTest.kt
new file mode 100644
index 0000000000..278a268864
--- /dev/null
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersPresenterTest.kt
@@ -0,0 +1,313 @@
+/*
+ * 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.home.impl.spacefilters
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
+import io.element.android.tests.testutils.awaitLastSequentialItem
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class SpaceFiltersPresenterTest {
+ @Test
+ fun `present - when feature flag is disabled returns Disabled state`() = runTest {
+ val presenter = createSpaceFiltersPresenter(
+ featureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(FeatureFlags.RoomListSpaceFilters.key to false)
+ )
+ )
+ presenter.test {
+ val state = awaitItem()
+ assertThat(state).isEqualTo(SpaceFiltersState.Disabled)
+ }
+ }
+
+ @Test
+ fun `present - when available filters is empty returns Disabled state`() = runTest {
+ val presenter = createSpaceFiltersPresenter(
+ featureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(FeatureFlags.RoomListSpaceFilters.key to true)
+ )
+ )
+ presenter.test {
+ val state = awaitLastSequentialItem()
+ assertThat(state).isEqualTo(SpaceFiltersState.Disabled)
+ }
+ }
+
+ @Test
+ fun `present - when feature flag is enabled and filters exist returns Unselected state`() = runTest {
+ val spaceFilter = aSpaceServiceFilter(displayName = "Test Space")
+ val spaceService = FakeSpaceService()
+ val matrixClient = FakeMatrixClient(spaceService = spaceService)
+
+ val presenter = createSpaceFiltersPresenter(
+ featureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(FeatureFlags.RoomListSpaceFilters.key to true)
+ ),
+ matrixClient = matrixClient,
+ )
+ presenter.test {
+ // Emit filters
+ spaceService.emitSpaceFilters(listOf(spaceFilter))
+
+ val state = awaitLastSequentialItem()
+ assertThat(state).isInstanceOf(SpaceFiltersState.Unselected::class.java)
+ }
+ }
+
+ @Test
+ fun `present - ShowFilters event transitions from Unselected to Selecting`() = runTest {
+ val spaceFilter = aSpaceServiceFilter(displayName = "Test Space")
+ val spaceService = FakeSpaceService()
+ val matrixClient = FakeMatrixClient(spaceService = spaceService)
+
+ val presenter = createSpaceFiltersPresenter(
+ featureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(FeatureFlags.RoomListSpaceFilters.key to true)
+ ),
+ matrixClient = matrixClient,
+ )
+ presenter.test {
+ // Emit filters first
+ spaceService.emitSpaceFilters(listOf(spaceFilter))
+
+ val unselectedState = awaitLastSequentialItem() as SpaceFiltersState.Unselected
+ unselectedState.eventSink(SpaceFiltersEvent.Unselected.ShowFilters)
+
+ val selectingState = awaitLastSequentialItem()
+ assertThat(selectingState).isInstanceOf(SpaceFiltersState.Selecting::class.java)
+ }
+ }
+
+ @Test
+ fun `present - Cancel event in Selecting state transitions back to Unselected`() = runTest {
+ val spaceFilter = aSpaceServiceFilter(displayName = "Test Space")
+ val spaceService = FakeSpaceService()
+ val matrixClient = FakeMatrixClient(spaceService = spaceService)
+
+ val presenter = createSpaceFiltersPresenter(
+ featureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(FeatureFlags.RoomListSpaceFilters.key to true)
+ ),
+ matrixClient = matrixClient,
+ )
+ presenter.test {
+ // Emit filters first
+ spaceService.emitSpaceFilters(listOf(spaceFilter))
+
+ // Start in Unselected
+ val unselectedState = awaitLastSequentialItem() as SpaceFiltersState.Unselected
+ unselectedState.eventSink(SpaceFiltersEvent.Unselected.ShowFilters)
+
+ // Now in Selecting
+ val selectingState = awaitLastSequentialItem() as SpaceFiltersState.Selecting
+ selectingState.eventSink(SpaceFiltersEvent.Selecting.Cancel)
+
+ // Back to Unselected
+ val finalState = awaitLastSequentialItem()
+ assertThat(finalState).isInstanceOf(SpaceFiltersState.Unselected::class.java)
+ }
+ }
+
+ @Test
+ fun `present - SelectFilter event in Selecting state transitions to Selected`() = runTest {
+ val spaceFilter = aSpaceServiceFilter(displayName = "Test Space")
+ val spaceService = FakeSpaceService()
+ val matrixClient = FakeMatrixClient(spaceService = spaceService)
+
+ val presenter = createSpaceFiltersPresenter(
+ featureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(FeatureFlags.RoomListSpaceFilters.key to true)
+ ),
+ matrixClient = matrixClient,
+ )
+ presenter.test {
+ // Emit filters first
+ spaceService.emitSpaceFilters(listOf(spaceFilter))
+
+ // Start in Unselected
+ val unselectedState = awaitLastSequentialItem() as SpaceFiltersState.Unselected
+ unselectedState.eventSink(SpaceFiltersEvent.Unselected.ShowFilters)
+
+ // Now in Selecting
+ val selectingState = awaitLastSequentialItem() as SpaceFiltersState.Selecting
+ selectingState.eventSink(SpaceFiltersEvent.Selecting.SelectFilter(spaceFilter))
+
+ // Now in Selected
+ val selectedState = awaitLastSequentialItem() as SpaceFiltersState.Selected
+ assertThat(selectedState.selectedFilter).isEqualTo(spaceFilter)
+ }
+ }
+
+ @Test
+ fun `present - ClearSelection event in Selected state transitions back to Unselected`() = runTest {
+ val spaceFilter = aSpaceServiceFilter(displayName = "Test Space")
+ val spaceService = FakeSpaceService()
+ val matrixClient = FakeMatrixClient(spaceService = spaceService)
+
+ val presenter = createSpaceFiltersPresenter(
+ featureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(FeatureFlags.RoomListSpaceFilters.key to true)
+ ),
+ matrixClient = matrixClient,
+ )
+ presenter.test {
+ // Emit filters first
+ spaceService.emitSpaceFilters(listOf(spaceFilter))
+
+ // Start in Unselected
+ val unselectedState = awaitLastSequentialItem() as SpaceFiltersState.Unselected
+ unselectedState.eventSink(SpaceFiltersEvent.Unselected.ShowFilters)
+
+ // Now in Selecting
+ val selectingState = awaitLastSequentialItem() as SpaceFiltersState.Selecting
+ selectingState.eventSink(SpaceFiltersEvent.Selecting.SelectFilter(spaceFilter))
+
+ // Now in Selected
+ val selectedState = awaitLastSequentialItem() as SpaceFiltersState.Selected
+ selectedState.eventSink(SpaceFiltersEvent.Selected.ClearSelection)
+
+ // Back to Unselected
+ val finalState = awaitLastSequentialItem()
+ assertThat(finalState).isInstanceOf(SpaceFiltersState.Unselected::class.java)
+ }
+ }
+
+ @Test
+ fun `present - available filters are passed from SpaceService`() = runTest {
+ val spaceFilter1 = aSpaceServiceFilter(displayName = "Work", roomId = RoomId("!work:example.com"))
+ val spaceFilter2 = aSpaceServiceFilter(displayName = "Personal", roomId = RoomId("!personal:example.com"))
+ val spaceFilters = listOf(spaceFilter1, spaceFilter2)
+
+ val spaceService = FakeSpaceService()
+ val matrixClient = FakeMatrixClient(spaceService = spaceService)
+
+ val presenter = createSpaceFiltersPresenter(
+ featureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(FeatureFlags.RoomListSpaceFilters.key to true)
+ ),
+ matrixClient = matrixClient,
+ )
+ presenter.test {
+ // Emit space filters
+ spaceService.emitSpaceFilters(spaceFilters)
+
+ // Start in Unselected
+ val unselectedState = awaitLastSequentialItem() as SpaceFiltersState.Unselected
+ unselectedState.eventSink(SpaceFiltersEvent.Unselected.ShowFilters)
+
+ // Now in Selecting with available filters
+ val selectingState = awaitLastSequentialItem() as SpaceFiltersState.Selecting
+ assertThat(selectingState.availableFilters).containsExactly(spaceFilter1, spaceFilter2).inOrder()
+ }
+ }
+
+ @Test
+ fun `present - selected filter is cleared when space is removed from available filters`() = runTest {
+ val spaceFilter = aSpaceServiceFilter(displayName = "Work", roomId = RoomId("!work:example.com"))
+ val otherSpaceFilter = aSpaceServiceFilter(displayName = "Personal", roomId = RoomId("!personal:example.com"))
+
+ val spaceService = FakeSpaceService()
+ val matrixClient = FakeMatrixClient(spaceService = spaceService)
+
+ val presenter = createSpaceFiltersPresenter(
+ featureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(FeatureFlags.RoomListSpaceFilters.key to true)
+ ),
+ matrixClient = matrixClient,
+ )
+ presenter.test {
+ // Emit filters first
+ spaceService.emitSpaceFilters(listOf(spaceFilter, otherSpaceFilter))
+
+ // Go to Selecting
+ val unselectedState = awaitLastSequentialItem() as SpaceFiltersState.Unselected
+ unselectedState.eventSink(SpaceFiltersEvent.Unselected.ShowFilters)
+
+ // Select the filter
+ val selectingState = awaitLastSequentialItem() as SpaceFiltersState.Selecting
+ selectingState.eventSink(SpaceFiltersEvent.Selecting.SelectFilter(spaceFilter))
+
+ // Verify in Selected state
+ val selectedState = awaitLastSequentialItem() as SpaceFiltersState.Selected
+ assertThat(selectedState.selectedFilter).isEqualTo(spaceFilter)
+
+ // Remove the selected space from available filters (but keep other spaces)
+ spaceService.emitSpaceFilters(listOf(otherSpaceFilter))
+
+ // Should auto-transition to Unselected
+ val finalState = awaitLastSequentialItem()
+ assertThat(finalState).isInstanceOf(SpaceFiltersState.Unselected::class.java)
+ }
+ }
+
+ @Test
+ fun `present - selected filter stays in sync when available filters update`() = runTest {
+ val originalFilter = aSpaceServiceFilter(
+ displayName = "Work",
+ roomId = RoomId("!work:example.com"),
+ descendants = listOf(RoomId("!room1:example.com"))
+ )
+ val updatedFilter = aSpaceServiceFilter(
+ displayName = "Work",
+ roomId = RoomId("!work:example.com"),
+ descendants = listOf(RoomId("!room1:example.com"), RoomId("!room2:example.com"))
+ )
+
+ val spaceService = FakeSpaceService()
+ val matrixClient = FakeMatrixClient(spaceService = spaceService)
+
+ val presenter = createSpaceFiltersPresenter(
+ featureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(FeatureFlags.RoomListSpaceFilters.key to true)
+ ),
+ matrixClient = matrixClient,
+ )
+ presenter.test {
+ // Emit initial space filters
+ spaceService.emitSpaceFilters(listOf(originalFilter))
+
+ // Start in Unselected
+ val unselectedState = awaitLastSequentialItem() as SpaceFiltersState.Unselected
+ unselectedState.eventSink(SpaceFiltersEvent.Unselected.ShowFilters)
+
+ // Now in Selecting
+ val selectingState = awaitLastSequentialItem() as SpaceFiltersState.Selecting
+ selectingState.eventSink(SpaceFiltersEvent.Selecting.SelectFilter(originalFilter))
+
+ // Now in Selected
+ val selectedState = awaitLastSequentialItem() as SpaceFiltersState.Selected
+ assertThat(selectedState.selectedFilter.descendants).hasSize(1)
+
+ // Emit updated space filters
+ spaceService.emitSpaceFilters(listOf(updatedFilter))
+
+ // Selected filter should be updated
+ val updatedSelectedState = awaitLastSequentialItem() as SpaceFiltersState.Selected
+ assertThat(updatedSelectedState.selectedFilter.descendants).hasSize(2)
+ }
+ }
+
+ private fun createSpaceFiltersPresenter(
+ featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
+ matrixClient: FakeMatrixClient = FakeMatrixClient(),
+ ): SpaceFiltersPresenter {
+ return SpaceFiltersPresenter(
+ featureFlagService = featureFlagService,
+ matrixClient = matrixClient,
+ )
+ }
+}
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersViewTest.kt
new file mode 100644
index 0000000000..5c1325b107
--- /dev/null
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersViewTest.kt
@@ -0,0 +1,80 @@
+/*
+ * 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.home.impl.spacefilters
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
+import io.element.android.tests.testutils.EventsRecorder
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class SpaceFiltersViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `clicking on a filter with alias shows display name and alias`() {
+ val filter = aSpaceServiceFilter(
+ displayName = "Test Space",
+ canonicalAlias = A_ROOM_ALIAS,
+ )
+ val eventsRecorder = EventsRecorder()
+ rule.setSpaceFiltersView(
+ state = aSelectingSpaceFiltersState(
+ availableFilters = listOf(filter),
+ eventSink = eventsRecorder,
+ )
+ )
+
+ // Both display name and alias should be visible
+ rule.onNodeWithText(filter.spaceRoom.displayName).assertExists()
+ rule.onNodeWithText(A_ROOM_ALIAS.value).assertExists()
+
+ rule.onNodeWithText(filter.spaceRoom.displayName).performClick()
+
+ eventsRecorder.assertSingle(SpaceFiltersEvent.Selecting.SelectFilter(filter))
+ }
+
+ @Test
+ fun `multiple filters are displayed and clickable`() {
+ val filter1 = aSpaceServiceFilter(displayName = "Space One")
+ val filter2 = aSpaceServiceFilter(displayName = "Space Two")
+ val eventsRecorder = EventsRecorder()
+ rule.setSpaceFiltersView(
+ state = aSelectingSpaceFiltersState(
+ availableFilters = listOf(filter1, filter2),
+ eventSink = eventsRecorder,
+ )
+ )
+
+ // Both filters should be visible
+ rule.onNodeWithText(filter1.spaceRoom.displayName).assertExists()
+ rule.onNodeWithText(filter2.spaceRoom.displayName).assertExists()
+
+ // Click on second filter
+ rule.onNodeWithText(filter2.spaceRoom.displayName).performClick()
+
+ eventsRecorder.assertSingle(SpaceFiltersEvent.Selecting.SelectFilter(filter2))
+ }
+}
+
+private fun AndroidComposeTestRule.setSpaceFiltersView(
+ state: SpaceFiltersState,
+) {
+ setContent {
+ SpaceFiltersView(state = state)
+ }
+}
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
index 49c5ffb85f..1e685d3f5a 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
@@ -376,10 +376,9 @@ private fun JoinRule?.toJoinAuthorisationStatus(): JoinAuthorisationStatus {
return when (this) {
JoinRule.Knock,
is JoinRule.KnockRestricted -> JoinAuthorisationStatus.CanKnock
- JoinRule.Invite,
- JoinRule.Private -> JoinAuthorisationStatus.NeedInvite
+ JoinRule.Invite -> JoinAuthorisationStatus.NeedInvite
is JoinRule.Restricted -> JoinAuthorisationStatus.Restricted
JoinRule.Public -> JoinAuthorisationStatus.CanJoin
- else -> JoinAuthorisationStatus.Unknown
+ is JoinRule.Custom, null -> JoinAuthorisationStatus.Unknown
}
}
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt
index 8992b92056..35cfbb1594 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt
@@ -41,8 +41,8 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.invite.api.InviteData
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
+import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewAliasAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom
-import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewSubtitleAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
@@ -514,7 +514,7 @@ private fun IncompleteContent(
title = {
when (roomIdOrAlias) {
is RoomIdOrAlias.Alias -> {
- RoomPreviewSubtitleAtom(roomIdOrAlias.identifier)
+ RoomPreviewAliasAtom(roomIdOrAlias.identifier)
}
is RoomIdOrAlias.Id -> {
PlaceholderAtom(width = 200.dp, height = 22.dp)
@@ -566,13 +566,12 @@ private fun DefaultLoadedContent(
}
},
subtitle = {
- when {
- contentState.details is LoadedDetails.Space -> {
- SpaceInfoRow(visibility = SpaceRoomVisibility.fromJoinRule(contentState.joinRule))
- }
- contentState.alias != null -> {
- RoomPreviewSubtitleAtom(contentState.alias.value)
- }
+ if (contentState.alias != null) {
+ RoomPreviewAliasAtom(contentState.alias.value)
+ }
+ if (contentState.details is LoadedDetails.Space) {
+ Spacer(Modifier.height(8.dp))
+ SpaceInfoRow(visibility = SpaceRoomVisibility.fromJoinRule(contentState.joinRule))
}
},
description = {
diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt
index aec50d1761..41ed2cce40 100644
--- a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt
+++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt
@@ -1035,34 +1035,6 @@ class JoinRoomPresenterTest {
}
}
- @Test
- fun `present - when room is not known RoomPreview is loaded as Private`() = runTest {
- val client = FakeMatrixClient(
- getNotJoinedRoomResult = { _, _ ->
- Result.success(
- aRoomPreview(
- info = aRoomPreviewInfo(joinRule = JoinRule.Private),
- roomMembershipDetails = {
- Result.success(aRoomMembershipDetails())
- },
- )
- )
- },
- spaceService = FakeSpaceService(
- spaceRoomListResult = { FakeSpaceRoomList() },
- ),
- )
- val presenter = createJoinRoomPresenter(
- matrixClient = client
- )
- presenter.test {
- skipItems(1)
- awaitItem().also { state ->
- assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.NeedInvite)
- }
- }
- }
-
@Test
fun `present - when room is not known RoomPreview is loaded as Custom`() = runTest {
val client = FakeMatrixClient(
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt
index 23c6b6ab2d..52e93d4992 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt
@@ -193,7 +193,8 @@ class LinkNewDeviceFlowNode(
is ErrorType.Unknown -> ErrorScreenType.UnknownError
is ErrorType.UnsupportedProtocol -> ErrorScreenType.UnknownError
}
- // It is OK to push on backstack, since when user leaves the error screen, a new root will be set
+ // It is OK to push on backstack, since when user leaves the error screen, a new root will be set,
+ // or the whole flow will be popped.
backstack.push(NavTarget.Error(error))
}
@@ -263,6 +264,12 @@ class LinkNewDeviceFlowNode(
linkNewDesktopHandler.reset()
backstack.newRoot(NavTarget.Root)
}
+
+ override fun onCancel() {
+ linkNewMobileHandler.reset()
+ linkNewDesktopHandler.reset()
+ callback.onDone()
+ }
}
createNode(buildContext, listOf(callback, navTarget.errorScreenType))
}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorNode.kt
index 70fd3b49a4..e9ad9b4bc2 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorNode.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorNode.kt
@@ -27,6 +27,7 @@ class ErrorNode(
) : Node(buildContext = buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onRetry()
+ fun onCancel()
}
private val callback: Callback = callback()
@@ -38,6 +39,7 @@ class ErrorNode(
modifier = modifier,
errorScreenType = errorScreenType,
onRetry = callback::onRetry,
+ onCancel = callback::onCancel,
)
}
}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt
index 3a77f19f49..9f67e8bc17 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt
@@ -33,6 +33,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.LocalBuildMeta
import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
@@ -41,17 +42,23 @@ import kotlinx.collections.immutable.persistentListOf
fun ErrorView(
errorScreenType: ErrorScreenType,
onRetry: () -> Unit,
+ onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
val appName = LocalBuildMeta.current.applicationName
- BackHandler(onBack = onRetry)
+ BackHandler(onBack = onCancel)
FlowStepPage(
modifier = modifier,
iconStyle = BigIcon.Style.AlertSolid,
title = titleText(errorScreenType, appName),
subTitle = subtitleText(errorScreenType, appName),
content = { Content(errorScreenType) },
- buttons = { Buttons(onRetry) },
+ buttons = {
+ Buttons(
+ onRetry = onRetry,
+ onCancel = onCancel,
+ )
+ },
)
}
@@ -118,11 +125,19 @@ private fun Content(errorScreenType: ErrorScreenType) {
}
@Composable
-private fun Buttons(onRetry: () -> Unit) {
+private fun Buttons(
+ onRetry: () -> Unit,
+ onCancel: () -> Unit,
+) {
Button(
modifier = Modifier.fillMaxWidth(),
- text = stringResource(CommonStrings.action_start_over),
- onClick = onRetry
+ text = stringResource(CommonStrings.action_try_again),
+ onClick = onRetry,
+ )
+ OutlinedButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(CommonStrings.action_cancel),
+ onClick = onCancel,
)
}
@@ -133,6 +148,7 @@ internal fun ErrorViewPreview(@PreviewParameter(ErrorScreenTypeProvider::class)
ErrorView(
errorScreenType = errorScreenType,
onRetry = {},
+ onCancel = {},
)
}
}
diff --git a/features/linknewdevice/impl/src/main/res/values-nb/translations.xml b/features/linknewdevice/impl/src/main/res/values-nb/translations.xml
index af3559d3e8..6b8541b25b 100644
--- a/features/linknewdevice/impl/src/main/res/values-nb/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-nb/translations.xml
@@ -1,16 +1,33 @@
"Skann QR-koden"
+ "Åpne %1$s på en bærbar eller stasjonær datamaskin"
"Skann QR-koden med denne enheten"
"Klar til å skanne"
+ "Åpne %1$s på en datamaskin for å få QR-koden"
+ "Tallene stemmer ikke overens"
+ "Skriv inn 2-sifret kode"
+ "Dette vil bekrefte at forbindelsen til den andre enheten din er sikker."
+ "Skriv inn nummeret som vises på den andre enheten din"
"Kontotilbyderen din støtter ikke %1$s."
"%1$s støttes ikke"
+ "Din kontoleverandør støtter ikke pålogging på en ny enhet med QR-kode."
"QR-kode støttes ikke"
"Påloggingen ble kansellert på den andre enheten."
"Påloggingsforespørsel kansellert"
"Påloggingen er utløpt. Vennligst prøv igjen."
"Påloggingen ble ikke fullført i tide"
+ "Åpne %1$s på den andre enheten"
"Velg %1$s"
+ "Logg inn med QR-kode"
+ "Skann QR-koden som vises her med den andre enheten"
+ "Åpne %1$s på den andre enheten"
+ "Datamaskin"
+ "Laster QR-kode…"
+ "Mobil enhet"
+ "Hvilken type enhet ønsker du å koble til?"
+ "Prøv igjen og påse at du har tastet inn den tosifrede koden riktig. Hvis tallene fortsatt ikke stemmer, må du kontakte kontoleverandøren din."
+ "Tallene stemmer ikke overens"
"En sikker tilkobling kunne ikke opprettes til den nye enheten. Dine eksisterende enheter er fortsatt trygge, og du trenger ikke å bekymre deg for dem."
"Hva nå?"
"Prøv å logge på igjen med en QR-kode i tilfelle dette var et nettverksproblem"
@@ -21,6 +38,8 @@
"Påloggingsforespørsel kansellert"
"Påloggingen ble avvist på den andre enheten."
"Pålogging avslått"
+ "Du trenger ikke å gjøre noe annet."
+ "Din andre enhet er allerede logget inn"
"Påloggingen er utløpt. Vennligst prøv igjen."
"Påloggingen ble ikke fullført i tide"
"Den andre enheten din støtter ikke pålogging på %s med en QR-kode.
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorViewTest.kt
index 8f44182dd4..aa52a70149 100644
--- a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorViewTest.kt
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorViewTest.kt
@@ -12,6 +12,7 @@ import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBackKey
@@ -26,33 +27,45 @@ class ErrorViewTest {
val rule = createAndroidComposeRule()
@Test
- fun `on back pressed - calls the onRetry callback`() {
+ fun `on back pressed - calls the onCancel callback`() {
ensureCalledOnce { callback ->
rule.setErrorView(
- onRetry = callback
+ onCancel = callback,
)
rule.pressBackKey()
}
}
@Test
- fun `on start over button clicked - calls the expected callback`() {
+ fun `on try again button clicked - calls the expected callback`() {
ensureCalledOnce { callback ->
rule.setErrorView(
onRetry = callback
)
- rule.clickOn(CommonStrings.action_start_over)
+ rule.clickOn(CommonStrings.action_try_again)
+ }
+ }
+
+ @Test
+ fun `on cancel button clicked - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setErrorView(
+ onCancel = callback
+ )
+ rule.clickOn(CommonStrings.action_cancel)
}
}
private fun AndroidComposeTestRule.setErrorView(
- onRetry: () -> Unit,
+ onRetry: () -> Unit = EnsureNeverCalled(),
+ onCancel: () -> Unit = EnsureNeverCalled(),
errorScreenType: ErrorScreenType = ErrorScreenType.UnknownError,
) {
setContent {
ErrorView(
errorScreenType = errorScreenType,
onRetry = onRetry,
+ onCancel = onCancel,
)
}
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt
index a19bb12d86..928d98c244 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt
@@ -20,6 +20,7 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dev.zacsweers.metro.AppScope
@@ -196,7 +197,12 @@ class LoginFlowNode(
createNode(buildContext, listOf(callback))
}
NavTarget.QrCode -> {
- createNode(buildContext)
+ val callback = object : QrCodeLoginFlowNode.Callback {
+ override fun navigateBack() {
+ backstack.pop()
+ }
+ }
+ createNode(buildContext, listOf(callback))
}
is NavTarget.ConfirmAccountProvider -> {
val inputs = ConfirmAccountProviderNode.Inputs(
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNode.kt
index 7dad95741b..b272660a28 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNode.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNode.kt
@@ -37,6 +37,7 @@ import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.bindings
+import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.DependencyInjectionGraphOwner
@@ -64,6 +65,12 @@ class QrCodeLoginFlowNode(
buildContext = buildContext,
plugins = plugins,
), DependencyInjectionGraphOwner {
+ interface Callback : Plugin {
+ fun navigateBack()
+ }
+
+ private val callback: Callback = callback()
+
private var authenticationJob: Job? = null
override val graph = qrCodeLoginGraphFactory.create()
@@ -85,7 +92,6 @@ class QrCodeLoginFlowNode(
override fun onBuilt() {
super.onBuilt()
-
observeLoginStep()
}
@@ -113,7 +119,8 @@ class QrCodeLoginFlowNode(
is QrLoginException.Cancelled -> {
backstack.replace(NavTarget.Error(QrCodeErrorScreenType.Cancelled))
}
- is QrLoginException.Expired -> {
+ is QrLoginException.Expired,
+ is QrLoginException.NotFound -> {
backstack.replace(NavTarget.Error(QrCodeErrorScreenType.Expired))
}
is QrLoginException.Declined -> {
@@ -132,7 +139,9 @@ class QrCodeLoginFlowNode(
Timber.e(error, "OIDC metadata is invalid")
backstack.replace(NavTarget.Error(QrCodeErrorScreenType.UnknownError))
}
- else -> {
+ QrLoginException.CheckCodeAlreadySent,
+ QrLoginException.CheckCodeCannotBeSent,
+ QrLoginException.Unknown -> {
Timber.e(error, "Unknown error found")
backstack.replace(NavTarget.Error(QrCodeErrorScreenType.UnknownError))
}
@@ -178,7 +187,13 @@ class QrCodeLoginFlowNode(
}
is NavTarget.Error -> {
val callback = object : QrCodeErrorNode.Callback {
- override fun onRetry() = reset()
+ override fun onRetry() {
+ reset()
+ }
+
+ override fun onCancel() {
+ callback.navigateBack()
+ }
}
createNode(buildContext, plugins = listOf(navTarget.errorType, callback))
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorNode.kt
index 4dc1e48e51..506335163e 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorNode.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorNode.kt
@@ -31,6 +31,7 @@ class QrCodeErrorNode(
) : Node(buildContext = buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onRetry()
+ fun onCancel()
}
private val callback: Callback = callback()
@@ -43,6 +44,7 @@ class QrCodeErrorNode(
errorScreenType = qrCodeErrorScreenType,
appName = buildMeta.productionApplicationName,
onRetry = callback::onRetry,
+ onCancel = callback::onCancel,
)
}
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorView.kt
index d2ec6ce192..a6d9da3de2 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorView.kt
@@ -35,6 +35,7 @@ import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
@@ -44,16 +45,22 @@ fun QrCodeErrorView(
errorScreenType: QrCodeErrorScreenType,
appName: String,
onRetry: () -> Unit,
+ onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
- BackHandler(onBack = onRetry)
+ BackHandler(onBack = onCancel)
FlowStepPage(
modifier = modifier,
iconStyle = BigIcon.Style.AlertSolid,
title = titleText(errorScreenType, appName),
subTitle = subtitleText(errorScreenType, appName),
content = { Content(errorScreenType) },
- buttons = { Buttons(onRetry) },
+ buttons = {
+ Buttons(
+ onRetry = onRetry,
+ onCancel = onCancel,
+ )
+ },
)
}
@@ -118,11 +125,19 @@ private fun Content(errorScreenType: QrCodeErrorScreenType) {
}
@Composable
-private fun Buttons(onRetry: () -> Unit) {
+private fun Buttons(
+ onRetry: () -> Unit,
+ onCancel: () -> Unit,
+) {
Button(
modifier = Modifier.fillMaxWidth(),
- text = stringResource(R.string.screen_qr_code_login_start_over_button),
- onClick = onRetry
+ text = stringResource(CommonStrings.action_try_again),
+ onClick = onRetry,
+ )
+ OutlinedButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(CommonStrings.action_cancel),
+ onClick = onCancel,
)
}
@@ -133,7 +148,8 @@ internal fun QrCodeErrorViewPreview(@PreviewParameter(QrCodeErrorScreenTypeProvi
QrCodeErrorView(
errorScreenType = errorScreenType,
appName = "Element X",
- onRetry = {}
+ onRetry = {},
+ onCancel = {},
)
}
}
diff --git a/features/login/impl/src/main/res/values-nb/translations.xml b/features/login/impl/src/main/res/values-nb/translations.xml
index 10f554ab91..3233329f86 100644
--- a/features/login/impl/src/main/res/values-nb/translations.xml
+++ b/features/login/impl/src/main/res/values-nb/translations.xml
@@ -60,6 +60,8 @@
"Påloggingsforespørsel kansellert"
"Påloggingen ble avvist på den andre enheten."
"Pålogging avslått"
+ "Du trenger ikke å gjøre noe annet."
+ "Din andre enhet er allerede logget inn"
"Påloggingen er utløpt. Vennligst prøv igjen."
"Påloggingen ble ikke fullført i tide"
"Den andre enheten din støtter ikke pålogging på %s med en QR-kode.
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNodeTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNodeTest.kt
index ee99d1170e..9d2628005c 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNodeTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNodeTest.kt
@@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData
+import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
@@ -60,6 +61,9 @@ class QrCodeLoginFlowNodeTest {
qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.Expired)
assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.Expired))
+ qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.NotFound)
+ assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.Expired))
+
qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.Declined)
assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.Declined))
@@ -183,7 +187,11 @@ class QrCodeLoginFlowNodeTest {
)
return QrCodeLoginFlowNode(
buildContext = buildContext,
- plugins = emptyList(),
+ plugins = listOf(
+ object : QrCodeLoginFlowNode.Callback {
+ override fun navigateBack() = lambdaError()
+ }
+ ),
qrCodeLoginGraphFactory = FakeQrCodeLoginGraph.Builder(qrCodeLoginManager),
coroutineDispatchers = coroutineDispatchers,
)
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorViewTest.kt
index c7b6a5e3d6..de0f689220 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorViewTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorViewTest.kt
@@ -12,8 +12,9 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
-import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.qrcode.QrCodeErrorScreenType
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBackKey
@@ -28,10 +29,10 @@ class QrCodeErrorViewTest {
val rule = createAndroidComposeRule()
@Test
- fun `on back pressed - calls the onRetry callback`() {
+ fun `on back pressed - calls the onCancel callback`() {
ensureCalledOnce { callback ->
rule.setQrCodeErrorView(
- onRetry = callback
+ onCancel = callback,
)
rule.pressBackKey()
}
@@ -41,14 +42,25 @@ class QrCodeErrorViewTest {
fun `on try again button clicked - calls the expected callback`() {
ensureCalledOnce { callback ->
rule.setQrCodeErrorView(
- onRetry = callback
+ onRetry = callback,
)
- rule.clickOn(R.string.screen_qr_code_login_start_over_button)
+ rule.clickOn(CommonStrings.action_try_again)
+ }
+ }
+
+ @Test
+ fun `on cancel button clicked - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setQrCodeErrorView(
+ onCancel = callback,
+ )
+ rule.clickOn(CommonStrings.action_cancel)
}
}
private fun AndroidComposeTestRule.setQrCodeErrorView(
- onRetry: () -> Unit,
+ onRetry: () -> Unit = EnsureNeverCalled(),
+ onCancel: () -> Unit = EnsureNeverCalled(),
errorScreenType: QrCodeErrorScreenType = QrCodeErrorScreenType.UnknownError,
appName: String = "Element X",
) {
@@ -56,7 +68,8 @@ class QrCodeErrorViewTest {
QrCodeErrorView(
errorScreenType = errorScreenType,
appName = appName,
- onRetry = onRetry
+ onRetry = onRetry,
+ onCancel = onCancel,
)
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
index 22c4cdbda1..d9c3d17afa 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
@@ -210,10 +210,7 @@ class MessagesPresenter(
// * History sharing is enabled,
// * The room is encrypted, and:
// * The room's history_visibility allows future users to see content.
- val showSharedHistoryIcon = isKeyShareOnInviteEnabled &&
- roomInfo.isEncrypted == true &&
- (roomInfo.historyVisibility == RoomHistoryVisibility.Shared ||
- roomInfo.historyVisibility == RoomHistoryVisibility.WorldReadable)
+ val topBarSharedHistoryIcon = if (isKeyShareOnInviteEnabled) roomInfo.sharedHistoryIcon() else SharedHistoryIcon.NONE
LifecycleResumeEffect(dmRoomMember, roomInfo.isEncrypted) {
if (roomInfo.isEncrypted == true) {
@@ -297,12 +294,24 @@ class MessagesPresenter(
pinnedMessagesBannerState = pinnedMessagesBannerState,
dmUserVerificationState = dmUserVerificationState,
roomMemberModerationState = roomMemberModerationState,
- showSharedHistoryIcon = showSharedHistoryIcon,
+ topBarSharedHistoryIcon = topBarSharedHistoryIcon,
successorRoom = roomInfo.successorRoom,
eventSink = ::handleEvent,
)
}
+ private fun RoomInfo.sharedHistoryIcon(): SharedHistoryIcon {
+ if (isEncrypted == true) {
+ if (historyVisibility == RoomHistoryVisibility.Shared) {
+ return SharedHistoryIcon.SHARED
+ } else if (historyVisibility == RoomHistoryVisibility.WorldReadable) {
+ return SharedHistoryIcon.WORLD_READABLE
+ }
+ }
+
+ return SharedHistoryIcon.NONE
+ }
+
private fun RoomInfo.avatarData(): AvatarData {
return AvatarData(
id = id.value,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
index b37a916b2e..c18fb461e0 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
@@ -54,10 +54,22 @@ data class MessagesState(
val pinnedMessagesBannerState: PinnedMessagesBannerState,
val dmUserVerificationState: IdentityState?,
val roomMemberModerationState: RoomMemberModerationState,
- /** Should the top bar include the "history" icon? */
- val showSharedHistoryIcon: Boolean,
+ /** Type of "shared history" icon to show in the top bar. */
+ val topBarSharedHistoryIcon: SharedHistoryIcon,
val successorRoom: SuccessorRoom?,
val eventSink: (MessagesEvent) -> Unit
) {
val isTombstoned = successorRoom != null
}
+
+/** Type of "shared history" icon to show in the top bar. */
+enum class SharedHistoryIcon {
+ /** Show no icon at all. */
+ NONE,
+
+ /** history_visibility: shared. */
+ SHARED,
+
+ /** history_visibility: world_readable. */
+ WORLD_READABLE
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
index b657b4bbb4..d969ae1491 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
@@ -120,7 +120,7 @@ fun aMessagesState(
pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(),
dmUserVerificationState: IdentityState? = null,
roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(),
- showSharedHistoryIcon: Boolean = false,
+ topBarSharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE,
successorRoom: SuccessorRoom? = null,
eventSink: (MessagesEvent) -> Unit = {},
) = MessagesState(
@@ -148,7 +148,7 @@ fun aMessagesState(
pinnedMessagesBannerState = pinnedMessagesBannerState,
dmUserVerificationState = dmUserVerificationState,
roomMemberModerationState = roomMemberModerationState,
- showSharedHistoryIcon = showSharedHistoryIcon,
+ topBarSharedHistoryIcon = topBarSharedHistoryIcon,
successorRoom = successorRoom,
eventSink = eventSink,
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
index 5d4ccf2d78..559392583f 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
@@ -225,7 +225,7 @@ fun MessagesView(
heroes = state.heroes,
roomCallState = state.roomCallState,
dmUserIdentityState = state.dmUserVerificationState,
- showSharedHistoryIcon = state.showSharedHistoryIcon,
+ sharedHistoryIcon = state.topBarSharedHistoryIcon,
onBackClick = { hidingKeyboard { onBackClick() } },
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
onJoinCallClick = onJoinCallClick,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt
index 03953e0e29..46145ba6b9 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt
@@ -19,6 +19,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@@ -29,6 +30,7 @@ import io.element.android.annotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.MessagesPresenter
+import io.element.android.features.messages.impl.MessagesState
import io.element.android.features.messages.impl.MessagesView
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
@@ -44,11 +46,11 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.RoomScope
-import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@@ -67,22 +69,19 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
-import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
@ContributesNode(RoomScope::class)
@AssistedInject
class ThreadedMessagesNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
- @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val room: JoinedRoom,
private val analyticsService: AnalyticsService,
- messageComposerPresenterFactory: MessageComposerPresenter.Factory,
- timelinePresenterFactory: TimelinePresenter.Factory,
- presenterFactory: MessagesPresenter.Factory,
- actionListPresenterFactory: ActionListPresenter.Factory,
+ private val messageComposerPresenterFactory: MessageComposerPresenter.Factory,
+ private val timelinePresenterFactory: TimelinePresenter.Factory,
+ private val presenterFactory: MessagesPresenter.Factory,
+ private val actionListPresenterFactory: ActionListPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer,
private val permalinkParser: PermalinkParser,
@@ -96,20 +95,29 @@ class ThreadedMessagesNode(
private val inputs = inputs()
private val callback: Callback = callback()
- // TODO use a loading state node to preload this instead of using `runBlocking`
- private val threadedTimeline = runBlocking { room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = inputs.threadRootEventId)).getOrThrow() }
- private val timelineController = TimelineController(room, threadedTimeline)
- private val presenter = presenterFactory.create(
- navigator = this,
- composerPresenter = messageComposerPresenterFactory.create(timelineController, this),
- timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
- // TODO add special processor for threaded timeline
- actionListPresenter = actionListPresenterFactory.create(
- postProcessor = TimelineItemActionPostProcessor.Default,
- timelineMode = timelineController.mainTimelineMode(),
- ),
- timelineController = timelineController,
- )
+ private var timelineController: TimelineController? by mutableStateOf(null)
+ private var presenter: Presenter? by mutableStateOf(null)
+
+ /**
+ * This should be fast to load, but not faster than several UI frames, which will cause ANRs.
+ * We'll load the [presenter] in an async way to prevent this.
+ */
+ private suspend fun createPresenter(): Presenter {
+ val threadedTimeline = room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = inputs.threadRootEventId)).getOrThrow()
+ val timelineController = TimelineController(room, threadedTimeline)
+ this.timelineController = timelineController
+ return presenterFactory.create(
+ navigator = this,
+ composerPresenter = messageComposerPresenterFactory.create(timelineController, this),
+ timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
+ // TODO add special processor for threaded timeline
+ actionListPresenter = actionListPresenterFactory.create(
+ postProcessor = TimelineItemActionPostProcessor.Default,
+ timelineMode = timelineController.mainTimelineMode(),
+ ),
+ timelineController = timelineController,
+ )
+ }
interface Callback : Plugin {
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean
@@ -130,7 +138,10 @@ class ThreadedMessagesNode(
super.onBuilt()
lifecycle.subscribe(
onCreate = {
- sessionCoroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) }
+ analyticsService.capture(room.toAnalyticsViewRoom())
+ lifecycleScope.launch {
+ presenter = createPresenter()
+ }
},
onStart = {
appNavigationStateService.onNavigateToThread(id, inputs.threadRootEventId)
@@ -231,56 +242,61 @@ class ThreadedMessagesNode(
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) {
- val state = presenter.present()
- OnLifecycleEvent { _, event ->
- when (event) {
- Lifecycle.Event.ON_PAUSE -> state.composerState.eventSink(MessageComposerEvent.SaveDraft)
- else -> Unit
- }
- }
- MessagesView(
- state = state,
- onBackClick = this::navigateUp,
- onRoomDetailsClick = {},
- onEventContentClick = { isLive, event ->
- if (isLive) {
- callback.handleEventClick(timelineController.mainTimelineMode(), event)
- } else {
- val detachedTimelineMode = timelineController.detachedTimelineMode()
- if (detachedTimelineMode != null) {
- callback.handleEventClick(detachedTimelineMode, event)
- } else {
- false
- }
+ // Only display the actual UI and lifecycle logic if the presenter is loaded
+ presenter?.present()?.let { state ->
+ OnLifecycleEvent { _, event ->
+ when (event) {
+ Lifecycle.Event.ON_PAUSE -> state.composerState.eventSink(MessageComposerEvent.SaveDraft)
+ else -> Unit
}
- },
- onUserDataClick = callback::navigateToRoomMemberDetails,
- onLinkClick = { url, customTab ->
- onLinkClick(
- activity = activity,
- darkTheme = isDark,
- url = url,
- eventSink = state.timelineState.eventSink,
- customTab = customTab,
- )
- },
- onSendLocationClick = callback::navigateToSendLocation,
- onCreatePollClick = callback::navigateToCreatePoll,
- onJoinCallClick = { callback.navigateToRoomCall(room.roomId) },
- onViewAllPinnedMessagesClick = {},
- modifier = modifier,
- knockRequestsBannerView = {},
- )
-
- var focusedEventId by rememberSaveable {
- mutableStateOf(inputs.focusedEventId)
- }
- LaunchedEffect(Unit) {
- focusedEventId?.also { eventId ->
- state.timelineState.eventSink(TimelineEvent.FocusOnEvent(eventId))
}
- // Reset the focused event id to null to avoid refocusing when restoring node.
- focusedEventId = null
+
+ MessagesView(
+ state = state,
+ onBackClick = this::navigateUp,
+ onRoomDetailsClick = {},
+ onEventContentClick = { isLive, event ->
+ timelineController?.let { controller ->
+ if (isLive) {
+ callback.handleEventClick(controller.mainTimelineMode(), event)
+ } else {
+ val detachedTimelineMode = controller.detachedTimelineMode()
+ if (detachedTimelineMode != null) {
+ callback.handleEventClick(detachedTimelineMode, event)
+ } else {
+ false
+ }
+ }
+ } == true
+ },
+ onUserDataClick = callback::navigateToRoomMemberDetails,
+ onLinkClick = { url, customTab ->
+ onLinkClick(
+ activity = activity,
+ darkTheme = isDark,
+ url = url,
+ eventSink = state.timelineState.eventSink,
+ customTab = customTab,
+ )
+ },
+ onSendLocationClick = callback::navigateToSendLocation,
+ onCreatePollClick = callback::navigateToCreatePoll,
+ onJoinCallClick = { callback.navigateToRoomCall(room.roomId) },
+ onViewAllPinnedMessagesClick = {},
+ modifier = modifier,
+ knockRequestsBannerView = {},
+ )
+
+ var focusedEventId by rememberSaveable {
+ mutableStateOf(inputs.focusedEventId)
+ }
+ LaunchedEffect(Unit) {
+ focusedEventId?.also { eventId ->
+ state.timelineState.eventSink(TimelineEvent.FocusOnEvent(eventId))
+ }
+ // Reset the focused event id to null to avoid refocusing when restoring node.
+ focusedEventId = null
+ }
}
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt
index f25957e07f..96f86aecbd 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt
@@ -20,6 +20,8 @@ import androidx.compose.ui.unit.dp
const val MIN_HEIGHT_IN_DP = 100
const val MAX_HEIGHT_IN_DP = 360
const val DEFAULT_ASPECT_RATIO = 1.33f
+const val MIN_ASPECT_RATIO = 0.001f
+const val MAX_ASPECT_RATIO = 10f
@Composable
fun TimelineItemAspectRatioBox(
@@ -30,7 +32,8 @@ fun TimelineItemAspectRatioBox(
maxHeight: Int = MAX_HEIGHT_IN_DP,
content: @Composable (BoxScope.() -> Unit),
) {
- val safeAspectRatio = aspectRatio ?: DEFAULT_ASPECT_RATIO
+ // Make sure the aspect ratio is not extremely large, otherwise the resulting size can crash Compose
+ val safeAspectRatio = aspectRatio?.coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) ?: DEFAULT_ASPECT_RATIO
Box(
modifier = modifier
.heightIn(min = minHeight.dp, max = maxHeight.dp)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt
index c138378a3a..03ce564eff 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt
@@ -114,8 +114,10 @@ private fun ReactionSummaryViewContent(
LaunchedEffect(pagerState.currentPage) {
selectedReactionKey = summary.reactions[pagerState.currentPage].key
val visibleInfo = reactionListState.layoutInfo.visibleItemsInfo
- if (selectedReactionIndex <= visibleInfo.first().index || selectedReactionIndex >= visibleInfo.last().index) {
- reactionListState.animateScrollToItem(selectedReactionIndex)
+ if (visibleInfo.isNotEmpty()) {
+ if (selectedReactionIndex <= visibleInfo.first().index || selectedReactionIndex >= visibleInfo.last().index) {
+ reactionListState.animateScrollToItem(selectedReactionIndex)
+ }
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
index 3d8467729a..1db6ad304c 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
@@ -50,6 +50,11 @@ import kotlinx.collections.immutable.toImmutableList
import org.jsoup.nodes.Document
import kotlin.time.Duration
+private const val MIN_IMAGE_SIZE = 1L
+private const val MAX_IMAGE_SIZE = 10_000L
+private const val MIN_ASPECT_RATIO = 0.001f
+private const val MAX_ASPECT_RATIO = 10f
+
@Inject
class TimelineItemContentMessageFactory(
private val fileSizeFormatter: FileSizeFormatter,
@@ -83,7 +88,10 @@ class TimelineItemContentMessageFactory(
val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedCaption = dom?.let(::parseHtml)
?: messageType.caption?.withLinks()
- val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
+ // Coerce the image sizes and prevent invalid aspect ratios, which can cause crashes
+ val width = messageType.info?.width?.coerceIn(MIN_IMAGE_SIZE, MAX_IMAGE_SIZE)
+ val height = messageType.info?.height?.coerceIn(MIN_IMAGE_SIZE, MAX_IMAGE_SIZE)
+ val aspectRatio = aspectRatioOf(width, height)?.coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO)
TimelineItemImageContent(
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
@@ -94,10 +102,10 @@ class TimelineItemContentMessageFactory(
thumbnailSource = messageType.info?.thumbnailSource,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
blurhash = messageType.info?.blurhash,
- width = messageType.info?.width?.toInt(),
- height = messageType.info?.height?.toInt(),
- thumbnailWidth = messageType.info?.thumbnailInfo?.width?.toInt(),
- thumbnailHeight = messageType.info?.thumbnailInfo?.height?.toInt(),
+ width = width?.toInt(),
+ height = height?.toInt(),
+ thumbnailWidth = messageType.info?.thumbnailInfo?.width?.coerceIn(MIN_IMAGE_SIZE, MAX_IMAGE_SIZE)?.toInt(),
+ thumbnailHeight = messageType.info?.thumbnailInfo?.height?.coerceIn(MIN_IMAGE_SIZE, MAX_IMAGE_SIZE)?.toInt(),
aspectRatio = aspectRatio,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = fileExtensionExtractor.extractFromName(messageType.filename)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt
index eb756a7369..5d3746f82e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt
@@ -30,6 +30,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.messages.impl.SharedHistoryIcon
import io.element.android.features.messages.impl.timeline.components.CallMenuItem
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.aStandByCallState
@@ -63,7 +64,7 @@ internal fun MessagesViewTopBar(
heroes: ImmutableList,
roomCallState: RoomCallState,
dmUserIdentityState: IdentityState?,
- showSharedHistoryIcon: Boolean,
+ sharedHistoryIcon: SharedHistoryIcon,
onRoomDetailsClick: () -> Unit,
onJoinCallClick: () -> Unit,
onBackClick: () -> Unit,
@@ -110,12 +111,18 @@ internal fun MessagesViewTopBar(
else -> Unit
}
- if (showSharedHistoryIcon) {
- Icon(
+ when (sharedHistoryIcon) {
+ SharedHistoryIcon.NONE -> Unit
+ SharedHistoryIcon.SHARED -> Icon(
imageVector = CompoundIcons.History(),
tint = ElementTheme.colors.iconInfoPrimary,
contentDescription = stringResource(CommonStrings.common_shared_history),
)
+ SharedHistoryIcon.WORLD_READABLE -> Icon(
+ imageVector = CompoundIcons.UserProfileSolid(),
+ tint = ElementTheme.colors.iconInfoPrimary,
+ contentDescription = stringResource(CommonStrings.common_world_readable_history),
+ )
}
}
},
@@ -178,7 +185,7 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
heroes: ImmutableList = persistentListOf(),
roomCallState: RoomCallState = RoomCallState.Unavailable,
dmUserIdentityState: IdentityState? = null,
- showSharedHistoryIcon: Boolean = false,
+ sharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE,
) = MessagesViewTopBar(
roomName = roomName,
roomAvatar = roomAvatar,
@@ -186,7 +193,7 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
heroes = heroes,
roomCallState = roomCallState,
dmUserIdentityState = dmUserIdentityState,
- showSharedHistoryIcon = showSharedHistoryIcon,
+ sharedHistoryIcon = sharedHistoryIcon,
onRoomDetailsClick = {},
onJoinCallClick = {},
onBackClick = {},
@@ -223,7 +230,12 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
AMessagesViewTopBar(
roomName = "A DM with shared history",
dmUserIdentityState = IdentityState.Verified,
- showSharedHistoryIcon = true,
+ sharedHistoryIcon = SharedHistoryIcon.SHARED,
+ )
+ HorizontalDivider()
+ AMessagesViewTopBar(
+ roomName = "A room with world_readable history",
+ sharedHistoryIcon = SharedHistoryIcon.WORLD_READABLE,
)
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt
index 56b0e402d2..9b5961c364 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt
@@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
@@ -69,6 +70,7 @@ class DefaultVoiceMessageComposerPresenter(
}
private val permissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.RECORD_AUDIO)
+ private var pendingEvent: VoiceMessageRecorderEvent.Start? = null
private val mediaSender = mediaSenderFactory.create(timelineMode)
@Composable
@@ -77,8 +79,7 @@ class DefaultVoiceMessageComposerPresenter(
val recorderState by voiceRecorder.state.collectAsState(initial = VoiceRecorderState.Idle)
val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.Initial)
val keepScreenOn by remember { derivedStateOf { recorderState is VoiceRecorderState.Recording } }
-
- val permissionState = permissionsPresenter.present()
+ val permissionState by rememberUpdatedState(permissionsPresenter.present())
var isSending by remember { mutableStateOf(false) }
var showSendFailureDialog by remember { mutableStateOf(false) }
@@ -88,6 +89,15 @@ class DefaultVoiceMessageComposerPresenter(
player.setMedia(recording.file.path)
}
+ LaunchedEffect(permissionState.permissionGranted) {
+ if (permissionState.permissionGranted) {
+ pendingEvent?.let {
+ localCoroutineScope.startRecording()
+ pendingEvent = null
+ }
+ }
+ }
+
fun handleLifecycleEvent(event: Lifecycle.Event) {
when (event) {
Lifecycle.Event.ON_PAUSE -> {
@@ -102,6 +112,7 @@ class DefaultVoiceMessageComposerPresenter(
}
fun handleVoiceMessageRecorderEvent(event: VoiceMessageRecorderEvent) {
+ pendingEvent = null
when (event) {
VoiceMessageRecorderEvent.Start -> {
Timber.v("Voice message record button pressed")
@@ -111,6 +122,7 @@ class DefaultVoiceMessageComposerPresenter(
}
else -> {
Timber.i("Voice message permission needed")
+ pendingEvent = VoiceMessageRecorderEvent.Start
permissionState.eventSink(PermissionsEvent.RequestPermissions)
}
}
diff --git a/features/messages/impl/src/main/res/values-et/translations.xml b/features/messages/impl/src/main/res/values-et/translations.xml
index 80b6307500..8da4d23f79 100644
--- a/features/messages/impl/src/main/res/values-et/translations.xml
+++ b/features/messages/impl/src/main/res/values-et/translations.xml
@@ -31,7 +31,7 @@
"Teade selle sõnumi kohta edastatakse sinu koduserveri haldajale. Haldajal ei ole võimalik lugeda krüptitud sõnumite sisu."
"Sellest sisust teatamise põhjus"
"Kaamera"
- "Tee pilt"
+ "Pildista"
"Salvesta video"
"Manus"
"Fotode ja videote galerii"
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
index 8fe20cbebe..d52179f3ef 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
@@ -1217,7 +1217,7 @@ class MessagesPresenterTest {
}
@Test
- fun `present - shows a "history" icon if the room is encrypted and history is shared`() = runTest {
+ fun `present - shows a history icon if the room is encrypted and history is shared`() = runTest {
val presenter = createMessagesPresenter(
joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
@@ -1233,7 +1233,28 @@ class MessagesPresenterTest {
awaitItem()
runCurrent()
val state = awaitItem()
- assertThat(state.showSharedHistoryIcon).isTrue()
+ assertThat(state.topBarSharedHistoryIcon).isEqualTo(SharedHistoryIcon.SHARED)
+ }
+ }
+
+ @Test
+ fun `present - shows a "world_readable" icon if the room is encrypted and history is world_readable`() = runTest {
+ val presenter = createMessagesPresenter(
+ joinedRoom = FakeJoinedRoom(
+ baseRoom = FakeBaseRoom(
+ roomPermissions = roomPermissions(),
+ initialRoomInfo = aRoomInfo(isEncrypted = true, historyVisibility = RoomHistoryVisibility.WorldReadable),
+ ),
+ ),
+ featureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(FeatureFlags.EnableKeyShareOnInvite.key to true)
+ )
+ )
+ presenter.testWithLifecycleOwner {
+ awaitItem()
+ runCurrent()
+ val state = awaitItem()
+ assertThat(state.topBarSharedHistoryIcon).isEqualTo(SharedHistoryIcon.WORLD_READABLE)
}
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultRoomAliasSuggestionsDataSourceTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultRoomAliasSuggestionsDataSourceTest.kt
index 5cd3607701..0c101a7440 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultRoomAliasSuggestionsDataSourceTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultRoomAliasSuggestionsDataSourceTest.kt
@@ -15,6 +15,7 @@ import io.element.android.features.messages.impl.messagecomposer.suggestions.Roo
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.room.aRoomSummary
+import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -22,7 +23,10 @@ import org.junit.Test
class DefaultRoomAliasSuggestionsDataSourceTest {
@Test
fun `getAllRoomAliasSuggestions must emit a list of room alias suggestions`() = runTest {
- val roomListService = FakeRoomListService()
+ val roomList = FakeDynamicRoomList()
+ val roomListService = FakeRoomListService(
+ createRoomListLambda = { roomList }
+ )
val sut = DefaultRoomAliasSuggestionsDataSource(
roomListService
)
@@ -31,7 +35,7 @@ class DefaultRoomAliasSuggestionsDataSourceTest {
)
sut.getAllRoomAliasSuggestions().test {
assertThat(awaitItem()).isEmpty()
- roomListService.postAllRooms(
+ roomList.summaries.emit(
listOf(
aRoomSummary(roomId = A_ROOM_ID_2, canonicalAlias = null),
aRoomSummaryWithAnAlias,
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt
index 48348f5e9a..8a7324d9e5 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt
@@ -522,7 +522,9 @@ class DefaultVoiceMessageComposerPresenterTest {
permissionsPresenter.setPermissionGranted()
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
- val finalState = awaitItem()
+ advanceUntilIdle()
+
+ val finalState = expectMostRecentItem()
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
voiceRecorder.assertCalls(stopped = 1, started = 1)
@@ -547,14 +549,16 @@ class DefaultVoiceMessageComposerPresenterTest {
assertThat(it.showPermissionRationaleDialog).isTrue()
it.eventSink(VoiceMessageComposerEvent.AcceptPermissionRationale)
}
+ skipItems(1)
// Dialog is hidden, user accepts permissions
assertThat(awaitItem().showPermissionRationaleDialog).isFalse()
+ // Permission is granted, recording starts automatically
permissionsPresenter.setPermissionGranted()
+ advanceUntilIdle()
- awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
- val finalState = awaitItem()
+ val finalState = expectMostRecentItem()
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
voiceRecorder.assertCalls(started = 1)
@@ -579,12 +583,14 @@ class DefaultVoiceMessageComposerPresenterTest {
assertThat(it.showPermissionRationaleDialog).isTrue()
it.eventSink(VoiceMessageComposerEvent.DismissPermissionsRationale)
}
+ skipItems(1)
// Dialog is hidden, user tries to record again
awaitItem().also {
assertThat(it.showPermissionRationaleDialog).isFalse()
it.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
}
+ skipItems(1)
// Dialog is shown once again
val finalState = awaitItem().also {
@@ -593,6 +599,7 @@ class DefaultVoiceMessageComposerPresenterTest {
}
voiceRecorder.assertCalls(started = 0)
+ cancelAndIgnoreRemainingEvents()
testPauseAndDestroy(finalState)
}
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt
index 9ac4fdfcc8..b925eabe9e 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt
@@ -32,7 +32,12 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider
state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.ALL_MESSAGES }
}.last()
@@ -71,10 +77,8 @@ class EditDefaultNotificationSettingsPresenterTest {
initialRoomModeIsDefault = false,
getRoomsWithUserDefinedRulesResult = { Result.success(listOf(A_ROOM_ID, A_ROOM_ID_2)) },
)
- val roomListService = FakeRoomListService()
- val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService)
- presenter.test {
- roomListService.postAllRooms(
+ val roomList = FakeDynamicRoomList(
+ summaries = MutableStateFlow(
listOf(
aRoomSummary(
roomId = A_ROOM_ID,
@@ -86,8 +90,14 @@ class EditDefaultNotificationSettingsPresenterTest {
name = "A",
userDefinedNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
),
- ),
+ )
)
+ )
+ val roomListService = FakeRoomListService(
+ createRoomListLambda = { roomList }
+ )
+ val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService)
+ presenter.test {
val loadedState = consumeItemsUntilPredicate { state ->
state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY }
}.last()
@@ -103,10 +113,8 @@ class EditDefaultNotificationSettingsPresenterTest {
initialRoomModeIsDefault = false,
getRoomsWithUserDefinedRulesResult = { Result.success(listOf(A_ROOM_ID, A_ROOM_ID_2)) },
)
- val roomListService = FakeRoomListService()
- val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService)
- presenter.test {
- roomListService.postAllRooms(
+ val roomList = FakeDynamicRoomList(
+ summaries = MutableStateFlow(
listOf(
aRoomSummary(
roomId = A_ROOM_ID,
@@ -118,8 +126,14 @@ class EditDefaultNotificationSettingsPresenterTest {
name = null,
userDefinedNotificationMode = RoomNotificationMode.MUTE,
),
- ),
+ )
)
+ )
+ val roomListService = FakeRoomListService(
+ createRoomListLambda = { roomList }
+ )
+ val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService)
+ presenter.test {
val loadedState = consumeItemsUntilPredicate { state ->
state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.MUTE }
}.last()
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt
index d2c9175f38..b033a5870d 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt
@@ -45,12 +45,17 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlin.time.Duration.Companion.seconds
@AssistedInject
class ChangeRolesPresenter(
@@ -220,7 +225,19 @@ class ChangeRolesPresenter(
add(UserRoleChange(selectedUser.userId, RoomMember.Role.User))
}
}
- room.updateUsersRoles(changes).map { true }
+ room.updateUsersRoles(changes).map {
+ // Wait for the changes to take effect or a timeout
+ withTimeoutOrNull(10.seconds) {
+ room.roomInfoFlow
+ .map { it.roomPowerLevels }
+ .filterNotNull()
+ .takeWhile { powerLevels ->
+ changes.any { powerLevels.powerLevelOf(it.userId) != it.powerLevel }
+ }
+ .collect()
+ }
+ true
+ }
}
}
diff --git a/features/rolesandpermissions/impl/src/main/res/values-nb/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-nb/translations.xml
index 299dcc9cb9..3536e03ad5 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-nb/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-nb/translations.xml
@@ -38,6 +38,12 @@
"Du har endringer som ikke er lagret."
"Lagre endringer?"
"Det er ingen utestengte brukere."
+
+ - "%1$d utestengt"
+ - "%1$d utestengt"
+
+ "Sjekk stavemåten eller prøv et nytt søk"
+ "Ingen resultater for \"%1$s\""
- "%1$d person"
- "%1$d personer"
@@ -49,6 +55,11 @@
"Fjern utestengelsen fra rommet"
"Utestengt"
"Medlemmer"
+
+ - "%1$d invitert"
+ - "%1$d invitert"
+
+ "Venter"
"Admin"
"Moderator"
"Eier"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-sv/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-sv/translations.xml
index d52c85de14..85cbe5aeb5 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-sv/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-sv/translations.xml
@@ -1,14 +1,16 @@
- "Endast administratörer"
+ "Admin"
"Banna personer"
"Ta bort meddelanden"
- "Bjuda in personer och acceptera förfrågningar om att gå med"
+ "Medlem"
+ "Bjud in personer"
+ "Hantera medlemmar"
"Meddelanden och innehåll"
- "Administratörer och moderatorer"
- "Ta bort personer och avslå förfrågningar om att gå med"
+ "Moderator"
+ "Ta bort personer"
"Byt rumsavatar"
- "Redigera rummet"
+ "Redigera detaljer"
"Byt rumsnamn"
"Byt rumsämne"
"Skicka meddelanden"
@@ -31,10 +33,10 @@
"Medlemmar"
"Du har osparade ändringar."
"Spara ändringar?"
- "Det finns inga bannade användare i det här rummet."
+ "Det finns inga bannade användare."
- - "%1$d person"
- - "%1$d personer"
+ - "%1$d Person"
+ - "%1$d Personer"
"Ta bort och banna medlem"
"Ta bara bort medlem"
@@ -43,8 +45,8 @@
"Avbanna från rummet"
"Bannade"
"Medlemmar"
- "Endast administratörer"
- "Administratörer och moderatorer"
+ "Admin"
+ "Moderator"
"Ägare"
"Rumsmedlemmar"
"Avbannar %1$s"
diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt
index a068ac7a7f..ad0d2e9ab9 100644
--- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt
+++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt
@@ -25,7 +25,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
-import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewSubtitleAtom
+import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewAliasAtom
import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@@ -54,7 +54,7 @@ fun RoomAliasResolverView(
containerColor = Color.Transparent,
contentPadding = PaddingValues(
horizontal = 16.dp,
- vertical = 32.dp
+ vertical = 24.dp
),
topBar = {
RoomAliasResolverTopBar(onBackClick = onBackClick)
@@ -121,7 +121,7 @@ private fun RoomAliasResolverContent(
PlaceholderAtom(width = AvatarSize.RoomPreviewHeader.dp, height = AvatarSize.RoomPreviewHeader.dp)
},
title = {
- RoomPreviewSubtitleAtom(roomAlias.value)
+ RoomPreviewAliasAtom(roomAlias.value)
},
subtitle = {
if (isLoading) {
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
index 07b76d41b9..853d76859b 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
@@ -168,6 +168,8 @@ class RoomDetailsPresenter(
val canReportRoom by produceState(false) { value = client.canReportRoom() }
+ val enableKeyShareOnInvite by featureFlagService.isFeatureEnabledFlow(FeatureFlags.EnableKeyShareOnInvite).collectAsState(initial = false)
+
return RoomDetailsState(
roomId = room.roomId,
roomName = roomName,
@@ -197,6 +199,8 @@ class RoomDetailsPresenter(
isTombstoned = roomInfo.successorRoom != null,
showDebugInfo = isDeveloperModeEnabled,
roomVersion = roomInfo.roomVersion,
+ enableKeyShareOnInvite = enableKeyShareOnInvite,
+ roomHistoryVisibility = roomInfo.historyVisibility,
eventSink = ::handleEvent,
)
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
index 2332776e96..20ec12fdb9 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
@@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
+import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -50,6 +51,8 @@ data class RoomDetailsState(
val isTombstoned: Boolean,
val showDebugInfo: Boolean,
val roomVersion: String?,
+ val enableKeyShareOnInvite: Boolean,
+ val roomHistoryVisibility: RoomHistoryVisibility,
val eventSink: (RoomDetailsEvent) -> Unit
) {
val roomBadges = buildList {
@@ -61,6 +64,14 @@ data class RoomDetailsState(
if (isPublic) {
add(RoomBadge.PUBLIC)
}
+ if (enableKeyShareOnInvite && isEncrypted) {
+ when (roomHistoryVisibility) {
+ RoomHistoryVisibility.Invited, RoomHistoryVisibility.Joined -> add(RoomBadge.SHARED_HISTORY_HIDDEN)
+ RoomHistoryVisibility.Shared -> add(RoomBadge.SHARED_HISTORY_SHARED)
+ RoomHistoryVisibility.WorldReadable -> add(RoomBadge.SHARED_HISTORY_WORLD_READABLE)
+ else -> {}
+ }
+ }
}.toImmutableList()
}
@@ -84,4 +95,7 @@ enum class RoomBadge {
ENCRYPTED,
NOT_ENCRYPTED,
PUBLIC,
+ SHARED_HISTORY_HIDDEN,
+ SHARED_HISTORY_SHARED,
+ SHARED_HISTORY_WORLD_READABLE
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
index 783fcfaf10..e40e6b03ef 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
@@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
+import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.toImmutableList
@@ -57,6 +58,9 @@ open class RoomDetailsStateProvider : PreviewParameterProvider
aRoomDetailsState(isTombstoned = true),
aDmRoomDetailsState(dmRoomMemberVerificationState = UserProfileVerificationState.VERIFIED),
aDmRoomDetailsState(dmRoomMemberVerificationState = UserProfileVerificationState.VERIFICATION_VIOLATION),
+ aSharedHistoryRoomDetailsState(roomHistoryVisibility = RoomHistoryVisibility.Joined),
+ aSharedHistoryRoomDetailsState(roomHistoryVisibility = RoomHistoryVisibility.Shared),
+ aSharedHistoryRoomDetailsState(roomHistoryVisibility = RoomHistoryVisibility.WorldReadable),
// Add other state here
)
}
@@ -117,6 +121,8 @@ fun aRoomDetailsState(
canReportRoom: Boolean = true,
isTombstoned: Boolean = false,
showDebugInfo: Boolean = false,
+ enableKeyShareOnInvite: Boolean = false,
+ roomHistoryVisibility: RoomHistoryVisibility = RoomHistoryVisibility.Shared,
eventSink: (RoomDetailsEvent) -> Unit = {},
) = RoomDetailsState(
roomId = roomId,
@@ -147,6 +153,8 @@ fun aRoomDetailsState(
isTombstoned = isTombstoned,
showDebugInfo = showDebugInfo,
roomVersion = "12",
+ enableKeyShareOnInvite = enableKeyShareOnInvite,
+ roomHistoryVisibility = roomHistoryVisibility,
eventSink = eventSink,
)
@@ -182,3 +190,11 @@ fun aDmRoomDetailsState(
verificationState = dmRoomMemberVerificationState,
)
)
+
+fun aSharedHistoryRoomDetailsState(
+ roomHistoryVisibility: RoomHistoryVisibility
+) = aRoomDetailsState(
+ isEncrypted = true,
+ enableKeyShareOnInvite = true,
+ roomHistoryVisibility = roomHistoryVisibility,
+)
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
index 86481be026..3ec9b0a1da 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
@@ -518,6 +518,27 @@ private fun RoomBadge.toMatrixBadgeData(): MatrixBadgeAtom.MatrixBadgeData {
type = MatrixBadgeAtom.Type.Info,
)
}
+ RoomBadge.SHARED_HISTORY_HIDDEN -> {
+ MatrixBadgeAtom.MatrixBadgeData(
+ text = stringResource(R.string.crypto_history_sharing_room_info_hidden_badge_content),
+ icon = CompoundIcons.VisibilityOff(),
+ type = MatrixBadgeAtom.Type.Info
+ )
+ }
+ RoomBadge.SHARED_HISTORY_SHARED -> {
+ MatrixBadgeAtom.MatrixBadgeData(
+ text = stringResource(R.string.crypto_history_sharing_room_info_shared_badge_content),
+ icon = CompoundIcons.History(),
+ type = MatrixBadgeAtom.Type.Info
+ )
+ }
+ RoomBadge.SHARED_HISTORY_WORLD_READABLE -> {
+ MatrixBadgeAtom.MatrixBadgeData(
+ text = stringResource(R.string.crypto_history_sharing_room_info_world_readable_badge_content),
+ icon = CompoundIcons.UserProfileSolid(),
+ type = MatrixBadgeAtom.Type.Info
+ )
+ }
}
}
@@ -589,9 +610,14 @@ private fun FavoriteItem(
isFavorite: Boolean,
onFavoriteChanges: (Boolean) -> Unit,
) {
+ val (textResId, icon) = if (isFavorite) {
+ CommonStrings.common_favourited to CompoundIcons.FavouriteSolid()
+ } else {
+ CommonStrings.common_favourite to CompoundIcons.Favourite()
+ }
PreferenceSwitch(
- icon = CompoundIcons.Favourite(),
- title = stringResource(id = CommonStrings.common_favourite),
+ icon = icon,
+ title = stringResource(id = textResId),
isChecked = isFavorite,
onCheckedChange = onFavoriteChanges
)
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListEvents.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListEvent.kt
similarity index 85%
rename from features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListEvents.kt
rename to features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListEvent.kt
index f33bb2df00..865db3212a 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListEvents.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListEvent.kt
@@ -10,7 +10,7 @@ package io.element.android.features.roomdetails.impl.members
import io.element.android.libraries.matrix.api.room.RoomMember
-sealed interface RoomMemberListEvents {
- data class ChangeSelectedSection(val section: SelectedSection) : RoomMemberListEvents
- data class RoomMemberSelected(val roomMember: RoomMember) : RoomMemberListEvents
+sealed interface RoomMemberListEvent {
+ data class ChangeSelectedSection(val section: SelectedSection) : RoomMemberListEvent
+ data class RoomMemberSelected(val roomMember: RoomMember) : RoomMemberListEvent
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt
index 7ad98a373d..f47f12f446 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt
@@ -125,11 +125,11 @@ class RoomMemberListPresenter(
}
}
- fun handleEvent(event: RoomMemberListEvents) {
+ fun handleEvent(event: RoomMemberListEvent) {
when (event) {
- is RoomMemberListEvents.RoomMemberSelected ->
+ is RoomMemberListEvent.RoomMemberSelected ->
roomModerationState.eventSink(ShowActionsForUser(event.roomMember.toMatrixUser()))
- is RoomMemberListEvents.ChangeSelectedSection -> selectedSection = event.section
+ is RoomMemberListEvent.ChangeSelectedSection -> selectedSection = event.section
}
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt
index 3cc795a8db..6d52db6c87 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt
@@ -25,7 +25,7 @@ data class RoomMemberListState(
val canInvite: Boolean,
val selectedSection: SelectedSection,
val moderationState: RoomMemberModerationState,
- val eventSink: (RoomMemberListEvents) -> Unit,
+ val eventSink: (RoomMemberListEvent) -> Unit,
) {
val showBannedSection: Boolean = moderationState.permissions.canBan && roomMembers.dataOrNull()?.banned?.isNotEmpty() == true
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt
index 63dfec56e5..bc077feb6a 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt
@@ -85,7 +85,7 @@ internal fun aRoomMemberListState(
selectedSection: SelectedSection = SelectedSection.MEMBERS,
searchQuery: String = "",
canInvite: Boolean = false,
- eventSink: (RoomMemberListEvents) -> Unit = {},
+ eventSink: (RoomMemberListEvent) -> Unit = {},
) = RoomMemberListState(
roomMembers = roomMembers,
filteredRoomMembers = roomMembers.map { it.filter(searchQuery) },
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt
index bc37bbbe74..146d2f7952 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt
@@ -25,8 +25,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
@@ -68,7 +66,7 @@ fun RoomMemberListView(
modifier: Modifier = Modifier,
) {
fun onSelectUser(roomMember: RoomMember) {
- state.eventSink(RoomMemberListEvents.RoomMemberSelected(roomMember))
+ state.eventSink(RoomMemberListEvent.RoomMemberSelected(roomMember))
}
Scaffold(
@@ -100,7 +98,7 @@ fun RoomMemberListView(
selectedSection = state.selectedSection,
showBannedSection = state.showBannedSection,
searchQuery = state.searchQuery.text.toString(),
- onSelectedSectionChange = { state.eventSink(RoomMemberListEvents.ChangeSelectedSection(it)) },
+ onSelectedSectionChange = { state.eventSink(RoomMemberListEvent.ChangeSelectedSection(it)) },
onSelectUser = ::onSelectUser,
)
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvents.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvent.kt
similarity index 82%
rename from features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvents.kt
rename to features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvent.kt
index 81bfd86f82..548a86c0e9 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvents.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvent.kt
@@ -10,10 +10,10 @@ package io.element.android.features.roomdetails.impl.notificationsettings
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
-sealed interface RoomNotificationSettingsEvents {
- data class ChangeRoomNotificationMode(val mode: RoomNotificationMode) : RoomNotificationSettingsEvents
- data class SetNotificationMode(val isDefault: Boolean) : RoomNotificationSettingsEvents
- data object DeleteCustomNotification : RoomNotificationSettingsEvents
- data object ClearSetNotificationError : RoomNotificationSettingsEvents
- data object ClearRestoreDefaultError : RoomNotificationSettingsEvents
+sealed interface RoomNotificationSettingsEvent {
+ data class ChangeRoomNotificationMode(val mode: RoomNotificationMode) : RoomNotificationSettingsEvent
+ data class SetNotificationMode(val isDefault: Boolean) : RoomNotificationSettingsEvent
+ data object DeleteCustomNotification : RoomNotificationSettingsEvent
+ data object ClearSetNotificationError : RoomNotificationSettingsEvent
+ data object ClearRestoreDefaultError : RoomNotificationSettingsEvent
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt
index b3a88d3f6f..c7930b5895 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt
@@ -100,12 +100,12 @@ class RoomNotificationSettingsPresenter(
!notificationSettingsService.canHomeServerPushEncryptedEventsToDevice().getOrDefault(true)
}
- fun handleEvent(event: RoomNotificationSettingsEvents) {
+ fun handleEvent(event: RoomNotificationSettingsEvent) {
when (event) {
- is RoomNotificationSettingsEvents.ChangeRoomNotificationMode -> {
+ is RoomNotificationSettingsEvent.ChangeRoomNotificationMode -> {
localCoroutineScope.setRoomNotificationMode(event.mode, pendingRoomNotificationMode, pendingSetDefault, setNotificationSettingAction)
}
- is RoomNotificationSettingsEvents.SetNotificationMode -> {
+ is RoomNotificationSettingsEvent.SetNotificationMode -> {
if (event.isDefault) {
localCoroutineScope.restoreDefaultRoomNotificationMode(restoreDefaultAction, pendingSetDefault)
} else {
@@ -114,13 +114,13 @@ class RoomNotificationSettingsPresenter(
}
}
}
- is RoomNotificationSettingsEvents.DeleteCustomNotification -> {
+ is RoomNotificationSettingsEvent.DeleteCustomNotification -> {
localCoroutineScope.restoreDefaultRoomNotificationMode(restoreDefaultAction, pendingSetDefault)
}
- RoomNotificationSettingsEvents.ClearSetNotificationError -> {
+ RoomNotificationSettingsEvent.ClearSetNotificationError -> {
setNotificationSettingAction.value = AsyncAction.Uninitialized
}
- RoomNotificationSettingsEvents.ClearRestoreDefaultError -> {
+ RoomNotificationSettingsEvent.ClearRestoreDefaultError -> {
restoreDefaultAction.value = AsyncAction.Uninitialized
}
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsState.kt
index a2ea5e8fa7..55d78ad9a1 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsState.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsState.kt
@@ -23,7 +23,7 @@ data class RoomNotificationSettingsState(
val setNotificationSettingAction: AsyncAction,
val restoreDefaultAction: AsyncAction,
val displayMentionsOnlyDisclaimer: Boolean,
- val eventSink: (RoomNotificationSettingsEvents) -> Unit
+ val eventSink: (RoomNotificationSettingsEvent) -> Unit
)
val RoomNotificationSettingsState.displayNotificationMode: RoomNotificationMode? get() {
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt
index 5a33066d1d..12bc258944 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt
@@ -85,7 +85,7 @@ private fun RoomSpecificNotificationSettingsView(
PreferenceSwitch(
isChecked = !state.displayIsDefault.orTrue(),
onCheckedChange = {
- state.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(!it))
+ state.eventSink(RoomNotificationSettingsEvent.SetNotificationMode(!it))
},
title = stringResource(id = R.string.screen_room_notification_settings_allow_custom),
subtitle = stringResource(id = R.string.screen_room_notification_settings_allow_custom_footnote),
@@ -138,7 +138,7 @@ private fun RoomSpecificNotificationSettingsView(
enabled = !state.displayIsDefault.orTrue(),
displayMentionsOnlyDisclaimer = state.displayMentionsOnlyDisclaimer,
onSelectOption = {
- state.eventSink(RoomNotificationSettingsEvents.ChangeRoomNotificationMode(it.mode))
+ state.eventSink(RoomNotificationSettingsEvent.ChangeRoomNotificationMode(it.mode))
},
)
}
@@ -148,14 +148,14 @@ private fun RoomSpecificNotificationSettingsView(
async = state.setNotificationSettingAction,
onSuccess = {},
errorMessage = { stringResource(R.string.screen_notification_settings_edit_failed_updating_default_mode) },
- onErrorDismiss = { state.eventSink(RoomNotificationSettingsEvents.ClearSetNotificationError) },
+ onErrorDismiss = { state.eventSink(RoomNotificationSettingsEvent.ClearSetNotificationError) },
)
AsyncActionView(
async = state.restoreDefaultAction,
onSuccess = {},
errorMessage = { stringResource(R.string.screen_notification_settings_edit_failed_updating_default_mode) },
- onErrorDismiss = { state.eventSink(RoomNotificationSettingsEvents.ClearRestoreDefaultError) },
+ onErrorDismiss = { state.eventSink(RoomNotificationSettingsEvent.ClearRestoreDefaultError) },
)
}
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt
index f66316b737..3e4e9e773e 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt
@@ -60,7 +60,7 @@ fun UserDefinedRoomNotificationSettingsView(
enabled = !state.displayIsDefault.orTrue(),
displayMentionsOnlyDisclaimer = state.displayMentionsOnlyDisclaimer,
onSelectOption = {
- state.eventSink(RoomNotificationSettingsEvents.ChangeRoomNotificationMode(it.mode))
+ state.eventSink(RoomNotificationSettingsEvent.ChangeRoomNotificationMode(it.mode))
},
)
}
@@ -69,7 +69,7 @@ fun UserDefinedRoomNotificationSettingsView(
headlineContent = { Text(stringResource(R.string.screen_room_notification_settings_edit_remove_setting)) },
style = ListItemStyle.Destructive,
onClick = {
- state.eventSink(RoomNotificationSettingsEvents.DeleteCustomNotification)
+ state.eventSink(RoomNotificationSettingsEvent.DeleteCustomNotification)
}
)
@@ -77,14 +77,14 @@ fun UserDefinedRoomNotificationSettingsView(
async = state.setNotificationSettingAction,
onSuccess = {},
errorMessage = { stringResource(R.string.screen_notification_settings_edit_failed_updating_default_mode) },
- onErrorDismiss = { state.eventSink(RoomNotificationSettingsEvents.ClearSetNotificationError) },
+ onErrorDismiss = { state.eventSink(RoomNotificationSettingsEvent.ClearSetNotificationError) },
)
AsyncActionView(
async = state.restoreDefaultAction,
onSuccess = { onBackClick() },
errorMessage = { stringResource(R.string.screen_notification_settings_edit_failed_updating_default_mode) },
- onErrorDismiss = { state.eventSink(RoomNotificationSettingsEvents.ClearRestoreDefaultError) },
+ onErrorDismiss = { state.eventSink(RoomNotificationSettingsEvent.ClearRestoreDefaultError) },
)
}
}
diff --git a/features/roomdetails/impl/src/main/res/values-cs/translations.xml b/features/roomdetails/impl/src/main/res/values-cs/translations.xml
index 78a8a9f703..6b89c70bdb 100644
--- a/features/roomdetails/impl/src/main/res/values-cs/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-cs/translations.xml
@@ -145,7 +145,7 @@ Nedoporučujeme povolovat šifrování pro místnosti, které může kdokoli naj
"Šifrování"
"Povolit koncové šifrování"
"Vstoupit může kdokoli."
- "Kdokoliv"
+ "Kdokoli"
"Vyberte, kteří členové prostorů mohou vstoupit do této místnosti bez pozvánky. %1$s"
"Spravovat prostory"
"Vstoupit mohou pouze pozvaní lidé."
diff --git a/features/roomdetails/impl/src/main/res/values-et/translations.xml b/features/roomdetails/impl/src/main/res/values-et/translations.xml
index 65e39cf045..d5d3af8563 100644
--- a/features/roomdetails/impl/src/main/res/values-et/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-et/translations.xml
@@ -1,5 +1,8 @@
+ "Uued liikmed ei näe ajalugu"
+ "Uued liikmed näevad ajalugu"
+ "Kõik võivad ajalugu näha"
"Selleks, et jututuba oleks nähtav jututubade avalikus kataloogis, vajab ta aadressi."
"Muuda aadressi"
"Teavituste seadistamisel tekkis viga"
@@ -142,7 +145,7 @@ Me ei soovita krüptimise kasutamist selliste avalike jututubade puhul, millega
"Krüptimine"
"Võta läbiv krüptimine kasutusele"
"Kõik võivad jututoaga liituda"
- "Avalik"
+ "Kõik"
"Vali kogukonnad, mille liikmed saavad selle jututoaga liituda ilma kutseta. %1$s"
"Halda kogukondi"
"Liituda saab vaid kutse olemasolul"
diff --git a/features/roomdetails/impl/src/main/res/values-fr/translations.xml b/features/roomdetails/impl/src/main/res/values-fr/translations.xml
index 3445cf4802..b3568aff21 100644
--- a/features/roomdetails/impl/src/main/res/values-fr/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-fr/translations.xml
@@ -1,5 +1,8 @@
+ "Les nouveaux membres ne voient pas l’historique."
+ "Les nouveaux membres voient l’historique"
+ "Tout le monde voit l’historique"
"Vous aurez besoin d’une adresse pour le rendre visible dans l’annuaire public."
"Modifier l’adresse"
"Une erreur s’est produite lors de la mise à jour du paramètre de notification."
diff --git a/features/roomdetails/impl/src/main/res/values-nb/translations.xml b/features/roomdetails/impl/src/main/res/values-nb/translations.xml
index b5ef934c26..2fdb3352f2 100644
--- a/features/roomdetails/impl/src/main/res/values-nb/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-nb/translations.xml
@@ -63,6 +63,7 @@
"Profil"
"Forespørsler om å bli med"
"Roller og tillatelser"
+ "Navn"
"Sikkerhet og personvern"
"Sikkerhet"
"Del rom"
@@ -70,6 +71,12 @@
"Emne"
"Oppdaterer rommet …"
"Det er ingen utestengte brukere."
+
+ - "%1$d utestengt"
+ - "%1$d utestengt"
+
+ "Sjekk stavemåten eller prøv et nytt søk"
+ "Ingen resultater for \"%1$s\""
- "%1$d person"
- "%1$d personer"
@@ -81,6 +88,11 @@
"Fjern utestengelsen fra rommet"
"Utestengt"
"Medlemmer"
+
+ - "%1$d invitert"
+ - "%1$d invitert"
+
+ "Venter"
"Admin"
"Moderator"
"Eier"
@@ -117,8 +129,10 @@
"Romdetaljer"
"Roller og tillatelser"
"Legg til adresse"
+ "Alle i autoriserte områder kan bli med, men alle andre må be om tilgang."
"Alle må be om tilgang."
"Be om å få bli med"
+ "Alle i %1$s kan bli med, men alle andre må be om tilgang."
"Ja, aktiver kryptering"
"Når kryptering for et rom er aktivert, kan den ikke deaktiveres. Meldingshistorikken vil bare være synlig for rommedlemmer siden de ble invitert eller siden de ble med i rommet.
Ingen andre enn rommedlemmene vil kunne lese meldingene. Dette kan føre til at bots og broer ikke fungerer som de skal.
@@ -129,22 +143,28 @@ Vi anbefaler ikke å aktivere kryptering for rom som hvem som helst kan finne og
"Aktiver ende-til-ende-kryptering"
"Alle kan bli med."
"Hvem som helst"
+ "Administrer områder"
"Bare inviterte personer kan bli med."
"Kun for inviterte"
"Tilgang"
+ "Alle i autoriserte områder kan bli med."
+ "Alle i %1$s kan bli med."
"Medlemmer av område"
"Områder støttes ikke for øyeblikket"
"Du trenger en adresse for å gjøre den synlig i den offentlige katalogen."
"Adresse"
"Tillat at dette rommet blir funnet ved å søke %1$s offentlig romkatalog"
+ "Tillat å bli funnet ved søk i den offentlige katalogen."
"Synlig i offentlig katalog"
"Alle (historikken er offentlig)"
+ "Endringene vil ikke påvirke tidligere meldinger, kun nye. %1$s"
"Hvem kan lese historikk"
"Medlemmer siden de ble invitert"
"Medlemmer (full historikk)"
"Romadresser er måter å finne og få tilgang til rom på. Dette sikrer også at du enkelt kan dele rommet ditt med andre.
Du kan velge å publisere rommet ditt i hjemeserverens offentlige romkatalog."
"Publisering av rom"
+ "Adresser er en måte å finne og få tilgang til rom og områder. Dette sikrer også at du enkelt kan dele dem med andre."
"Synlighet"
"Sikkerhet og personvern"
diff --git a/features/roomdetails/impl/src/main/res/values-sv/translations.xml b/features/roomdetails/impl/src/main/res/values-sv/translations.xml
index 0d7ade3f39..b2b44eb610 100644
--- a/features/roomdetails/impl/src/main/res/values-sv/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-sv/translations.xml
@@ -5,15 +5,17 @@
"Ett fel uppstod vid uppdatering av aviseringsinställningen."
"Din hemserver stöder inte det här alternativet i krypterade rum, du kanske inte aviseras i vissa rum."
"Omröstningar"
- "Endast administratörer"
+ "Admin"
"Banna personer"
"Ta bort meddelanden"
- "Bjuda in personer och acceptera förfrågningar om att gå med"
+ "Medlem"
+ "Bjud in personer"
+ "Hantera medlemmar"
"Meddelanden och innehåll"
- "Administratörer och moderatorer"
- "Ta bort personer och avslå förfrågningar om att gå med"
+ "Moderator"
+ "Ta bort personer"
"Byt rumsavatar"
- "Redigera rummet"
+ "Redigera detaljer"
"Byt rumsnamn"
"Byt rumsämne"
"Skicka meddelanden"
@@ -40,7 +42,7 @@
"Krypterat"
"Inte krypterat"
"Offentligt rum"
- "Redigera rummet"
+ "Redigera detaljer"
"Ett okänt fel uppstod och informationen kunde inte ändras."
"Kunde inte uppdatera rummet"
"Meddelanden är säkrade med lås. Bara du och mottagarna har de unika nycklarna för att låsa upp dem."
@@ -65,10 +67,10 @@
"Rumsinfo"
"Ämne"
"Uppdaterar rummet …"
- "Det finns inga bannade användare i det här rummet."
+ "Det finns inga bannade användare."
- - "%1$d person"
- - "%1$d personer"
+ - "%1$d Person"
+ - "%1$d Personer"
"Ta bort och banna medlem"
"Ta bara bort medlem"
@@ -77,8 +79,8 @@
"Avbanna från rummet"
"Bannade"
"Medlemmar"
- "Endast administratörer"
- "Administratörer och moderatorer"
+ "Admin"
+ "Moderator"
"Ägare"
"Rumsmedlemmar"
"Avbannar %1$s"
@@ -112,7 +114,7 @@
"Rumsdetaljer"
"Roller och behörigheter"
"Lägg till rumsadress"
- "Vem som helst kan be om att gå med i rummet men en administratör eller moderator måste acceptera begäran."
+ "Alla måste begära åtkomst."
"Be om att gå med"
"Ja, aktivera kryptering"
"När det är aktiverat kan kryptering för ett rum inte inaktiveras, meddelandehistoriken visas bara för rumsmedlemmar sedan de blev inbjudna eller sedan de gick med i rummet.
@@ -124,9 +126,9 @@ Vi rekommenderar inte att aktivera kryptering för rum som vem som helst kan hit
"Aktivera totalsträckskryptering"
"Vem som helst kan hitta och gå med"
"Vem som helst"
- "Användare kan bara gå med om de är inbjudna"
+ "Endast inbjudna personer kan gå med."
"Endast inbjudan"
- "Tillgång till rum"
+ "Åtkomst"
"Utrymmesmedlemmar"
"Utrymmen stöds för närvarande inte"
"Du behöver en rumsadress för att göra den synlig i katalogen."
diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml
index ca00571dd1..6275b2837e 100644
--- a/features/roomdetails/impl/src/main/res/values/localazy.xml
+++ b/features/roomdetails/impl/src/main/res/values/localazy.xml
@@ -1,5 +1,8 @@
+ "New members don\'t see history"
+ "New members see history"
+ "Anyone can see history"
"You’ll need an address in order to make it visible in the public directory."
"Edit address"
"An error occurred while updating the notification setting."
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt
index 5b1fd9474f..d355010307 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt
@@ -582,7 +582,7 @@ class RoomDetailsPresenterTest {
assertThat(awaitItem().canShowKnockRequests).isFalse()
featureFlagService.setFeatureEnabled(FeatureFlags.Knock, true)
assertThat(awaitItem().canShowKnockRequests).isTrue()
- room.givenRoomInfo(aRoomInfo(joinRule = JoinRule.Private))
+ room.givenRoomInfo(aRoomInfo(joinRule = JoinRule.Invite))
assertThat(awaitItem().canShowKnockRequests).isFalse()
cancelAndIgnoreRemainingEvents()
}
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateTest.kt
index 68889c1551..7bfd52d82d 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateTest.kt
@@ -9,6 +9,7 @@
package io.element.android.features.roomdetails.impl
import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
@@ -56,4 +57,52 @@ class RoomDetailsStateTest {
persistentListOf(RoomBadge.ENCRYPTED)
)
}
+
+ @Test
+ fun `room public not encrypted should not have history sharing badges`() {
+ val sut = aRoomDetailsState(
+ isEncrypted = false,
+ enableKeyShareOnInvite = true,
+ roomHistoryVisibility = RoomHistoryVisibility.Shared
+ )
+ assertThat(sut.roomBadges).isEqualTo(
+ persistentListOf(RoomBadge.NOT_ENCRYPTED, RoomBadge.PUBLIC)
+ )
+ }
+
+ @Test
+ fun `room public encrypted should have history sharing hidden badge`() {
+ val sut = aRoomDetailsState(
+ isEncrypted = true,
+ enableKeyShareOnInvite = true,
+ roomHistoryVisibility = RoomHistoryVisibility.Joined
+ )
+ assertThat(sut.roomBadges).isEqualTo(
+ persistentListOf(RoomBadge.ENCRYPTED, RoomBadge.PUBLIC, RoomBadge.SHARED_HISTORY_HIDDEN)
+ )
+ }
+
+ @Test
+ fun `room public encrypted should have history sharing shared badge`() {
+ val sut = aRoomDetailsState(
+ isEncrypted = true,
+ enableKeyShareOnInvite = true,
+ roomHistoryVisibility = RoomHistoryVisibility.Shared
+ )
+ assertThat(sut.roomBadges).isEqualTo(
+ persistentListOf(RoomBadge.ENCRYPTED, RoomBadge.PUBLIC, RoomBadge.SHARED_HISTORY_SHARED)
+ )
+ }
+
+ @Test
+ fun `room public encrypted should have history sharing world_readable badge`() {
+ val sut = aRoomDetailsState(
+ isEncrypted = true,
+ enableKeyShareOnInvite = true,
+ roomHistoryVisibility = RoomHistoryVisibility.WorldReadable
+ )
+ assertThat(sut.roomBadges).isEqualTo(
+ persistentListOf(RoomBadge.ENCRYPTED, RoomBadge.PUBLIC, RoomBadge.SHARED_HISTORY_WORLD_READABLE)
+ )
+ }
}
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt
index 028d4b2781..48f0e791a3 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt
@@ -66,7 +66,7 @@ class RoomMemberListPresenterTest {
skipItems(1)
val loadedState = awaitItem()
assertThat(loadedState.showBannedSection).isTrue()
- loadedState.eventSink(RoomMemberListEvents.ChangeSelectedSection(SelectedSection.BANNED))
+ loadedState.eventSink(RoomMemberListEvent.ChangeSelectedSection(SelectedSection.BANNED))
val bannedSectionState = awaitItem()
assertThat(bannedSectionState.selectedSection).isEqualTo(SelectedSection.BANNED)
// Now update the room members to have no banned users
@@ -188,7 +188,7 @@ class RoomMemberListPresenterTest {
)
presenter.test {
skipItems(1)
- awaitItem().eventSink(RoomMemberListEvents.RoomMemberSelected(anInvitedVictor()))
+ awaitItem().eventSink(RoomMemberListEvent.RoomMemberSelected(anInvitedVictor()))
}
}
}
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt
index 4dbf21c392..34ac8d623f 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt
@@ -8,9 +8,6 @@
package io.element.android.features.roomdetails.impl.members.details
-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
-import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomdetails.impl.aJoinedRoom
import io.element.android.features.roomdetails.impl.members.aRoomMember
@@ -89,9 +86,7 @@ class RoomMemberDetailsPresenterTest {
val presenter = createRoomMemberDetailsPresenter(
room = room,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
assertThat(initialState.userName).isEqualTo("Alice")
assertThat(initialState.avatarUrl).isEqualTo("Alice Avatar url")
@@ -111,9 +106,7 @@ class RoomMemberDetailsPresenterTest {
val presenter = createRoomMemberDetailsPresenter(
room = room,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
assertThat(initialState.userName).isEqualTo("Alice")
assertThat(initialState.avatarUrl).isEqualTo("Profile avatar url")
@@ -130,9 +123,7 @@ class RoomMemberDetailsPresenterTest {
val presenter = createRoomMemberDetailsPresenter(
room = room,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
assertThat(initialState.userName).isEqualTo("Profile user name")
assertThat(initialState.avatarUrl).isEqualTo("Profile avatar url")
@@ -161,9 +152,7 @@ class RoomMemberDetailsPresenterTest {
}
},
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
assertThat(initialState.userName).isNull()
assertThat(initialState.avatarUrl).isNull()
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenterTest.kt
index c14dca9497..9f20aec0fd 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenterTest.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenterTest.kt
@@ -8,9 +8,6 @@
package io.element.android.features.roomdetails.impl.notificationsettings
-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
-import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomdetails.impl.aJoinedRoom
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
@@ -20,6 +17,7 @@ import io.element.android.libraries.matrix.test.notificationsettings.FakeNotific
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate
+import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -27,9 +25,7 @@ class RoomNotificationSettingsPresenterTest {
@Test
fun `present - initial state is created from room info`() = runTest {
val presenter = createRoomNotificationSettingsPresenter()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
assertThat(initialState.roomNotificationSettings.dataOrNull()).isNull()
assertThat(initialState.defaultRoomNotificationMode).isNull()
@@ -42,10 +38,8 @@ class RoomNotificationSettingsPresenterTest {
@Test
fun `present - notification mode changed`() = runTest {
val presenter = createRoomNotificationSettingsPresenter()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- awaitItem().eventSink(RoomNotificationSettingsEvents.ChangeRoomNotificationMode(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
+ presenter.test {
+ awaitItem().eventSink(RoomNotificationSettingsEvent.ChangeRoomNotificationMode(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
val updatedState = consumeItemsUntilPredicate {
it.roomNotificationSettings.dataOrNull()?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
}.last()
@@ -58,9 +52,7 @@ class RoomNotificationSettingsPresenterTest {
fun `present - observe notification mode changed`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService()
val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
notificationSettingsService.setRoomNotificationMode(A_ROOM_ID, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
val updatedState = consumeItemsUntilPredicate {
it.roomNotificationSettings.dataOrNull()?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
@@ -74,11 +66,9 @@ class RoomNotificationSettingsPresenterTest {
val notificationSettingsService = FakeNotificationSettingsService()
notificationSettingsService.givenSetNotificationModeError(AN_EXCEPTION)
val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
- initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(false))
+ initialState.eventSink(RoomNotificationSettingsEvent.SetNotificationMode(false))
val failedState = consumeItemsUntilPredicate {
it.setNotificationSettingAction.isFailure()
}.last()
@@ -87,7 +77,7 @@ class RoomNotificationSettingsPresenterTest {
assertThat(failedState.pendingSetDefault).isNull()
assertThat(failedState.setNotificationSettingAction.isFailure()).isTrue()
- failedState.eventSink(RoomNotificationSettingsEvents.ClearSetNotificationError)
+ failedState.eventSink(RoomNotificationSettingsEvent.ClearSetNotificationError)
val errorClearedState = consumeItemsUntilPredicate {
it.setNotificationSettingAction.isUninitialized()
@@ -100,11 +90,9 @@ class RoomNotificationSettingsPresenterTest {
fun `present - notification settings set custom`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService()
val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
- initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(false))
+ initialState.eventSink(RoomNotificationSettingsEvent.SetNotificationMode(false))
skipItems(3)
val defaultState = awaitItem()
assertThat(defaultState.roomNotificationSettings.dataOrNull()?.isDefault).isFalse()
@@ -115,12 +103,10 @@ class RoomNotificationSettingsPresenterTest {
@Test
fun `present - notification settings restore default`() = runTest {
val presenter = createRoomNotificationSettingsPresenter()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
- initialState.eventSink(RoomNotificationSettingsEvents.ChangeRoomNotificationMode(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
- initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(true))
+ initialState.eventSink(RoomNotificationSettingsEvent.ChangeRoomNotificationMode(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
+ initialState.eventSink(RoomNotificationSettingsEvent.SetNotificationMode(true))
val defaultState = consumeItemsUntilPredicate {
it.roomNotificationSettings.dataOrNull()?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
}.last()
@@ -134,17 +120,15 @@ class RoomNotificationSettingsPresenterTest {
val notificationSettingsService = FakeNotificationSettingsService()
notificationSettingsService.givenRestoreDefaultNotificationModeError(AN_EXCEPTION)
val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
- initialState.eventSink(RoomNotificationSettingsEvents.ChangeRoomNotificationMode(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
- initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(true))
+ initialState.eventSink(RoomNotificationSettingsEvent.ChangeRoomNotificationMode(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
+ initialState.eventSink(RoomNotificationSettingsEvent.SetNotificationMode(true))
val failedState = consumeItemsUntilPredicate {
it.restoreDefaultAction.isFailure()
}.last()
assertThat(failedState.restoreDefaultAction.isFailure()).isTrue()
- failedState.eventSink(RoomNotificationSettingsEvents.ClearRestoreDefaultError)
+ failedState.eventSink(RoomNotificationSettingsEvent.ClearRestoreDefaultError)
val errorClearedState = consumeItemsUntilPredicate {
it.restoreDefaultAction.isUninitialized()
@@ -161,9 +145,7 @@ class RoomNotificationSettingsPresenterTest {
}
val room = aJoinedRoom(notificationSettingsService = notificationService, isEncrypted = true)
val presenter = createRoomNotificationSettingsPresenter(notificationService, room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
assertThat(awaitLastSequentialItem().displayMentionsOnlyDisclaimer).isTrue()
}
}
@@ -175,9 +157,7 @@ class RoomNotificationSettingsPresenterTest {
}
val room = aJoinedRoom(notificationSettingsService = notificationService, isEncrypted = false)
val presenter = createRoomNotificationSettingsPresenter(notificationService, room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
assertThat(awaitLastSequentialItem().displayMentionsOnlyDisclaimer).isFalse()
}
}
diff --git a/features/roomdetailsedit/impl/src/main/res/values-sv/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-sv/translations.xml
index 176aed6b00..93abacab47 100644
--- a/features/roomdetailsedit/impl/src/main/res/values-sv/translations.xml
+++ b/features/roomdetailsedit/impl/src/main/res/values-sv/translations.xml
@@ -1,6 +1,6 @@
- "Redigera rummet"
+ "Redigera detaljer"
"Ett okänt fel uppstod och informationen kunde inte ändras."
"Kunde inte uppdatera rummet"
"Uppdaterar rummet …"
diff --git a/features/roommembermoderation/impl/src/main/res/values-sv/translations.xml b/features/roommembermoderation/impl/src/main/res/values-sv/translations.xml
index 9f3aabe191..896c4129fc 100644
--- a/features/roommembermoderation/impl/src/main/res/values-sv/translations.xml
+++ b/features/roommembermoderation/impl/src/main/res/values-sv/translations.xml
@@ -9,7 +9,7 @@
"Denne kommer kunna gå med i rummet igen om denne bjuds in"
"Är du säker på att du vill ta bort den här medlemmen?"
"Visa profil"
- "Ta bort från rummet"
+ "Ta bort användare"
"Ta bort medlem och banna från att gå med i framtiden?"
"Tar bort %1$s …"
"Avbanna från rummet"
diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt
index 09873b6b86..9230c1183a 100644
--- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt
+++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt
@@ -472,7 +472,6 @@ private fun JoinRule?.map(): SecurityAndPrivacyRoomAccess {
JoinRule.Invite -> SecurityAndPrivacyRoomAccess.InviteOnly
// All other cases are not supported so we default to InviteOnly
is JoinRule.Custom,
- JoinRule.Private,
null -> SecurityAndPrivacyRoomAccess.InviteOnly
}
}
@@ -481,7 +480,7 @@ private fun SecurityAndPrivacyRoomAccess.map(): JoinRule? {
return when (this) {
SecurityAndPrivacyRoomAccess.Anyone -> JoinRule.Public
SecurityAndPrivacyRoomAccess.AskToJoin -> JoinRule.Knock
- SecurityAndPrivacyRoomAccess.InviteOnly -> JoinRule.Private
+ SecurityAndPrivacyRoomAccess.InviteOnly -> JoinRule.Invite
is SecurityAndPrivacyRoomAccess.SpaceMember -> JoinRule.Restricted(
rules = this.spaceIds.map { AllowRule.RoomMembership(it) }.toImmutableList()
)
diff --git a/features/securityandprivacy/impl/src/main/res/values-cs/translations.xml b/features/securityandprivacy/impl/src/main/res/values-cs/translations.xml
index 71480958c6..d0d747f086 100644
--- a/features/securityandprivacy/impl/src/main/res/values-cs/translations.xml
+++ b/features/securityandprivacy/impl/src/main/res/values-cs/translations.xml
@@ -21,7 +21,7 @@ Nedoporučujeme povolovat šifrování pro místnosti, které může kdokoli naj
"Šifrování"
"Povolit koncové šifrování"
"Vstoupit může kdokoli."
- "Kdokoliv"
+ "Kdokoli"
"Vyberte, kteří členové prostorů mohou vstoupit do této místnosti bez pozvánky. %1$s"
"Spravovat prostory"
"Vstoupit mohou pouze pozvaní lidé."
diff --git a/features/securityandprivacy/impl/src/main/res/values-et/translations.xml b/features/securityandprivacy/impl/src/main/res/values-et/translations.xml
index a907f0bb16..e5c84338ce 100644
--- a/features/securityandprivacy/impl/src/main/res/values-et/translations.xml
+++ b/features/securityandprivacy/impl/src/main/res/values-et/translations.xml
@@ -21,7 +21,7 @@ Me ei soovita krüptimise kasutamist selliste avalike jututubade puhul, millega
"Krüptimine"
"Võta läbiv krüptimine kasutusele"
"Kõik võivad jututoaga liituda"
- "Avalik"
+ "Kõik"
"Vali kogukonnad, mille liikmed saavad selle jututoaga liituda ilma kutseta. %1$s"
"Halda kogukondi"
"Liituda saab vaid kutse olemasolul"
diff --git a/features/securityandprivacy/impl/src/main/res/values-nb/translations.xml b/features/securityandprivacy/impl/src/main/res/values-nb/translations.xml
index 035f99cfb5..72be67639b 100644
--- a/features/securityandprivacy/impl/src/main/res/values-nb/translations.xml
+++ b/features/securityandprivacy/impl/src/main/res/values-nb/translations.xml
@@ -2,9 +2,16 @@
"Du trenger en adresse for å gjøre den synlig i den offentlige katalogen."
"Rediger adresse"
+ "Områder hvor medlemmer kan bli med i rommet uten invitasjon."
+ "Administrer områder"
+ "(Ukjent område)"
+ "Andre områder du ikke er medlem i"
+ "Dine områder"
"Legg til adresse"
+ "Alle i autoriserte områder kan bli med, men alle andre må be om tilgang."
"Alle må be om tilgang."
"Be om å få bli med"
+ "Alle i %1$s kan bli med, men alle andre må be om tilgang."
"Ja, aktiver kryptering"
"Når kryptering for et rom er aktivert, kan den ikke deaktiveres. Meldingshistorikken vil bare være synlig for rommedlemmer siden de ble invitert eller siden de ble med i rommet.
Ingen andre enn rommedlemmene vil kunne lese meldingene. Dette kan føre til at bots og broer ikke fungerer som de skal.
@@ -15,22 +22,28 @@ Vi anbefaler ikke å aktivere kryptering for rom som hvem som helst kan finne og
"Aktiver ende-til-ende-kryptering"
"Alle kan bli med."
"Hvem som helst"
+ "Administrer områder"
"Bare inviterte personer kan bli med."
"Kun for inviterte"
"Tilgang"
+ "Alle i autoriserte områder kan bli med."
+ "Alle i %1$s kan bli med."
"Medlemmer av område"
"Områder støttes ikke for øyeblikket"
"Du trenger en adresse for å gjøre den synlig i den offentlige katalogen."
"Adresse"
"Tillat at dette rommet blir funnet ved å søke %1$s offentlig romkatalog"
+ "Tillat å bli funnet ved søk i den offentlige katalogen."
"Synlig i offentlig katalog"
"Alle (historikken er offentlig)"
+ "Endringene vil ikke påvirke tidligere meldinger, kun nye. %1$s"
"Hvem kan lese historikk"
"Medlemmer siden de ble invitert"
"Medlemmer (full historikk)"
"Romadresser er måter å finne og få tilgang til rom på. Dette sikrer også at du enkelt kan dele rommet ditt med andre.
Du kan velge å publisere rommet ditt i hjemeserverens offentlige romkatalog."
"Publisering av rom"
+ "Adresser er en måte å finne og få tilgang til rom og områder. Dette sikrer også at du enkelt kan dele dem med andre."
"Synlighet"
"Sikkerhet og personvern"
diff --git a/features/securityandprivacy/impl/src/main/res/values-sv/translations.xml b/features/securityandprivacy/impl/src/main/res/values-sv/translations.xml
index c77201aa5b..b99e4e53cf 100644
--- a/features/securityandprivacy/impl/src/main/res/values-sv/translations.xml
+++ b/features/securityandprivacy/impl/src/main/res/values-sv/translations.xml
@@ -3,7 +3,7 @@
"Du behöver en rumsadress för att göra den synlig i katalogen."
"Rumsadress"
"Lägg till rumsadress"
- "Vem som helst kan be om att gå med i rummet men en administratör eller moderator måste acceptera begäran."
+ "Alla måste begära åtkomst."
"Be om att gå med"
"Ja, aktivera kryptering"
"När det är aktiverat kan kryptering för ett rum inte inaktiveras, meddelandehistoriken visas bara för rumsmedlemmar sedan de blev inbjudna eller sedan de gick med i rummet.
@@ -15,9 +15,9 @@ Vi rekommenderar inte att aktivera kryptering för rum som vem som helst kan hit
"Aktivera totalsträckskryptering"
"Vem som helst kan hitta och gå med"
"Vem som helst"
- "Användare kan bara gå med om de är inbjudna"
+ "Endast inbjudna personer kan gå med."
"Endast inbjudan"
- "Tillgång till rum"
+ "Åtkomst"
"Utrymmesmedlemmar"
"Utrymmen stöds för närvarande inte"
"Du behöver en rumsadress för att göra den synlig i katalogen."
diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt
index e9cd49cc94..d2844c79f0 100644
--- a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt
+++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt
@@ -309,7 +309,7 @@ class SecurityAndPrivacyPresenterTest {
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
getRoomVisibilityResult = { Result.success(RoomVisibility.Private) },
- initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Private)
+ initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Invite)
),
enableEncryptionResult = enableEncryptionLambda,
updateJoinRuleResult = updateJoinRuleLambda,
@@ -809,14 +809,13 @@ class SecurityAndPrivacyPresenterTest {
)
)
)
- // No spaces available, so isSpaceMemberSelectable should be false
+ // Room has SpaceMember access with existing space ID, so isSpaceMemberSelectable is true
val presenter = createSecurityAndPrivacyPresenter(room = room)
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(savedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.SpaceMember::class.java)
- assertThat(isSpaceMemberSelectable).isFalse()
- // showSpaceMemberOption should still be true because savedSettings has SpaceMember
+ assertThat(isSpaceMemberSelectable).isTrue()
assertThat(showSpaceMemberOption).isTrue()
}
cancelAndIgnoreRemainingEvents()
@@ -1091,7 +1090,7 @@ class SecurityAndPrivacyPresenterTest {
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
getRoomVisibilityResult = { Result.success(RoomVisibility.Private) },
- initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Private)
+ initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Invite)
),
),
navigator: SecurityAndPrivacyNavigator = FakeSecurityAndPrivacyNavigator(),
diff --git a/features/space/impl/build.gradle.kts b/features/space/impl/build.gradle.kts
index a29d388587..deec862fa3 100644
--- a/features/space/impl/build.gradle.kts
+++ b/features/space/impl/build.gradle.kts
@@ -52,4 +52,5 @@ dependencies {
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.features.createroom.test)
testImplementation(projects.features.invite.test)
+ testImplementation(projects.features.rolesandpermissions.test)
}
diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt
index 666a538a29..e3698aacb2 100644
--- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt
+++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt
@@ -14,6 +14,7 @@ import android.os.Parcelable
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@@ -21,10 +22,13 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
+import com.bumble.appyx.navmodel.backstack.operation.replace
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.createroom.api.CreateRoomEntryPoint
+import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint
+import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.features.space.impl.addroom.AddRoomToSpaceNode
import io.element.android.features.space.impl.di.SpaceFlowGraph
@@ -37,9 +41,15 @@ import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.DependencyInjectionGraphOwner
import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.spaces.SpaceService
+import io.element.android.libraries.matrix.api.spaces.loadAllIncrementally
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
@@ -47,10 +57,12 @@ import kotlinx.parcelize.Parcelize
class SpaceFlowNode(
@Assisted val buildContext: BuildContext,
@Assisted plugins: List,
- room: JoinedRoom,
+ private val room: JoinedRoom,
spaceService: SpaceService,
graphFactory: SpaceFlowGraph.Factory,
private val createRoomEntryPoint: CreateRoomEntryPoint,
+ private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
+ @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : BaseFlowNode(
backstack = BackStack(
initialElement = NavTarget.Root,
@@ -78,11 +90,17 @@ class SpaceFlowNode(
@Parcelize
data object AddRoom : NavTarget
+
+ @Parcelize
+ data object ChangeOwners : NavTarget
}
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
+ onCreate = {
+ spaceRoomList.loadAllIncrementally(lifecycleScope)
+ },
onDestroy = {
spaceRoomList.destroy()
}
@@ -100,6 +118,10 @@ class SpaceFlowNode(
override fun navigateToRolesAndPermissions() {
backstack.push(NavTarget.Settings(SpaceSettingsFlowNode.NavTarget.RolesAndPermissions))
}
+
+ override fun navigateToChooseOwners() {
+ backstack.replace(NavTarget.ChangeOwners)
+ }
}
createNode(buildContext, listOf(callback))
}
@@ -152,7 +174,10 @@ class SpaceFlowNode(
is NavTarget.CreateRoom -> {
val callback = object : CreateRoomEntryPoint.Callback {
override fun onRoomCreated(roomId: RoomId) {
+ // Reset the room list in the space so this new room is displayed
+ lifecycleScope.launch { spaceRoomList.reset() }
callback.navigateToRoom(roomId, emptyList())
+ backstack.pop()
}
}
createRoomEntryPoint
@@ -161,7 +186,7 @@ class SpaceFlowNode(
buildContext = buildContext,
callback = callback,
)
- .setParentSpace(spaceRoomList.roomId)
+ .setParentSpace(spaceRoomList.spaceId)
.build()
}
NavTarget.AddRoom -> {
@@ -172,6 +197,29 @@ class SpaceFlowNode(
}
createNode(buildContext, listOf(callback))
}
+ NavTarget.ChangeOwners -> {
+ val node = changeRoomMemberRolesEntryPoint.createNode(
+ parentNode = this,
+ buildContext = buildContext,
+ room = room,
+ listType = ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving,
+ )
+
+ val completionProxy = node as ChangeRoomMemberRolesEntryPoint.NodeProxy
+ sessionCoroutineScope.launch {
+ val changedOwners = withContext(NonCancellable) {
+ completionProxy.waitForCompletion()
+ }
+
+ if (changedOwners) {
+ backstack.replace(NavTarget.Leave)
+ } else {
+ backstack.pop()
+ }
+ }
+
+ node
+ }
}
}
diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceEvent.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceEvent.kt
index ee4dc12ba4..1d8d157bc8 100644
--- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceEvent.kt
+++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceEvent.kt
@@ -14,4 +14,6 @@ sealed interface AddRoomToSpaceEvent {
data class OnSearchActiveChanged(val active: Boolean) : AddRoomToSpaceEvent
data object Save : AddRoomToSpaceEvent
data object ResetSaveAction : AddRoomToSpaceEvent
+ data object Dismiss : AddRoomToSpaceEvent
+ data class UpdateSearchVisibleRange(val range: IntRange) : AddRoomToSpaceEvent
}
diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceNode.kt
index 71e7665844..dadd54cf6f 100644
--- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceNode.kt
+++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceNode.kt
@@ -40,7 +40,7 @@ class AddRoomToSpaceNode(
val state by stateFlow.collectAsState()
AddRoomToSpaceView(
state = state,
- onBackClick = ::navigateUp,
+ onBackClick = callback::onFinish,
onRoomsAdded = callback::onFinish,
modifier = modifier
)
diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenter.kt
index 657d0702f8..e4f58fdff2 100644
--- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenter.kt
+++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenter.kt
@@ -12,7 +12,6 @@ import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -25,6 +24,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
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.spaces.SpaceRoomList
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
@@ -45,9 +45,11 @@ class AddRoomToSpacePresenter(
@Composable
override fun present(): AddRoomToSpaceState {
var selectedRooms: ImmutableList by remember { mutableStateOf(persistentListOf()) }
- var searchQuery = rememberTextFieldState()
+ val searchQuery = rememberTextFieldState()
var isSearchActive by remember { mutableStateOf(false) }
val saveAction = remember { mutableStateOf>(AsyncAction.Uninitialized) }
+ // Track whether any rooms were added (for conditional reset on Dismiss)
+ var hasAddedRooms by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val dataSource = remember { dataSourceFactory.create(coroutineScope) }
@@ -56,19 +58,16 @@ class AddRoomToSpacePresenter(
LaunchedEffect(searchQuery.text) {
dataSource.setSearchQuery(searchQuery.text.toString())
}
- LaunchedEffect(isSearchActive) {
- dataSource.setIsActive(isSearchActive)
- }
val suggestions by dataSource.suggestions.collectAsState(initial = persistentListOf())
val filteredRooms by dataSource.roomInfoList.collectAsState(initial = persistentListOf())
- val searchResults by remember>>> {
+ val searchResults by remember {
derivedStateOf {
when {
filteredRooms.isNotEmpty() -> SearchBarResultState.Results(filteredRooms)
- isSearchActive && searchQuery.text.isNotEmpty() -> SearchBarResultState.NoResultsFound()
- else -> SearchBarResultState.Initial()
+ isSearchActive && searchQuery.text.isNotEmpty() -> SearchBarResultState.NoResultsFound>()
+ else -> SearchBarResultState.Initial>()
}
}
}
@@ -91,12 +90,27 @@ class AddRoomToSpacePresenter(
AddRoomToSpaceEvent.Save -> {
coroutineScope.addRoomsToSpace(
selectedRooms = selectedRooms,
- addAction = saveAction,
+ dataSource = dataSource,
+ saveAction = saveAction,
+ onPartialSuccess = { successfullyAdded ->
+ if (successfullyAdded.isNotEmpty()) {
+ hasAddedRooms = true
+ }
+ selectedRooms = selectedRooms.filterNot { it.roomId in successfullyAdded }.toImmutableList()
+ },
)
}
AddRoomToSpaceEvent.ResetSaveAction -> {
saveAction.value = AsyncAction.Uninitialized
}
+ AddRoomToSpaceEvent.Dismiss -> {
+ if (hasAddedRooms) {
+ coroutineScope.launch { spaceRoomList.reset() }
+ }
+ }
+ is AddRoomToSpaceEvent.UpdateSearchVisibleRange -> coroutineScope.launch {
+ dataSource.updateVisibleRange(event.range)
+ }
}
}
@@ -113,21 +127,30 @@ class AddRoomToSpacePresenter(
private fun CoroutineScope.addRoomsToSpace(
selectedRooms: ImmutableList,
- addAction: MutableState>,
+ dataSource: AddRoomToSpaceSearchDataSource,
+ saveAction: MutableState>,
+ onPartialSuccess: (Set) -> Unit,
) = launch {
- addAction.runUpdatingState {
- val results = selectedRooms.map { selectedRoom ->
+ saveAction.runUpdatingState {
+ val spaceId = spaceRoomList.spaceId
+ val successfullyAdded = mutableSetOf()
+ val results = selectedRooms.map { room ->
async {
spaceService.addChildToSpace(
- spaceId = spaceRoomList.roomId,
- childId = selectedRoom.roomId,
- )
+ spaceId = spaceId,
+ childId = room.roomId,
+ ).onSuccess { successfullyAdded.add(room.roomId) }
}
}.awaitAll()
val anyFailure = results.any { it.isFailure }
if (anyFailure) {
+ // On partial success, mark added rooms in data source and update selection
+ dataSource.markAsAdded(successfullyAdded)
+ onPartialSuccess(successfullyAdded)
Result.failure(Exception("Failed to add some rooms"))
} else {
+ // On full success, refresh the space room list
+ spaceRoomList.reset()
Result.success(Unit)
}
}
diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceSearchDataSource.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceSearchDataSource.kt
index 61f08d53ae..10c70d6f7d 100644
--- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceSearchDataSource.kt
+++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceSearchDataSource.kt
@@ -20,15 +20,15 @@ import io.element.android.libraries.matrix.api.room.recent.getRecentlyVisitedRoo
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.RoomListService
-import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally
+import io.element.android.libraries.matrix.api.roomlist.updateVisibleRange
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
@@ -48,7 +48,7 @@ class AddRoomToSpaceSearchDataSource(
roomListService: RoomListService,
spaceRoomList: SpaceRoomList,
private val matrixClient: MatrixClient,
- private val coroutineDispatchers: CoroutineDispatchers,
+ coroutineDispatchers: CoroutineDispatchers,
) {
@AssistedFactory
interface Factory {
@@ -57,47 +57,58 @@ class AddRoomToSpaceSearchDataSource(
private val roomList = roomListService.createRoomList(
pageSize = PAGE_SIZE,
- initialFilter = RoomListFilter.all(),
source = RoomList.Source.All,
coroutineScope = coroutineScope,
)
- private val spaceChildrenFlow = spaceRoomList.spaceRoomsFlow.map { spaceChildren ->
- spaceChildren.map { it.roomId }.toSet()
+ private val spaceChildrenFlow = spaceRoomList.spaceRoomsFlow.map { rooms ->
+ rooms.map { it.roomId }.toSet()
}
- private val filterRoomPredicate: (RoomInfo, Set) -> Boolean = { info, childIds ->
+ // Track locally added rooms for partial success handling.
+ // These rooms will be filtered out from search results and suggestions.
+ private val addedRoomIdsFlow = MutableStateFlow>(emptySet())
+
+ /**
+ * Marks rooms as added to the space (for partial success handling).
+ */
+ fun markAsAdded(roomIds: Set) {
+ addedRoomIdsFlow.value += roomIds
+ }
+
+ private val filterRoomPredicate: (RoomInfo, Set, Set) -> Boolean = { info, childIds, addedIds ->
!info.isSpace &&
!info.isDm &&
info.currentUserMembership == CurrentUserMembership.JOINED &&
- info.id !in childIds
+ info.id !in childIds &&
+ info.id !in addedIds
}
val roomInfoList: Flow> = combine(
- roomList.filteredSummaries,
+ roomList.summaries,
spaceChildrenFlow,
- ) { roomSummaries, childIds ->
+ addedRoomIdsFlow,
+ ) { roomSummaries, childIds, addedIds ->
roomSummaries
- .filter { filterRoomPredicate(it.info, childIds) }
- .map { it.toSelectRoomInfo() }
+ .filter { filterRoomPredicate(it.info, childIds, addedIds) }
+ .map { it.info.toSelectRoomInfo() }
.toImmutableList()
}.flowOn(coroutineDispatchers.computation)
- val suggestions: Flow> = spaceChildrenFlow.map { childIds ->
+ val suggestions: Flow> = combine(
+ spaceChildrenFlow,
+ addedRoomIdsFlow,
+ ) { childIds, addedIds ->
matrixClient
- .getRecentlyVisitedRoomInfoFlow { filterRoomPredicate(it, childIds) }
+ .getRecentlyVisitedRoomInfoFlow { filterRoomPredicate(it, childIds, addedIds) }
.take(MAX_SUGGESTIONS_COUNT)
- .map { it.toSelectRoomInfo() }
.toList()
+ .map { it.toSelectRoomInfo() }
.toImmutableList()
}.flowOn(coroutineDispatchers.computation)
- suspend fun setIsActive(isActive: Boolean) = coroutineScope {
- if (isActive) {
- roomList.loadAllIncrementally(this)
- } else {
- roomList.reset()
- }
+ suspend fun updateVisibleRange(visibleRange: IntRange) {
+ roomList.updateVisibleRange(visibleRange)
}
suspend fun setSearchQuery(searchQuery: String) {
diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceView.kt
index 854924041b..c64efa8074 100644
--- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceView.kt
+++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceView.kt
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -43,6 +44,7 @@ import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect
import io.element.android.libraries.matrix.ui.components.SelectedRoom
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.getAvatarData
@@ -58,14 +60,15 @@ fun AddRoomToSpaceView(
onRoomsAdded: () -> Unit,
modifier: Modifier = Modifier,
) {
- fun onRoomRemoved(roomInfo: SelectRoomInfo) {
- state.eventSink(AddRoomToSpaceEvent.ToggleRoom(roomInfo))
+ fun onRoomToggled(room: SelectRoomInfo) {
+ state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room))
}
fun onBack() {
if (state.isSearchActive) {
state.eventSink(AddRoomToSpaceEvent.OnSearchActiveChanged(false))
} else {
+ state.eventSink(AddRoomToSpaceEvent.Dismiss)
onBackClick()
}
}
@@ -114,18 +117,22 @@ fun AddRoomToSpaceView(
if (state.selectedRooms.isNotEmpty()) {
SelectedRoomsRow(
selectedRooms = state.selectedRooms,
- onRemoveRoom = ::onRoomRemoved,
+ onRemoveRoom = ::onRoomToggled,
modifier = Modifier.padding(vertical = 16.dp)
)
}
},
) { rooms ->
+ val lazyListState = rememberLazyListState()
+ OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
+ state.eventSink(AddRoomToSpaceEvent.UpdateSearchVisibleRange(visibleRange))
+ }
LazyColumn {
- items(rooms, key = { it.roomId.value }) { roomInfo ->
+ items(rooms, key = { it.roomId }) { roomInfo ->
RoomListItem(
roomInfo = roomInfo,
isSelected = state.selectedRooms.any { it.roomId == roomInfo.roomId },
- onToggle = { state.eventSink(AddRoomToSpaceEvent.ToggleRoom(it)) }
+ onToggle = ::onRoomToggled
)
}
}
@@ -142,7 +149,7 @@ fun AddRoomToSpaceView(
if (state.selectedRooms.isNotEmpty()) {
SelectedRoomsRow(
selectedRooms = state.selectedRooms,
- onRemoveRoom = ::onRoomRemoved,
+ onRemoveRoom = ::onRoomToggled,
modifier = Modifier.padding(vertical = 16.dp)
)
}
@@ -159,7 +166,7 @@ fun AddRoomToSpaceView(
RoomListItem(
roomInfo = roomInfo,
isSelected = state.selectedRooms.any { it.roomId == roomInfo.roomId },
- onToggle = { state.eventSink(AddRoomToSpaceEvent.ToggleRoom(it)) }
+ onToggle = ::onRoomToggled
)
}
}
@@ -205,8 +212,8 @@ private fun SelectedRoomsRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(32.dp)
) {
- items(selectedRooms, key = { it.roomId.value }) { roomInfo ->
- SelectedRoom(roomInfo = roomInfo, onRemoveRoom = onRemoveRoom)
+ items(selectedRooms, key = { it.roomId }) { roomInfo ->
+ SelectedRoom(roomInfo = roomInfo, onRemoveRoom = { onRemoveRoom(roomInfo) })
}
}
}
diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceNode.kt
index c41acc0c05..6f7ef89e5a 100644
--- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceNode.kt
+++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceNode.kt
@@ -34,6 +34,7 @@ class LeaveSpaceNode(
interface Callback : Plugin {
fun closeLeaveSpaceFlow()
fun navigateToRolesAndPermissions()
+ fun navigateToChooseOwners()
}
private val leaveSpaceHandle = matrixClient.spaceService.getLeaveSpaceHandle(room.roomId)
@@ -57,6 +58,7 @@ class LeaveSpaceNode(
state = state,
onCancel = callback::closeLeaveSpaceFlow,
onRolesAndPermissionsClick = callback::navigateToRolesAndPermissions,
+ onChooseOwnersClick = callback::navigateToChooseOwners,
modifier = modifier
)
}
diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenter.kt
index 2d30bc8617..4e1fd34673 100644
--- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenter.kt
+++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenter.kt
@@ -92,6 +92,7 @@ class LeaveSpacePresenter(
SelectableSpaceRoom(
spaceRoom = room.spaceRoom,
isLastOwner = room.isLastOwner,
+ joinedMembersCount = room.spaceRoom.numJoinedMembers,
isSelected = selectedRoomIds.contains(room.spaceRoom.roomId),
)
}.toImmutableList()
@@ -130,9 +131,11 @@ class LeaveSpacePresenter(
}
}
+ val currentSpaceToLeave = leaveSpaceRooms.dataOrNull()?.current
return LeaveSpaceState(
- spaceName = leaveSpaceRooms.dataOrNull()?.current?.spaceRoom?.displayName,
- isLastOwner = leaveSpaceRooms.dataOrNull()?.current?.isLastOwner == true,
+ spaceName = currentSpaceToLeave?.spaceRoom?.displayName,
+ needsOwnerChange = currentSpaceToLeave?.let { it.spaceRoom.numJoinedMembers > 1 && it.isLastOwner } == true,
+ areCreatorsPrivileged = currentSpaceToLeave?.areCreatorsPrivileged == true,
selectableSpaceRooms = selectableSpaceRooms,
leaveSpaceAction = leaveSpaceAction.value,
eventSink = ::handleEvent,
diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceState.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceState.kt
index 2ff98ea583..9bf029cb15 100644
--- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceState.kt
+++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceState.kt
@@ -15,7 +15,8 @@ import kotlinx.collections.immutable.toImmutableList
data class LeaveSpaceState(
val spaceName: String?,
- val isLastOwner: Boolean,
+ val needsOwnerChange: Boolean,
+ val areCreatorsPrivileged: Boolean,
val selectableSpaceRooms: AsyncData>,
val leaveSpaceAction: AsyncAction,
val eventSink: (LeaveSpaceEvents) -> Unit,
@@ -25,7 +26,7 @@ data class LeaveSpaceState(
private val selectableRooms: ImmutableList
init {
- val partition = rooms.partition { it.isLastOwner }
+ val partition = rooms.partition { it.isLastOwner && it.joinedMembersCount > 1 }
lastAdminRooms = partition.first.toImmutableList()
selectableRooms = partition.second.toImmutableList()
}
@@ -33,12 +34,12 @@ data class LeaveSpaceState(
/**
* True if we should show the quick action to select/deselect all rooms.
*/
- val showQuickAction = isLastOwner.not() && selectableRooms.isNotEmpty()
+ val showQuickAction = needsOwnerChange.not() && selectableRooms.isNotEmpty()
/**
* True if we should show the leave button.
*/
- val showLeaveButton = isLastOwner.not() && selectableSpaceRooms is AsyncData.Success
+ val showLeaveButton = needsOwnerChange.not() && selectableSpaceRooms is AsyncData.Success
/**
* True if there all the selectable rooms are selected.
diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceStateProvider.kt
index 362e987fc4..3d8df360a7 100644
--- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceStateProvider.kt
+++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceStateProvider.kt
@@ -38,7 +38,7 @@ class LeaveSpaceStateProvider : PreviewParameterProvider {
),
aSelectableSpaceRoom(
spaceRoom = aSpaceRoom(
- joinRule = JoinRule.Private,
+ joinRule = JoinRule.Invite,
),
isSelected = false,
),
@@ -56,7 +56,7 @@ class LeaveSpaceStateProvider : PreviewParameterProvider {
),
aSelectableSpaceRoom(
spaceRoom = aSpaceRoom(
- joinRule = JoinRule.Private,
+ joinRule = JoinRule.Invite,
),
isSelected = true,
),
@@ -109,17 +109,23 @@ class LeaveSpaceStateProvider : PreviewParameterProvider {
aLeaveSpaceState(
isLastOwner = true,
),
+ aLeaveSpaceState(
+ isLastOwner = true,
+ areCreatorsPrivileged = true,
+ ),
)
}
fun aLeaveSpaceState(
spaceName: String? = "Space name",
isLastOwner: Boolean = false,
+ areCreatorsPrivileged: Boolean = false,
selectableSpaceRooms: AsyncData> = AsyncData.Uninitialized,
leaveSpaceAction: AsyncAction = AsyncAction.Uninitialized,
) = LeaveSpaceState(
spaceName = spaceName,
- isLastOwner = isLastOwner,
+ needsOwnerChange = isLastOwner,
+ areCreatorsPrivileged = areCreatorsPrivileged,
selectableSpaceRooms = selectableSpaceRooms,
leaveSpaceAction = leaveSpaceAction,
eventSink = { }
@@ -128,9 +134,11 @@ fun aLeaveSpaceState(
fun aSelectableSpaceRoom(
spaceRoom: SpaceRoom = aSpaceRoom(),
isLastOwner: Boolean = false,
+ joinedMembersCount: Int = 2,
isSelected: Boolean = false,
) = SelectableSpaceRoom(
spaceRoom = spaceRoom,
isLastOwner = isLastOwner,
+ joinedMembersCount = joinedMembersCount,
isSelected = isSelected,
)
diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt
index 52d52a103e..b4722fad0d 100644
--- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt
+++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt
@@ -12,14 +12,13 @@ package io.element.android.features.space.impl.leave
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.consumeWindowInsets
-import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
-import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
@@ -40,6 +39,7 @@ import io.element.android.features.space.impl.R
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
+import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncFailure
@@ -54,7 +54,6 @@ import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconSource
-import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@@ -71,30 +70,42 @@ fun LeaveSpaceView(
state: LeaveSpaceState,
onCancel: () -> Unit,
onRolesAndPermissionsClick: () -> Unit,
+ onChooseOwnersClick: () -> Unit,
modifier: Modifier = Modifier,
) {
- Scaffold(
+ HeaderFooterPage(
modifier = modifier,
+ contentPadding = PaddingValues(bottom = 14.dp),
topBar = {
- LeaveSpaceHeader(
- state = state,
- onBackClick = onCancel,
+ TopAppBar(
+ navigationIcon = {
+ BackButton(onClick = onCancel)
+ },
+ title = {},
)
},
- containerColor = ElementTheme.colors.bgCanvasDefault,
- ) { padding ->
- Column(
- modifier = Modifier
- .padding(padding)
- .imePadding()
- .consumeWindowInsets(padding)
- .fillMaxSize()
- ) {
- LazyColumn(
- modifier = Modifier
- .weight(1f),
- ) {
- if (state.isLastOwner.not()) {
+ header = {
+ LeaveSpaceHeader(state = state)
+ },
+ footer = {
+ LeaveSpaceButtons(
+ showLeaveButton = state.showLeaveButton,
+ selectedRoomsCount = state.selectedRoomsCount,
+ onLeaveSpace = {
+ state.eventSink(LeaveSpaceEvents.LeaveSpace)
+ },
+ onCancel = onCancel,
+ showRolesAndPermissionsButton = state.needsOwnerChange && !state.areCreatorsPrivileged,
+ showChooseOwnersButton = state.needsOwnerChange && state.areCreatorsPrivileged,
+ onChooseOwnersButtonClick = onChooseOwnersClick,
+ onRolesAndPermissionsClick = onRolesAndPermissionsClick,
+ )
+ },
+ content = {
+ if (state.needsOwnerChange.not()) {
+ LazyColumn(
+ modifier = Modifier.padding(top = 20.dp),
+ ) {
when (state.selectableSpaceRooms) {
is AsyncData.Success -> {
// List rooms where the user is the only admin
@@ -125,18 +136,8 @@ fun LeaveSpaceView(
}
}
}
- LeaveSpaceButtons(
- showLeaveButton = state.showLeaveButton,
- selectedRoomsCount = state.selectedRoomsCount,
- onLeaveSpace = {
- state.eventSink(LeaveSpaceEvents.LeaveSpace)
- },
- onCancel = onCancel,
- showRolesAndPermissionsButton = state.isLastOwner,
- onRolesAndPermissionsClick = onRolesAndPermissionsClick,
- )
}
- }
+ )
AsyncActionView(
async = state.leaveSpaceAction,
@@ -149,25 +150,27 @@ fun LeaveSpaceView(
@Composable
private fun LeaveSpaceHeader(
state: LeaveSpaceState,
- onBackClick: () -> Unit,
) {
Column {
- TopAppBar(
- navigationIcon = {
- BackButton(onClick = onBackClick)
- },
- title = {},
- )
IconTitleSubtitleMolecule(
- modifier = Modifier.padding(top = 0.dp, bottom = 8.dp, start = 24.dp, end = 24.dp),
+ modifier = Modifier.padding(top = 24.dp, bottom = 8.dp, start = 24.dp, end = 24.dp),
iconStyle = BigIcon.Style.AlertSolid,
- title = stringResource(
- if (state.isLastOwner) R.string.screen_leave_space_title_last_admin else R.string.screen_leave_space_title,
- state.spaceName ?: stringResource(CommonStrings.common_space)
- ),
+ title = if (state.needsOwnerChange) {
+ if (state.areCreatorsPrivileged) {
+ stringResource(R.string.screen_leave_space_title_last_owner)
+ } else {
+ stringResource(R.string.screen_leave_space_title_last_admin, state.spaceName ?: stringResource(CommonStrings.common_space))
+ }
+ } else {
+ stringResource(R.string.screen_leave_space_title, state.spaceName ?: stringResource(CommonStrings.common_space))
+ },
subTitle =
- if (state.isLastOwner) {
- stringResource(R.string.screen_leave_space_subtitle_last_admin)
+ if (state.needsOwnerChange) {
+ if (state.areCreatorsPrivileged) {
+ stringResource(R.string.screen_leave_space_subtitle_last_owner, state.spaceName ?: stringResource(CommonStrings.common_space))
+ } else {
+ stringResource(R.string.screen_leave_space_subtitle_last_admin)
+ }
} else if (state.selectableSpaceRooms is AsyncData.Success && state.selectableSpaceRooms.data.isNotEmpty()) {
if (state.hasOnlyLastAdminRoom) {
stringResource(R.string.screen_leave_space_subtitle_only_last_admin)
@@ -216,10 +219,12 @@ private fun LeaveSpaceButtons(
onLeaveSpace: () -> Unit,
showRolesAndPermissionsButton: Boolean,
onRolesAndPermissionsClick: () -> Unit,
+ showChooseOwnersButton: Boolean,
+ onChooseOwnersButtonClick: () -> Unit,
onCancel: () -> Unit,
) {
ButtonColumnMolecule(
- modifier = Modifier.padding(16.dp)
+ modifier = Modifier.padding(top = 16.dp)
) {
if (showLeaveButton) {
val text = if (selectedRoomsCount > 0) {
@@ -243,6 +248,14 @@ private fun LeaveSpaceButtons(
leadingIcon = IconSource.Vector(CompoundIcons.Settings()),
)
}
+ if (showChooseOwnersButton) {
+ Button(
+ text = stringResource(R.string.screen_leave_space_choose_owners_action),
+ onClick = onChooseOwnersButtonClick,
+ modifier = Modifier.fillMaxWidth(),
+ destructive = true,
+ )
+ }
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_cancel),
@@ -262,6 +275,7 @@ private fun SpaceItem(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 66.dp)
+ .padding(horizontal = 16.dp)
.toggleable(
value = selectableSpaceRoom.isSelected,
role = Role.Checkbox,
@@ -276,9 +290,9 @@ private fun SpaceItem(
onClick = onClick,
),
verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Avatar(
- modifier = Modifier.padding(horizontal = 16.dp),
avatarData = room.getAvatarData(AvatarSize.LeaveSpaceRoom),
avatarType = if (room.isSpace) AvatarType.Space() else AvatarType.Room(),
)
@@ -297,8 +311,8 @@ private fun SpaceItem(
Row(
verticalAlignment = Alignment.CenterVertically,
) {
- if (room.joinRule == JoinRule.Private) {
- // Picto for private
+ if (room.joinRule == JoinRule.Invite) {
+ // Picto for invite only
Icon(
modifier = Modifier
.size(16.dp)
@@ -358,5 +372,6 @@ internal fun LeaveSpaceViewPreview(
state = state,
onCancel = {},
onRolesAndPermissionsClick = {},
+ onChooseOwnersClick = {},
)
}
diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/SelectableSpaceRoom.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/SelectableSpaceRoom.kt
index a6c42e16dc..a5dae34f15 100644
--- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/SelectableSpaceRoom.kt
+++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/SelectableSpaceRoom.kt
@@ -13,5 +13,6 @@ import io.element.android.libraries.matrix.api.spaces.SpaceRoom
data class SelectableSpaceRoom(
val spaceRoom: SpaceRoom,
val isLastOwner: Boolean,
+ val joinedMembersCount: Int,
val isSelected: Boolean,
)
diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt
index 5a5b10671c..9cb5138933 100644
--- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt
+++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt
@@ -54,7 +54,7 @@ class SpaceNode(
private val callback: Callback = callback()
private fun onShareRoom(context: Context) = lifecycleScope.launch {
- matrixClient.getRoom(spaceRoomList.roomId)?.use { room ->
+ matrixClient.getRoom(spaceRoomList.spaceId)?.use { room ->
room.getPermalink()
.onSuccess { permalink ->
context.startSharePlainTextIntent(
diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt
index a5bc2ec52e..807d139e6a 100644
--- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt
+++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt
@@ -6,6 +6,8 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(FlowPreview::class)
+
package io.element.android.features.space.impl.root
import androidx.compose.runtime.Composable
@@ -47,10 +49,13 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
+import kotlin.time.Duration.Companion.milliseconds
@Inject
class SpacePresenter(
@@ -69,7 +74,6 @@ class SpacePresenter(
@Composable
override fun present(): SpaceState {
LaunchedEffect(Unit) {
- paginate()
spaceRoomList.spaceRoomsFlow.collect { children = it.toImmutableList() }
}
@@ -81,13 +85,16 @@ class SpacePresenter(
val localCoroutineScope = rememberCoroutineScope()
val hasMoreToLoad by remember {
- spaceRoomList.paginationStatusFlow.mapState { status ->
- when (status) {
- is SpaceRoomList.PaginationStatus.Idle -> status.hasMoreToLoad
- SpaceRoomList.PaginationStatus.Loading -> true
+ spaceRoomList.paginationStatusFlow
+ .mapState { status ->
+ when (status) {
+ is SpaceRoomList.PaginationStatus.Idle -> status.hasMoreToLoad
+ SpaceRoomList.PaginationStatus.Loading -> true
+ }
}
- }
- }.collectAsState()
+ // Debounce to give more time for spaceRoomList to updates
+ .debounce(100.milliseconds)
+ }.collectAsState(true)
val permissions by room.permissionsAsState(SpacePermissions.DEFAULT) { perms ->
perms.spacePermissions()
@@ -111,21 +118,18 @@ class SpacePresenter(
var isManageMode by remember { mutableStateOf(false) }
var selectedRoomIds by remember { mutableStateOf>(emptySet()) }
var removeRoomsAction by remember { mutableStateOf>(AsyncAction.Uninitialized) }
+ // Track locally removed rooms for partial failure cases
var removedRoomIds by remember { mutableStateOf>(emptySet()) }
val filteredChildren by remember {
derivedStateOf {
- children
- .filterNot { it.roomId in removedRoomIds }
- .let { list ->
- if (isManageMode) {
- // In manage mode, only show rooms (not spaces)
- list.filter { !it.isSpace }
- } else {
- list
- }
- }
- .toImmutableList()
+ val notRemoved = children.filterNot { it.roomId in removedRoomIds }
+ if (isManageMode) {
+ // In manage mode, only show rooms (not spaces)
+ notRemoved.filter { !it.isSpace }.toImmutableList()
+ } else {
+ notRemoved.toImmutableList()
+ }
}
}
@@ -139,9 +143,20 @@ class SpacePresenter(
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
+ suspend fun exitManageMode(shouldReset: Boolean) {
+ isManageMode = false
+ selectedRoomIds = emptySet()
+ removedRoomIds = emptySet()
+ if (shouldReset) {
+ // Reset the space room list to see the updates.
+ spaceRoomList.reset()
+ }
+ }
+
fun handleEvent(event: SpaceEvents) {
when (event) {
- SpaceEvents.LoadMore -> localCoroutineScope.paginate()
+ // SpaceRoomList is loaded automatically as backend is really slow. Event is kept for future.
+ SpaceEvents.LoadMore -> Unit
is SpaceEvents.Join -> {
sessionCoroutineScope.joinRoom(event.spaceRoom, joinActions, setJoinActions)
}
@@ -170,8 +185,7 @@ class SpacePresenter(
selectedRoomIds = emptySet()
}
SpaceEvents.ExitManageMode -> {
- isManageMode = false
- selectedRoomIds = emptySet()
+ localCoroutineScope.launch { exitManageMode(shouldReset = removedRoomIds.isNotEmpty()) }
}
is SpaceEvents.ToggleRoomSelection -> {
selectedRoomIds = if (event.roomId in selectedRoomIds) {
@@ -186,7 +200,7 @@ class SpacePresenter(
SpaceEvents.ConfirmRoomRemoval -> {
localCoroutineScope.launch {
removeRoomsAction = AsyncAction.Loading
- val spaceId = spaceRoomList.roomId
+ val spaceId = spaceRoomList.spaceId
val roomsToRemove = selectedRoomIds.toSet()
val successfullyRemoved = mutableSetOf()
val results = roomsToRemove.map { roomId ->
@@ -196,16 +210,15 @@ class SpacePresenter(
}
}
results.awaitAll()
- if (successfullyRemoved.isNotEmpty()) {
- removedRoomIds = removedRoomIds + successfullyRemoved
- }
val hasError = successfullyRemoved.size < roomsToRemove.size
if (hasError) {
+ // On partial success, update selection to only keep failed rooms
+ selectedRoomIds = selectedRoomIds - successfullyRemoved
+ removedRoomIds = removedRoomIds + successfullyRemoved
removeRoomsAction = AsyncAction.Failure(Exception("Failed to remove some rooms"))
} else {
removeRoomsAction = AsyncAction.Success(Unit)
- isManageMode = false
- selectedRoomIds = emptySet()
+ exitManageMode(shouldReset = true)
}
}
}
@@ -246,8 +259,4 @@ class SpacePresenter(
setJoinActions(joinActions + mapOf(spaceRoom.roomId to AsyncAction.Failure(it)))
}
}
-
- private fun CoroutineScope.paginate() = launch {
- spaceRoomList.paginate()
- }
}
diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt
index c54bf0009e..a15f9cb94d 100644
--- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt
+++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt
@@ -50,6 +50,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.space.impl.R
import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule
import io.element.android.libraries.designsystem.components.BigIcon
@@ -76,6 +77,7 @@ import io.element.android.libraries.designsystem.theme.components.HorizontalDivi
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.IconSource
+import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
@@ -139,6 +141,7 @@ fun SpaceView(
SpaceViewTopBar(
spaceInfo = state.spaceInfo,
canAccessSpaceSettings = state.canAccessSpaceSettings,
+ canEditSpaceGraph = state.canEditSpaceGraph,
showManageRoomsAction = state.showManageRoomsAction,
onBackClick = onBackClick,
onLeaveSpaceClick = onLeaveSpaceClick,
@@ -169,6 +172,7 @@ fun SpaceView(
state.eventSink(SpaceEvents.ShowTopicViewer(topic))
},
onCreateRoomClick = onCreateRoomClick,
+ onAddRoomClick = onAddRoomClick,
)
JoinFailuresEffect(
hasAnyFailure = state.hasAnyJoinFailures,
@@ -242,6 +246,7 @@ private fun SpaceViewContent(
onRoomClick: (spaceRoom: SpaceRoom) -> Unit,
onTopicClick: (String) -> Unit,
onCreateRoomClick: () -> Unit,
+ onAddRoomClick: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier.fillMaxSize()) {
@@ -255,6 +260,7 @@ private fun SpaceViewContent(
Column {
SpaceHeaderView(
avatarData = spaceInfo.getAvatarData(AvatarSize.SpaceHeader),
+ alias = spaceInfo.canonicalAlias,
name = spaceInfo.name,
topic = spaceInfo.topic,
topicMaxLines = 2,
@@ -270,7 +276,10 @@ private fun SpaceViewContent(
if (state.children.isEmpty() && state.canEditSpaceGraph && !state.hasMoreToLoad) {
item {
- EmptySpaceView(onCreateRoomClick = onCreateRoomClick)
+ EmptySpaceView(
+ onCreateRoomClick = onCreateRoomClick,
+ onAddRoomClick = onAddRoomClick,
+ )
}
} else {
itemsIndexed(
@@ -331,7 +340,10 @@ private fun SpaceViewContent(
}
@Composable
-private fun EmptySpaceView(onCreateRoomClick: () -> Unit) {
+private fun EmptySpaceView(
+ onCreateRoomClick: () -> Unit,
+ onAddRoomClick: () -> Unit,
+) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(bottom = 24.dp),
@@ -339,15 +351,25 @@ private fun EmptySpaceView(onCreateRoomClick: () -> Unit) {
IconTitleSubtitleMolecule(
title = stringResource(R.string.screen_space_empty_state_title),
subTitle = null,
- iconStyle = BigIcon.Style.Default(CompoundIcons.Room()),
+ iconStyle = BigIcon.Style.Default(vectorIcon = CompoundIcons.Room(), usePrimaryTint = true),
modifier = Modifier.fillMaxWidth()
.padding(top = 40.dp, start = 24.dp, end = 24.dp, bottom = 24.dp),
)
- Button(
- text = stringResource(R.string.screen_space_add_room_action),
- leadingIcon = IconSource.Vector(CompoundIcons.Plus()),
- onClick = onCreateRoomClick,
- )
+ ButtonColumnMolecule(
+ modifier = Modifier.padding(horizontal = 16.dp)
+ ) {
+ Button(
+ text = stringResource(CommonStrings.action_add_existing_rooms),
+ leadingIcon = IconSource.Vector(CompoundIcons.Plus()),
+ onClick = onAddRoomClick,
+ modifier = Modifier.fillMaxWidth()
+ )
+ OutlinedButton(
+ text = stringResource(CommonStrings.action_create_room),
+ onClick = onCreateRoomClick,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
}
}
@@ -376,6 +398,7 @@ private fun LoadingMoreIndicator(
private fun SpaceViewTopBar(
spaceInfo: RoomInfo,
canAccessSpaceSettings: Boolean,
+ canEditSpaceGraph: Boolean,
showManageRoomsAction: Boolean,
onBackClick: () -> Unit,
onLeaveSpaceClick: () -> Unit,
@@ -416,7 +439,7 @@ private fun SpaceViewTopBar(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
- if (showManageRoomsAction) {
+ if (canEditSpaceGraph) {
SpaceMenuItem(
titleRes = CommonStrings.action_create_room,
icon = CompoundIcons.Plus(),
@@ -433,14 +456,16 @@ private fun SpaceViewTopBar(
onAddRoomClick()
}
)
- SpaceMenuItem(
- titleRes = CommonStrings.action_manage_rooms,
- icon = CompoundIcons.Edit(),
- onClick = {
- showMenu = false
- onManageRoomsClick()
- }
- )
+ if (showManageRoomsAction) {
+ SpaceMenuItem(
+ titleRes = CommonStrings.action_manage_rooms,
+ icon = CompoundIcons.Edit(),
+ onClick = {
+ showMenu = false
+ onManageRoomsClick()
+ }
+ )
+ }
HorizontalDivider()
}
SpaceMenuItem(
diff --git a/features/space/impl/src/main/res/values-cs/translations.xml b/features/space/impl/src/main/res/values-cs/translations.xml
index d98124c714..faa89f65a0 100644
--- a/features/space/impl/src/main/res/values-cs/translations.xml
+++ b/features/space/impl/src/main/res/values-cs/translations.xml
@@ -1,5 +1,6 @@
+ "Vyberte vlastníky"
"%1$s (Správce)"
- "Opustit %1$d místnost a prostor"
@@ -8,10 +9,21 @@
"Tím budete také odstraněni ze všech místností v tomto prostoru."
"Než budete moci odejít, musíte pro tento prostor přiřadit jiného správce."
+ "Jste jediným vlastníkem %1$s. Před odchodem musíte převést vlastnictví na někoho jiného."
"Z následujících místností nebudete odstraněni, protože jste jediným administrátorem:"
"Opustit %1$s?"
"Jste jediným administrátorem pro %1$s"
+ "Převést vlastnictví"
+ "Místnost"
+ "Přidání místnosti neovlivní přístup k místnosti. Chcete-li přístup změnit, přejděte do Nastavení místnosti > Zabezpečení a soukromí."
+ "Přidejte svou první místnost"
"Zobrazit členy"
+ "Odebrání místnosti neovlivní přístup k místnosti. Chcete-li přístup změnit, přejděte do sekce Informace o místnosti > Soukromí a zabezpečení."
+
+ - "Odstranit %1$d místnost od %2$s"
+ - "Odstranit %1$d místnosti od %2$s"
+ - "Odstranit %1$d místností od %2$s"
+
"Opustit prostor"
"Role a oprávnění"
"Zabezpečení a soukromí"
diff --git a/features/space/impl/src/main/res/values-de/translations.xml b/features/space/impl/src/main/res/values-de/translations.xml
index e549e1c4bf..8c273d0302 100644
--- a/features/space/impl/src/main/res/values-de/translations.xml
+++ b/features/space/impl/src/main/res/values-de/translations.xml
@@ -10,7 +10,9 @@
"Du wirst aus den folgenden Chats nicht entfernt, weil du der einzige Admin bist:"
"%1$s verlassen?"
"Du bist der einzige Administrator für %1$s"
+ "Chat"
"Das Hinzufügen eines Chats hat keinen Einfluss auf die Beitrittsregeln. Um die Regeln zu ändern, gehe zu \"Raum Info\" und dann zu \"Datenschutz und Sicherheit\""
+ "Füge deinen ersten Chat hinzu"
"Mitglieder anzeigen"
"Das Entfernen eines Chats hat keinen Einfluss auf die Beitrittsregeln. Um die Regeln zu ändern, gehe zu \"Raum Info\" und dann zu \"Datenschutz und Sicherheit\""
diff --git a/features/space/impl/src/main/res/values-et/translations.xml b/features/space/impl/src/main/res/values-et/translations.xml
index fdee05be05..8cb2ec1f8f 100644
--- a/features/space/impl/src/main/res/values-et/translations.xml
+++ b/features/space/impl/src/main/res/values-et/translations.xml
@@ -1,5 +1,6 @@
+ "Vali omanikud"
"%1$s (Peakasutaja)"
- "Lahku %1$d-st jututoast ja kogukonnast"
@@ -7,10 +8,20 @@
"Sellega eemaldad end ka kõikidest antud kogukonna jututubadest."
"Enne lahkumist pead sa selle kogukonna jaoks lisama vähemalt ühe täiendava peakasutaja."
+ "Sa oled „%1$s“ kogukonna viimane omanik. Enne lahkumist pead omandi kellelegi teisele üle andma."
"Sind ei saa järgnevatest jututubadest eemaldada, kuna oled seal/neis ainus peakasutaja:"
"Kas lahkud %1$s kogukonnast?"
"Sa oled siin ainus peakasutaja: %1$s"
+ "Anna omand üle"
+ "Jututuba"
+ "Jututoa lisamine ei mõjuta ligipääsu jututuppa. Selle muutmiseks ava „Jututoa seadistused“ → „Turvalisus ja privaatsus“."
+ "Lisa oma esimene jututuba"
"Vaata liikmeid"
+ "Jututoa eemaldamine ei mõjuta ligipääsu jututuppa. Selle muutmiseks ava „Jututoa seadistused“ → „Turvalisus ja privaatsus“."
+
+ - "Eemalda %1$d jututuba „%2$s“ kogukonnast"
+ - "Eemalda %1$d jututuba „%2$s“ kogukonnast"
+
"Lahku kogukonnast"
"Rollid ja õigused"
"Turvalisus ja privaatsus"
diff --git a/features/space/impl/src/main/res/values-fr/translations.xml b/features/space/impl/src/main/res/values-fr/translations.xml
index ef7c4182d2..dc3f298115 100644
--- a/features/space/impl/src/main/res/values-fr/translations.xml
+++ b/features/space/impl/src/main/res/values-fr/translations.xml
@@ -1,5 +1,6 @@
+ "Choisir les propriétaires"
"%1$s (Admin)"
- "Quitter %1$d salon et l’espace"
@@ -7,9 +8,11 @@
"Sélectionnez les salons que vous souhaitez quitter et dont vous n’êtes pas le seul administrateur:"
"Vous devez désigner un autre administrateur pour cet espace avant de pouvoir partir."
+ "Vous êtes le seul propriétaire de %1$s. Vous devez transférer la propriété de l’espace à quelqu’un d’autre avant de le quitter."
"Vous ne quitterez pas le ou les salons suivants car vous y êtes le seul administrateur:"
"Quitter %1$s?"
"Vous êtes le seul administrateur de %1$s"
+ "Transfert de propriété"
"Salon"
"Ajouter un salon ne changera pas l’accès au salon. Pour modifier l’accès, aller dans les paramètres du salon puis dans Sécurité & confidentialité."
"Ajoutez votre premier salon"
diff --git a/features/space/impl/src/main/res/values-hu/translations.xml b/features/space/impl/src/main/res/values-hu/translations.xml
index e50d95ac3b..2823500783 100644
--- a/features/space/impl/src/main/res/values-hu/translations.xml
+++ b/features/space/impl/src/main/res/values-hu/translations.xml
@@ -7,6 +7,7 @@
"Ez a tér összes szobájából is eltávolítja."
"Mielőtt elhagyhatná ezt a teret, ki kell jelölnie egy másik adminisztrátort."
+ "Ön a(z) %1$s egyetlen tulajdonosa. Mielőtt távozik, át kell ruháznia a tulajdonjogot valaki másra."
"Nem lesz eltávolítva a következő szobá(k)ból, mert ön az egyetlen adminisztrátor:"
"Kilép innen: %1$s?"
"Ön az egyetlen adminisztrátor itt: %1$s"
diff --git a/features/space/impl/src/main/res/values/localazy.xml b/features/space/impl/src/main/res/values/localazy.xml
index b5495b721a..414f6c5ccf 100644
--- a/features/space/impl/src/main/res/values/localazy.xml
+++ b/features/space/impl/src/main/res/values/localazy.xml
@@ -1,5 +1,6 @@
+ "Choose owners"
"%1$s (Admin)"
- "Leave %1$d room and space"
@@ -7,9 +8,11 @@
"Select the rooms you’d like to leave which you\'re not the only administrator for:"
"You need to assign another admin for this space before you can leave."
+ "You are the only owner of %1$s. You need to transfer ownership to someone else before you leave."
"You will not be removed from the following room(s) because you\'re the only administrator:"
"Leave %1$s?"
"You are the only admin for %1$s"
+ "Transfer ownership"
"Room"
"Adding a room will not affect the room access. To change the access go to Room settings > Security & privacy."
"Add your first room"
diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt
index 0c3ae1b888..c0ec58d267 100644
--- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt
+++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt
@@ -12,6 +12,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
+import io.element.android.features.changeroommemberroles.test.FakeChangeRoomMemberRolesEntryPoint
import io.element.android.features.createroom.api.FakeCreateRoomEntryPoint
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.features.space.impl.di.FakeSpaceFlowGraph
@@ -22,6 +23,7 @@ import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
+import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -33,7 +35,7 @@ class DefaultSpaceEntryPointTest {
val mainDispatcherRule = MainDispatcherRule()
@Test
- fun `test node builder`() {
+ fun `test node builder`() = runTest {
val entryPoint = DefaultSpaceEntryPoint()
val nodeInputs = SpaceEntryPoint.Inputs(A_ROOM_ID)
val parentNode = TestParentNode.create { buildContext, plugins ->
@@ -46,6 +48,8 @@ class DefaultSpaceEntryPointTest {
room = FakeJoinedRoom(),
graphFactory = FakeSpaceFlowGraph.Factory,
createRoomEntryPoint = FakeCreateRoomEntryPoint(),
+ changeRoomMemberRolesEntryPoint = FakeChangeRoomMemberRolesEntryPoint(),
+ sessionCoroutineScope = backgroundScope,
)
}
val callback = object : SpaceEntryPoint.Callback {
diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenterTest.kt
index b9a67704c9..cdfd4e548c 100644
--- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenterTest.kt
+++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpacePresenterTest.kt
@@ -17,14 +17,21 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.aRoomSummary
+import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
+import io.element.android.libraries.matrix.ui.components.aSelectRoomInfo
+import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
+import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
@@ -110,12 +117,15 @@ class AddRoomToSpacePresenterTest {
@Test
fun `present - searchResults shows Results when rooms available`() = runTest {
- val roomListService = FakeRoomListService()
+ val roomList = FakeDynamicRoomList()
+ val roomListService = FakeRoomListService(
+ createRoomListLambda = { roomList }
+ )
val presenter = createAddRoomToSpacePresenter(roomListService = roomListService)
presenter.test {
awaitItem() // Initial state
// Post rooms to the service
- roomListService.postAllRooms(
+ roomList.summaries.emit(
listOf(
aRoomSummary(
roomId = A_ROOM_ID,
@@ -273,9 +283,94 @@ class AddRoomToSpacePresenterTest {
}
}
+ @Test
+ fun `present - Dismiss without additions does not call reset`() = runTest {
+ val resetResult = lambdaRecorder>(ensureNeverCalled = true) { Result.success(Unit) }
+ val spaceRoomList = FakeSpaceRoomList(
+ paginateResult = { Result.success(Unit) },
+ resetResult = resetResult,
+ )
+ val presenter = createAddRoomToSpacePresenter(spaceRoomList = spaceRoomList)
+ presenter.test {
+ val state = awaitItem()
+ state.eventSink(AddRoomToSpaceEvent.Dismiss)
+ advanceUntilIdle()
+ // reset should NOT be called since no rooms were added
+ assert(resetResult).isNeverCalled()
+ }
+ }
+
+ @Test
+ fun `present - UpdateSearchVisibleRange triggers pagination when near end`() = runTest {
+ val loadMoreLambda = lambdaRecorder { }
+ val roomList = FakeDynamicRoomList(loadMoreLambda = loadMoreLambda)
+ val roomListService = FakeRoomListService(
+ createRoomListLambda = { roomList }
+ )
+ val presenter = createAddRoomToSpacePresenter(roomListService = roomListService)
+ presenter.test {
+ val state = awaitItem()
+ // Post rooms to simulate loaded content
+ roomList.summaries.emit(listOf(aRoomSummary()))
+ advanceUntilIdle()
+ skipItems(1)
+
+ // UpdateSearchVisibleRange should trigger loadMore
+ state.eventSink(AddRoomToSpaceEvent.UpdateSearchVisibleRange(IntRange(0, 9)))
+ advanceUntilIdle()
+
+ assert(loadMoreLambda).isCalledOnce()
+ }
+ }
+
+ @Test
+ fun `present - Dismiss after partial success calls reset`() = runTest {
+ val resetResult = lambdaRecorder> { Result.success(Unit) }
+ val spaceRoomList = FakeSpaceRoomList(
+ paginateResult = { Result.success(Unit) },
+ resetResult = resetResult,
+ )
+ // Room 1 succeeds, Room 2 fails
+ val addChildToSpaceResult = lambdaRecorder> { _, childId ->
+ if (childId == A_ROOM_ID_2) {
+ Result.failure(AN_EXCEPTION)
+ } else {
+ Result.success(Unit)
+ }
+ }
+ val spaceService = FakeSpaceService(
+ addChildToSpaceResult = addChildToSpaceResult,
+ )
+ val presenter = createAddRoomToSpacePresenter(
+ spaceRoomList = spaceRoomList,
+ spaceService = spaceService,
+ )
+ presenter.test {
+ val state = awaitItem()
+ // Select two rooms
+ val room1 = aSelectRoomInfoList()[0]
+ val room2 = aSelectRoomInfoList()[1]
+ state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room1))
+ awaitItem()
+ state.eventSink(AddRoomToSpaceEvent.ToggleRoom(room2))
+ awaitItem()
+ // Save - partial success (one room added, one failed)
+ state.eventSink(AddRoomToSpaceEvent.Save)
+ skipItems(1) // Loading
+ advanceUntilIdle()
+ val failureState = expectMostRecentItem()
+ assertThat(failureState.saveAction).isInstanceOf(AsyncAction.Failure::class.java)
+ // Dismiss after partial success - reset should be called
+ failureState.eventSink(AddRoomToSpaceEvent.Dismiss)
+ advanceUntilIdle()
+ assert(resetResult).isCalledOnce()
+ }
+ }
+
private fun TestScope.createAddRoomToSpacePresenter(
spaceRoomList: FakeSpaceRoomList = FakeSpaceRoomList(
paginateResult = { Result.success(Unit) },
+ resetResult = { Result.success(Unit) },
),
spaceService: FakeSpaceService = FakeSpaceService(
addChildToSpaceResult = { _, _ -> Result.success(Unit) },
@@ -301,3 +396,8 @@ class AddRoomToSpacePresenterTest {
)
}
}
+
+private fun aSelectRoomInfoList(): ImmutableList = listOf(
+ aSelectRoomInfo(roomId = A_ROOM_ID, name = "Room 1"),
+ aSelectRoomInfo(roomId = A_ROOM_ID_2, name = "Room 2"),
+).toImmutableList()
diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceViewTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceViewTest.kt
index d6a9ff770a..d75fecd05a 100644
--- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceViewTest.kt
+++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceViewTest.kt
@@ -14,6 +14,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
@@ -32,16 +33,19 @@ class AddRoomToSpaceViewTest {
@get:Rule val rule = createAndroidComposeRule()
@Test
- fun `clicking back when search inactive invokes onBackClick`() {
+ fun `clicking back when search inactive emits Dismiss and invokes onBackClick`() {
+ val eventsRecorder = EventsRecorder()
ensureCalledOnce {
rule.setAddRoomToSpaceView(
anAddRoomToSpaceState(
isSearchActive = false,
+ eventSink = eventsRecorder,
),
onBackClick = it,
)
rule.pressBack()
}
+ eventsRecorder.assertSingle(AddRoomToSpaceEvent.Dismiss)
}
@Test
@@ -96,6 +100,21 @@ class AddRoomToSpaceViewTest {
)
}
}
+
+ @Config(qualifiers = "h1024dp")
+ @Test
+ fun `displaying search results sends UpdateSearchVisibleRange event`() {
+ val eventsRecorder = EventsRecorder()
+ val rooms = aSelectRoomInfoList()
+ rule.setAddRoomToSpaceView(
+ anAddRoomToSpaceState(
+ isSearchActive = true,
+ searchResults = SearchBarResultState.Results(rooms),
+ eventSink = eventsRecorder,
+ ),
+ )
+ eventsRecorder.assertTrue(0) { it is AddRoomToSpaceEvent.UpdateSearchVisibleRange }
+ }
}
private fun AndroidComposeTestRule.setAddRoomToSpaceView(
diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenterTest.kt
index e78a338bb7..b3b6fc7976 100644
--- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenterTest.kt
+++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenterTest.kt
@@ -29,11 +29,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test
class LeaveSpacePresenterTest {
- private val aSpace = aSpaceRoom(
- roomId = A_SPACE_ID,
- displayName = A_SPACE_NAME,
- )
-
@Test
fun `present - initial state`() = runTest {
val presenter = createLeaveSpacePresenter(
@@ -44,7 +39,7 @@ class LeaveSpacePresenterTest {
presenter.test {
val state = awaitItem()
assertThat(state.spaceName).isNull()
- assertThat(state.isLastOwner).isFalse()
+ assertThat(state.needsOwnerChange).isFalse()
assertThat(state.selectableSpaceRooms.isLoading()).isTrue()
assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
cancelAndIgnoreRemainingEvents()
@@ -87,7 +82,7 @@ class LeaveSpacePresenterTest {
skipItems(2)
val finalState = awaitItem()
assertThat(finalState.spaceName).isEqualTo(A_SPACE_NAME)
- assertThat(finalState.isLastOwner).isTrue()
+ assertThat(finalState.needsOwnerChange).isTrue()
// The current state is not in the sub room list
assertThat(finalState.selectableSpaceRooms.dataOrNull()!!).isEmpty()
}
@@ -145,8 +140,8 @@ class LeaveSpacePresenterTest {
roomsResult = {
Result.success(
listOf(
- LeaveSpaceRoom(aSpaceRoom(roomId = A_ROOM_ID), isLastOwner = false),
- LeaveSpaceRoom(aSpaceRoom(roomId = A_ROOM_ID_2), isLastOwner = true),
+ LeaveSpaceRoom(aSpaceRoom(roomId = A_ROOM_ID), isLastOwner = false, areCreatorsPrivileged = false),
+ LeaveSpaceRoom(aSpaceRoom(roomId = A_ROOM_ID_2), isLastOwner = true, areCreatorsPrivileged = false),
)
)
},
@@ -157,7 +152,7 @@ class LeaveSpacePresenterTest {
skipItems(3)
val state = awaitItem()
assertThat(state.spaceName).isNull()
- assertThat(state.isLastOwner).isFalse()
+ assertThat(state.needsOwnerChange).isFalse()
val data = state.selectableSpaceRooms.dataOrNull()!!
assertThat(data.size).isEqualTo(2)
// Only one room is selectable as the user is the last admin in the other one
@@ -232,6 +227,20 @@ class LeaveSpacePresenterTest {
}
}
+ @Test
+ fun `present - needsOwnerChange is false if user is the last joined member`() = runTest {
+ val presenter = createLeaveSpacePresenter(
+ leaveSpaceHandle = FakeLeaveSpaceHandle(
+ roomsResult = { Result.success(listOf(aLeaveSpaceRoom(spaceRoom = aSpaceRoom(numJoinedMembers = 1), isLastOwner = true))) },
+ )
+ )
+ presenter.test {
+ skipItems(3)
+ val state = awaitItem()
+ assertThat(state.needsOwnerChange).isFalse()
+ }
+ }
+
private fun createLeaveSpacePresenter(
leaveSpaceHandle: LeaveSpaceHandle = FakeLeaveSpaceHandle(),
): LeaveSpacePresenter {
@@ -241,13 +250,18 @@ class LeaveSpacePresenterTest {
}
}
+private val aSpace = aSpaceRoom(
+ roomId = A_SPACE_ID,
+ displayName = A_SPACE_NAME,
+ numJoinedMembers = 2,
+)
+
private fun aLeaveSpaceRoom(
- spaceRoom: SpaceRoom = aSpaceRoom(
- roomId = A_SPACE_ID,
- displayName = A_SPACE_NAME,
- ),
+ spaceRoom: SpaceRoom = aSpace,
isLastOwner: Boolean = false,
+ areCreatorsPrivileged: Boolean = false,
) = LeaveSpaceRoom(
spaceRoom = spaceRoom,
isLastOwner = isLastOwner,
+ areCreatorsPrivileged = areCreatorsPrivileged,
)
diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt
index 811e9158b9..1d38e2e0f7 100644
--- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt
+++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt
@@ -42,13 +42,13 @@ import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
-import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import im.vector.app.features.analytics.plan.JoinedRoom as AnalyticsJoinedRoom
@@ -56,10 +56,9 @@ import im.vector.app.features.analytics.plan.JoinedRoom as AnalyticsJoinedRoom
class SpacePresenterTest {
@Test
fun `present - initial state`() = runTest {
- val paginateResult = lambdaRecorder> {
- Result.success(Unit)
- }
- val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult)
+ val spaceRoomList = FakeSpaceRoomList(
+ paginateResult = { Result.success(Unit) }
+ )
val presenter = createSpacePresenter(spaceRoomList = spaceRoomList)
presenter.test {
val state = awaitItem()
@@ -72,8 +71,6 @@ class SpacePresenterTest {
assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState())
assertThat(state.topicViewerState).isEqualTo(TopicViewerState.Hidden)
assertThat(state.canAccessSpaceSettings).isFalse()
- advanceUntilIdle()
- paginateResult.assertions().isCalledOnce()
}
}
@@ -105,19 +102,17 @@ class SpacePresenterTest {
}
@Test
- fun `present - load more`() = runTest {
- val paginateResult = lambdaRecorder> {
- Result.success(Unit)
- }
- val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult)
+ fun `present - load more does nothing`() = runTest {
+ // LoadMore event is a no-op as pagination is handled automatically for now as backend is slow.
+ val spaceRoomList = FakeSpaceRoomList(
+ paginateResult = { Result.success(Unit) }
+ )
val presenter = createSpacePresenter(spaceRoomList = spaceRoomList)
presenter.test {
val state = awaitItem()
- advanceUntilIdle()
- paginateResult.assertions().isCalledOnce()
+ // LoadMore event should not cause any state change
state.eventSink(SpaceEvents.LoadMore)
- advanceUntilIdle()
- paginateResult.assertions().isCalledExactly(2)
+ expectNoEvents()
}
}
@@ -196,7 +191,6 @@ class SpacePresenterTest {
assertThat(joiningState.joinActions[A_ROOM_ID_2]).isEqualTo(AsyncAction.Loading)
// Let the joinRoom call complete
advanceUntilIdle()
- runCurrent()
// The room is joined
fakeSpaceRoomList.emitSpaceRooms(
listOf(
@@ -211,7 +205,7 @@ class SpacePresenterTest {
val joinedState = awaitItem()
// Joined room is removed from the join actions
assertThat(joinedState.joinActions).doesNotContainKey(A_ROOM_ID_2)
- joinRoom.assertions().isCalledOnce().with(
+ assert(joinRoom).isCalledOnce().with(
value(A_ROOM_ID_2.toRoomIdOrAlias()),
value(serverNames),
value(AnalyticsJoinedRoom.Trigger.SpaceHierarchy),
@@ -354,16 +348,24 @@ class SpacePresenterTest {
}
@Test
- fun `present - exit manage mode clears selection`() = runTest {
- val presenter = createSpacePresenter()
+ fun `present - exit manage mode without removals does not call reset`() = runTest {
+ val resetResult = lambdaRecorder>(ensureNeverCalled = true) { Result.success(Unit) }
+ val fakeSpaceRoomList = FakeSpaceRoomList(
+ paginateResult = { Result.success(Unit) },
+ resetResult = resetResult,
+ )
+ val presenter = createSpacePresenter(spaceRoomList = fakeSpaceRoomList)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(SpaceEvents.EnterManageMode)
initialState.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID))
initialState.eventSink(SpaceEvents.ExitManageMode)
+ advanceUntilIdle()
val finalState = expectMostRecentItem()
assertThat(finalState.isManageMode).isFalse()
assertThat(finalState.selectedRoomIds).isEmpty()
+ // reset should NOT be called since no rooms were actually removed
+ assert(resetResult).isNeverCalled()
}
}
@@ -389,6 +391,7 @@ class SpacePresenterTest {
val removeChildFromSpaceResult = lambdaRecorder> { _, _ ->
Result.success(Unit)
}
+ val resetResult = lambdaRecorder> { Result.success(Unit) }
val aRoom = aSpaceRoom(
roomId = A_ROOM_ID,
roomType = RoomType.Room,
@@ -396,6 +399,7 @@ class SpacePresenterTest {
val fakeSpaceRoomList = FakeSpaceRoomList(
initialSpaceRoomsValue = listOf(aRoom),
paginateResult = { Result.success(Unit) },
+ resetResult = resetResult,
)
val presenter = createSpacePresenter(
spaceRoomList = fakeSpaceRoomList,
@@ -416,8 +420,8 @@ class SpacePresenterTest {
val successState = expectMostRecentItem()
assertThat(successState.removeRoomsAction).isEqualTo(AsyncAction.Success(Unit))
assertThat(successState.isManageMode).isFalse()
- assertThat(successState.children).isEmpty()
- removeChildFromSpaceResult.assertions().isCalledOnce()
+ assert(removeChildFromSpaceResult).isCalledOnce()
+ assert(resetResult).isCalledOnce()
}
}
@@ -465,7 +469,57 @@ class SpacePresenterTest {
assertThat(failureState.children.map { it.roomId }).doesNotContain(A_ROOM_ID)
// Failed room should still be present
assertThat(failureState.children.map { it.roomId }).contains(A_ROOM_ID_2)
- removeChildFromSpaceResult.assertions().isCalledExactly(2)
+ assert(removeChildFromSpaceResult).isCalledExactly(2)
+ }
+ }
+
+ @Test
+ fun `present - exit manage mode after partial failure calls reset`() = runTest {
+ val aRoom1 = aSpaceRoom(
+ roomId = A_ROOM_ID,
+ roomType = RoomType.Room,
+ )
+ val aRoom2 = aSpaceRoom(
+ roomId = A_ROOM_ID_2,
+ roomType = RoomType.Room,
+ )
+ // Room 1 succeeds, Room 2 fails
+ val removeChildFromSpaceResult = lambdaRecorder> { _, childId ->
+ if (childId == A_ROOM_ID_2) {
+ Result.failure(AN_EXCEPTION)
+ } else {
+ Result.success(Unit)
+ }
+ }
+ val resetResult = lambdaRecorder> { Result.success(Unit) }
+ val fakeSpaceRoomList = FakeSpaceRoomList(
+ initialSpaceRoomsValue = listOf(aRoom1, aRoom2),
+ paginateResult = { Result.success(Unit) },
+ resetResult = resetResult,
+ )
+ val presenter = createSpacePresenter(
+ spaceRoomList = fakeSpaceRoomList,
+ spaceService = FakeSpaceService(
+ removeChildFromSpaceResult = removeChildFromSpaceResult,
+ ),
+ )
+ presenter.test {
+ awaitItem() // Initial empty state
+ advanceUntilIdle()
+ val stateWithChildren = awaitItem()
+ stateWithChildren.eventSink(SpaceEvents.EnterManageMode)
+ stateWithChildren.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID))
+ stateWithChildren.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID_2))
+ stateWithChildren.eventSink(SpaceEvents.RemoveSelectedRooms)
+ stateWithChildren.eventSink(SpaceEvents.ConfirmRoomRemoval)
+ advanceUntilIdle()
+ val failureState = expectMostRecentItem()
+ assertThat(failureState.removeRoomsAction.isFailure()).isTrue()
+ // Exit manage mode after partial failure - reset should be called
+ failureState.eventSink(SpaceEvents.ExitManageMode)
+ advanceUntilIdle()
+ expectMostRecentItem()
+ assert(resetResult).isCalledOnce()
}
}
@@ -501,10 +555,8 @@ class SpacePresenterTest {
}
@Test
- fun `present - removed rooms persist after flow update`() = runTest {
- val removeChildFromSpaceResult = lambdaRecorder> { _, _ ->
- Result.success(Unit)
- }
+ fun `present - removed rooms persist after flow update on partial failure`() = runTest {
+ // On partial failure, successfully removed rooms should stay filtered even after flow updates
val aRoom1 = aSpaceRoom(
roomId = A_ROOM_ID,
roomType = RoomType.Room,
@@ -517,6 +569,14 @@ class SpacePresenterTest {
roomId = A_ROOM_ID_3,
roomType = RoomType.Room,
)
+ // Room 1 succeeds, Room 2 fails
+ val removeChildFromSpaceResult = lambdaRecorder> { _, childId ->
+ if (childId == A_ROOM_ID_2) {
+ Result.failure(AN_EXCEPTION)
+ } else {
+ Result.success(Unit)
+ }
+ }
val spaceRoomList = FakeSpaceRoomList(
initialSpaceRoomsValue = listOf(aRoom1, aRoom2),
paginateResult = { Result.success(Unit) },
@@ -532,12 +592,18 @@ class SpacePresenterTest {
advanceUntilIdle()
val stateWithChildren = awaitItem()
stateWithChildren.eventSink(SpaceEvents.EnterManageMode)
+ // Select both rooms for removal
stateWithChildren.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID))
+ stateWithChildren.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID_2))
stateWithChildren.eventSink(SpaceEvents.RemoveSelectedRooms)
stateWithChildren.eventSink(SpaceEvents.ConfirmRoomRemoval)
advanceUntilIdle()
- val successState = expectMostRecentItem()
- assertThat(successState.children.map { it.roomId }).doesNotContain(A_ROOM_ID)
+ val failureState = expectMostRecentItem()
+ assertThat(failureState.removeRoomsAction.isFailure()).isTrue()
+ // Successfully removed room should be filtered out
+ assertThat(failureState.children.map { it.roomId }).doesNotContain(A_ROOM_ID)
+ // Failed room should still be present
+ assertThat(failureState.children.map { it.roomId }).contains(A_ROOM_ID_2)
// Emit new flow update with a new room added (simulating server refresh)
spaceRoomList.emitSpaceRooms(listOf(aRoom1, aRoom2, aRoom3))
advanceUntilIdle()
@@ -571,7 +637,7 @@ class SpacePresenterTest {
seenInvitesStore = seenInvitesStore,
joinRoom = joinRoom,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
- sessionCoroutineScope = backgroundScope,
+ sessionCoroutineScope = this,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.SpaceSettings.key to spaceSettingsEnabled,
diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt
index ec4d681a40..87343b6e34 100644
--- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt
+++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt
@@ -15,7 +15,6 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
-import io.element.android.features.space.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
@@ -215,9 +214,25 @@ class SpaceViewTest {
),
onCreateRoomClick = onCreateRoomClick,
)
- rule.clickOn(R.string.screen_space_add_room_action)
+ rule.clickOn(CommonStrings.action_create_room)
onCreateRoomClick.assertions().isCalledOnce()
}
+
+ @Test
+ fun `clicking add existing room button calls the expected callback`() {
+ val onAddRoomClick = lambdaRecorder { }
+ rule.setSpaceView(
+ aSpaceState(
+ children = emptyList(),
+ hasMoreToLoad = false,
+ isManageMode = true,
+ canManageRooms = true,
+ ),
+ onAddRoomClick = onAddRoomClick,
+ )
+ rule.clickOn(CommonStrings.action_add_existing_rooms)
+ onAddRoomClick.assertions().isCalledOnce()
+ }
}
private fun AndroidComposeTestRule.setSpaceView(
diff --git a/features/verifysession/impl/src/main/res/values-sv/translations.xml b/features/verifysession/impl/src/main/res/values-sv/translations.xml
index 53b26d75de..100f4964da 100644
--- a/features/verifysession/impl/src/main/res/values-sv/translations.xml
+++ b/features/verifysession/impl/src/main/res/values-sv/translations.xml
@@ -16,7 +16,7 @@
"Bekräfta att emojierna nedan matchar de som visas på den andra användarens enhet."
"Bekräfta att siffrorna nedan matchar de som visas på din andra session."
"Jämför siffror"
- "Din nya session är nu verifierad. Den har tillgång till dina krypterade meddelanden, och andra användare kommer att se den som betrodd."
+ "Du kan nu läsa eller skicka meddelanden säkert på din andra enhet."
"Nu kan du lita på användarens identitet när du skickar eller tar emot meddelanden."
"Enhet verifierad"
"Ange återställningsnyckel"
@@ -33,7 +33,7 @@
"Verifiering misslyckades"
"Fortsätt bara om du initierade denna verifiering."
"Verifiera den andra enheten för att hålla din meddelandehistorik säker."
- "Din nya session är nu verifierad. Den har tillgång till dina krypterade meddelanden, och andra användare kommer att se den som betrodd."
+ "Du kan nu läsa eller skicka meddelanden säkert på din andra enhet."
"Enhet verifierad"
"Verifiering begärd"
"De matchar inte"
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 24efae8251..109a923109 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -5,10 +5,10 @@
# Project
android_gradle_plugin = "8.13.2"
# When updateing this, please also update the version in the file ./idea/kotlinc.xml
-kotlin = "2.3.0"
+kotlin = "2.3.10"
kotlinpoet = "2.2.0"
-ksp = "2.3.4"
-firebaseAppDistribution = "5.2.0"
+ksp = "2.3.5"
+firebaseAppDistribution = "5.2.1"
# AndroidX
core = "1.17.0"
@@ -16,10 +16,10 @@ datastore = "1.2.0"
constraintlayout = "2.2.1"
constraintlayout_compose = "1.1.1"
lifecycle = "2.10.0"
-activity = "1.12.2"
-media3 = "1.9.1"
-camera = "1.5.2"
-work = "2.11.0"
+activity = "1.12.3"
+media3 = "1.9.2"
+camera = "1.5.3"
+work = "2.11.1"
# Compose
compose_bom = "2026.01.00"
@@ -32,7 +32,7 @@ accompanist = "0.37.3"
# Test
test_core = "1.7.0"
-roborazzi = "1.57.0"
+roborazzi = "1.58.0"
# Jetbrain
datetime = "0.7.1"
@@ -46,13 +46,13 @@ appyx = "1.7.1"
sqldelight = "2.2.1"
wysiwyg = "2.41.1"
telephoto = "0.18.0"
-haze = "1.7.1"
+haze = "1.7.2"
# Dependency analysis
dependencyAnalysis = "3.5.1"
# DI
-metro = "0.10.1"
+metro = "0.10.3"
# Auto service
autoservice = "1.1.1"
@@ -62,7 +62,7 @@ detekt = "1.23.8"
# See https://github.com/pinterest/ktlint/releases/
ktlint = "1.8.0"
androidx-test-ext-junit = "1.3.0"
-kover = "0.9.4"
+kover = "0.9.7"
[libraries]
# Project
@@ -78,7 +78,7 @@ kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlin
kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" }
ksp_gradle_plugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" }
# https://firebase.google.com/docs/android/setup#available-libraries
-google_firebase_bom = "com.google.firebase:firebase-bom:34.8.0"
+google_firebase_bom = "com.google.firebase:firebase-bom:34.9.0"
firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" }
autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" }
ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
@@ -177,7 +177,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version
# https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt
# All new features should not be implemented in the pull request that upgrades the version, developers should
# only fix API breaks and may add some TODOs.
-matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.1.27"
+matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.2.10"
# Others
coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
@@ -218,8 +218,8 @@ haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref =
color_picker = "io.mhssn:colorpicker:1.0.0"
# Analytics
-posthog = "com.posthog:posthog-android:3.29.1"
-sentry = "io.sentry:sentry-android:8.31.0"
+posthog = "com.posthog:posthog-android:3.31.0"
+sentry = "io.sentry:sentry-android:8.32.0"
# main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.29.2"
@@ -261,7 +261,7 @@ ktlint = "org.jlleitschuh.gradle.ktlint:14.0.1"
dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12"
dependencycheck = "org.owasp.dependencycheck:12.2.0"
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" }
-paparazzi = "app.cash.paparazzi:2.0.0-alpha02"
+paparazzi = "app.cash.paparazzi:2.0.0-alpha04"
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" }
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt
index b95dacc5a8..5115605322 100644
--- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt
@@ -97,7 +97,7 @@ object LinkifyHelper {
val closingParenthesisCount = linkifiedTextLastPath.count { it == ')' }
val openingParenthesisCount = linkifiedTextLastPath.count { it == '(' }
// If it's not part of a pair, remove it from the link span by adjusting the end index
- end -= closingParenthesisCount - openingParenthesisCount
+ end -= (closingParenthesisCount - openingParenthesisCount).coerceAtLeast(0)
}
return end
}
diff --git a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt
index 4722994cc8..6fa18be1ac 100644
--- a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt
+++ b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt
@@ -122,4 +122,22 @@ class LinkifierHelperTest {
assertThat(urlSpans.size).isEqualTo(1)
assertThat(urlSpans.first().url).isEqualTo("https://github.com/element-hq/element-android/READ(ME)")
}
+
+ @Test
+ fun `linkification handles mismatched opening parenthesis in URL`() {
+ val text = "A url: (https://github.com/element-hq/element-android/READ((((((ME))"
+ val result = LinkifyHelper.linkify(text)
+ val urlSpans = result.toSpannable().getSpans()
+ assertThat(urlSpans.size).isEqualTo(1)
+ assertThat(urlSpans.first().url).isEqualTo("https://github.com/element-hq/element-android/READ((((((ME))")
+ }
+
+ @Test
+ fun `linkification handles mismatched closing parenthesis in URL`() {
+ val text = "A url: (https://github.com/element-hq/element-android/READ(ME)))))"
+ val result = LinkifyHelper.linkify(text)
+ val urlSpans = result.toSpannable().getSpans()
+ assertThat(urlSpans.size).isEqualTo(1)
+ assertThat(urlSpans.first().url).isEqualTo("https://github.com/element-hq/element-android/READ(ME)")
+ }
}
diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt
index d13777842d..fe563138ad 100644
--- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt
+++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt
@@ -8,7 +8,6 @@
package io.element.android.libraries.core.extensions
-import java.text.Normalizer
import java.util.Locale
fun Boolean.toOnOff() = if (this) "ON" else "OFF"
@@ -86,11 +85,6 @@ fun String.safeCapitalize(): String {
}
}
-fun String.withoutAccents(): String {
- return Normalizer.normalize(this, Normalizer.Form.NFD)
- .replace("\\p{Mn}+".toRegex(), "")
-}
-
private const val RTL_OVERRIDE_CHAR = '\u202E'
private const val LTR_OVERRIDE_CHAR = '\u202D'
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/MatrixBadgeAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/MatrixBadgeAtom.kt
index c96fb630e9..112de47bb2 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/MatrixBadgeAtom.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/MatrixBadgeAtom.kt
@@ -98,6 +98,18 @@ internal fun MatrixBadgeAtomNegativePreview() = ElementPreview {
)
}
+@PreviewsDayNight
+@Composable
+internal fun MatrixBadgeAtomNeutralWrappingPreview() = ElementPreview {
+ MatrixBadgeAtom.View(
+ MatrixBadgeAtom.MatrixBadgeData(
+ text = "How much wood could a wood chuck chuck if a wood chuck could chuck wood",
+ icon = CompoundIcons.LockOff(),
+ type = MatrixBadgeAtom.Type.Info,
+ )
+ )
+}
+
@PreviewsDayNight
@Composable
internal fun MatrixBadgeAtomInfoPreview() = ElementPreview {
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewAliasAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewAliasAtom.kt
new file mode 100644
index 0000000000..ad70fc3ba8
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewAliasAtom.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ * Copyright 2024, 2025 New Vector 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.atomic.atoms
+
+import android.content.ClipData
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.Clipboard
+import androidx.compose.ui.platform.LocalClipboard
+import androidx.compose.ui.platform.toClipEntry
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.text.toDp
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.coroutines.launch
+
+@Composable
+fun RoomPreviewAliasAtom(
+ alias: String,
+ modifier: Modifier = Modifier,
+ copiable: Boolean = true
+) {
+ val clipboard: Clipboard = LocalClipboard.current
+ val coroutineScope = rememberCoroutineScope()
+ Row(
+ modifier = modifier
+ .clickable(enabled = copiable) {
+ coroutineScope.launch {
+ val clipData = ClipData.newPlainText(alias, alias)
+ clipboard.setClipEntry(clipData.toClipEntry())
+ }
+ },
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ modifier = Modifier.weight(weight = 1f, fill = false),
+ text = alias,
+ style = ElementTheme.typography.fontBodyLgRegular,
+ textAlign = TextAlign.Center,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = ElementTheme.colors.textSecondary,
+ )
+ if (copiable) {
+ Icon(
+ imageVector = CompoundIcons.Copy(),
+ contentDescription = stringResource(id = CommonStrings.action_copy),
+ tint = ElementTheme.colors.iconSecondaryAlpha,
+ modifier = Modifier.size(ElementTheme.typography.fontBodyLgRegular.fontSize.toDp())
+ )
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun RoomPreviewAliasAtomPreview() = ElementPreview {
+ RoomPreviewAliasAtom(
+ alias = "#room-alias:matrix.org",
+ copiable = true
+ )
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewSubtitleAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewSubtitleAtom.kt
deleted file mode 100644
index b076bb0e24..0000000000
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewSubtitleAtom.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2024, 2025 New Vector 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.atomic.atoms
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.style.TextAlign
-import io.element.android.compound.theme.ElementTheme
-import io.element.android.libraries.designsystem.theme.components.Text
-
-@Composable
-fun RoomPreviewSubtitleAtom(subtitle: String, modifier: Modifier = Modifier) {
- Text(
- modifier = modifier,
- text = subtitle,
- style = ElementTheme.typography.fontBodyLgRegular,
- textAlign = TextAlign.Center,
- color = ElementTheme.colors.textSecondary,
- )
-}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/MatrixBadgeRowMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/MatrixBadgeRowMolecule.kt
index 48f8d798fe..16f4a40993 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/MatrixBadgeRowMolecule.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/MatrixBadgeRowMolecule.kt
@@ -9,9 +9,10 @@
package io.element.android.libraries.designsystem.atomic.molecules
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.atomic.atoms.MatrixBadgeAtom
@@ -22,10 +23,11 @@ fun MatrixBadgeRowMolecule(
data: ImmutableList,
modifier: Modifier = Modifier,
) {
- Row(
+ FlowRow(
modifier = modifier
.padding(start = 16.dp, end = 16.dp, top = 8.dp),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
) {
for (badge in data) {
MatrixBadgeAtom.View(badge)
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/RoomPreviewOrganism.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/RoomPreviewOrganism.kt
index c8b064a18b..f8f46f91b3 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/RoomPreviewOrganism.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/RoomPreviewOrganism.kt
@@ -9,6 +9,7 @@
package io.element.android.libraries.designsystem.atomic.organisms
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -19,12 +20,12 @@ import androidx.compose.ui.unit.dp
@Composable
fun RoomPreviewOrganism(
- avatar: @Composable () -> Unit,
- title: @Composable () -> Unit,
- subtitle: @Composable () -> Unit,
+ avatar: @Composable ColumnScope.() -> Unit,
+ title: @Composable ColumnScope.() -> Unit,
+ subtitle: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
- description: @Composable (() -> Unit)? = null,
- memberCount: @Composable (() -> Unit)? = null,
+ description: @Composable (ColumnScope.() -> Unit)? = null,
+ memberCount: @Composable (ColumnScope.() -> Unit)? = null,
) {
Column(
modifier = modifier.fillMaxWidth(),
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Badge.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Badge.kt
index 315e75e655..b5adf3a0b4 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Badge.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Badge.kt
@@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
@@ -63,6 +64,8 @@ fun Badge(
text = text,
style = ElementTheme.typography.fontBodySmRegular,
color = textColor,
+ overflow = TextOverflow.Ellipsis,
+ softWrap = false,
)
}
}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/SpaceAvatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/SpaceAvatar.kt
index c301a490eb..1dfc9f3169 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/SpaceAvatar.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/SpaceAvatar.kt
@@ -8,6 +8,7 @@
package io.element.android.libraries.designsystem.components.avatar.internal
+import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
@@ -16,6 +17,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
@@ -34,19 +36,26 @@ internal fun SpaceAvatar(
contentDescription: String? = null,
) {
val size = forcedAvatarSize ?: avatarData.size.dp
+ val avatarShape = avatarType.avatarShape(size)
+ val commonModifier = modifier
+ .border(
+ width = 1.dp,
+ color = ElementTheme.colors.iconQuaternaryAlpha,
+ shape = avatarShape,
+ )
when {
avatarType.isTombstoned -> TombstonedRoomAvatar(
size = size,
- avatarShape = avatarType.avatarShape(size),
- modifier = modifier,
+ avatarShape = avatarShape,
+ modifier = commonModifier,
contentDescription = contentDescription,
)
else -> InitialOrImageAvatar(
avatarData = avatarData,
hideAvatarImage = hideAvatarImage,
- avatarShape = avatarType.avatarShape(size),
+ avatarShape = avatarShape,
forcedAvatarSize = forcedAvatarSize,
- modifier = modifier,
+ modifier = commonModifier,
contentDescription = contentDescription,
)
}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt
index 38eada2370..e284c9a08d 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt
@@ -27,12 +27,15 @@ import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
+/**
+ * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2027-2043
+ */
@Composable
fun FloatingActionButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
shape: Shape = FloatingActionButtonDefaults.shape,
- containerColor: Color = ElementTheme.colors.textActionAccent,
+ containerColor: Color = ElementTheme.colors.bgAccentRest,
contentColor: Color = ElementTheme.colors.iconOnSolidPrimary,
elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/OnVisibleRangeChangeEffect.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/OnVisibleRangeChangeEffect.kt
new file mode 100644
index 0000000000..01a3be1711
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/OnVisibleRangeChangeEffect.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.foundation.lazy.LazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.snapshotFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+
+@Composable
+fun OnVisibleRangeChangeEffect(lazyListState: LazyListState, onChange: (IntRange) -> Unit) {
+ val onChangeUpdated by rememberUpdatedState(onChange)
+ LaunchedEffect(lazyListState) {
+ snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo }
+ .map { visibleItemsInfo ->
+ val firstItemIndex = visibleItemsInfo.firstOrNull()?.index ?: 0
+ val size = visibleItemsInfo.size
+ firstItemIndex until firstItemIndex + size
+ }
+ .distinctUntilChanged()
+ .collectLatest { visibleRange ->
+ onChangeUpdated(visibleRange)
+ }
+ }
+}
diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
index 4422330924..3b47726b80 100644
--- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
+++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
@@ -74,14 +74,21 @@ enum class FeatureFlags(
key = "feature.createSpaces",
title = "Create spaces",
description = "Allow creating spaces.",
- defaultValue = { false },
+ defaultValue = { true },
isFinished = false,
),
SpaceSettings(
key = "feature.spaceSettings",
title = "Space settings",
description = "Allow managing space settings such as details, permissions and privacy.",
- defaultValue = { false },
+ defaultValue = { true },
+ isFinished = false,
+ ),
+ RoomListSpaceFilters(
+ key = "feature.roomListSpaceFilters",
+ title = "Room list space filters",
+ description = "Allow filtering the room list by space.",
+ defaultValue = { true },
isFinished = false,
),
PrintLogsToLogcat(
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrLoginException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrLoginException.kt
index c81437eff0..5445c7e7fe 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrLoginException.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrLoginException.kt
@@ -13,6 +13,7 @@ sealed class QrLoginException : Exception() {
data object ConnectionInsecure : QrLoginException()
data object Declined : QrLoginException()
data object Expired : QrLoginException()
+ data object NotFound : QrLoginException()
data object LinkingNotSupported : QrLoginException()
data object OidcMetadataInvalid : QrLoginException()
data object SlidingSyncNotAvailable : QrLoginException()
@@ -20,5 +21,4 @@ sealed class QrLoginException : Exception() {
data object CheckCodeAlreadySent : QrLoginException()
data object CheckCodeCannotBeSent : QrLoginException()
data object Unknown : QrLoginException()
- data object NotFound : QrLoginException()
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewValue.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewValue.kt
index b77b01b692..9938745833 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewValue.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewValue.kt
@@ -37,7 +37,6 @@ fun MediaPreviewValue?.isPreviewEnabled(joinRule: JoinRule?): Boolean {
null, On -> true
Off -> false
Private -> when (joinRule) {
- is JoinRule.Private,
is JoinRule.Knock,
is JoinRule.Invite,
is JoinRule.Restricted,
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/history/RoomHistoryVisibility.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/history/RoomHistoryVisibility.kt
index 9f3826fe06..4d5c0e9dc9 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/history/RoomHistoryVisibility.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/history/RoomHistoryVisibility.kt
@@ -8,6 +8,9 @@
package io.element.android.libraries.matrix.api.room.history
+import androidx.compose.runtime.Immutable
+
+@Immutable
sealed interface RoomHistoryVisibility {
/**
* Previous events are accessible to newly joined members from the point
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRule.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRule.kt
index dad2492233..167ca25280 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRule.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRule.kt
@@ -14,7 +14,6 @@ import kotlinx.collections.immutable.ImmutableList
@Immutable
sealed interface JoinRule {
data object Public : JoinRule
- data object Private : JoinRule
data object Knock : JoinRule
data object Invite : JoinRule
data class Restricted(val rules: ImmutableList) : JoinRule
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/DynamicRoomList.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/DynamicRoomList.kt
index acba1e3cf6..952cee6f22 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/DynamicRoomList.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/DynamicRoomList.kt
@@ -8,25 +8,14 @@
package io.element.android.libraries.matrix.api.roomlist
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.SharedFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-
/**
* RoomList with dynamic filtering and loading.
* This is useful for large lists of rooms.
* It lets load rooms on demand and filter them.
*/
interface DynamicRoomList : RoomList {
- val currentFilter: StateFlow
- val loadedPages: StateFlow
val pageSize: Int
- val filteredSummaries: SharedFlow>
-
/**
* Load more rooms into the list if possible.
*/
@@ -44,28 +33,13 @@ interface DynamicRoomList : RoomList {
suspend fun updateFilter(filter: RoomListFilter)
}
-/**
- * Offers a way to load all the rooms incrementally.
- * It will load more room until all are loaded.
- * If total number of rooms increase, it will load more pages if needed.
- * The number of rooms is independent of the filter.
- */
-fun DynamicRoomList.loadAllIncrementally(coroutineScope: CoroutineScope) {
- combine(
- loadedPages,
- loadingState,
- ) { loadedPages, loadingState ->
- loadedPages to loadingState
+suspend fun DynamicRoomList.updateVisibleRange(
+ visibleRange: IntRange,
+ paginationThreshold: Int = pageSize * 3
+) {
+ val loadedCount = summaries.replayCache.firstOrNull().orEmpty().count()
+ val threshold = loadedCount - paginationThreshold
+ if (visibleRange.last >= threshold) {
+ loadMore()
}
- .onEach { (loadedPages, loadingState) ->
- when (loadingState) {
- is RoomList.LoadingState.Loaded -> {
- if (pageSize * loadedPages < loadingState.numberOfRooms) {
- loadMore()
- }
- }
- RoomList.LoadingState.NotLoaded -> Unit
- }
- }
- .launchIn(coroutineScope)
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/LatestEventValue.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/LatestEventValue.kt
index 5482a67875..b10dab9e4b 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/LatestEventValue.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/LatestEventValue.kt
@@ -28,4 +28,10 @@ sealed interface LatestEventValue {
val senderProfile: ProfileDetails,
val isSending: Boolean,
) : LatestEventValue
+
+ data class RoomInvite(
+ val timestamp: Long,
+ val inviterId: UserId?,
+ val invitedProfile: ProfileDetails,
+ ) : LatestEventValue
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListFilter.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListFilter.kt
index 33d233d589..11eed2128b 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListFilter.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListFilter.kt
@@ -8,7 +8,7 @@
package io.element.android.libraries.matrix.api.roomlist
-import io.element.android.libraries.core.extensions.withoutAccents
+import io.element.android.libraries.matrix.api.core.RoomId
sealed interface RoomListFilter {
companion object {
@@ -43,6 +43,10 @@ sealed interface RoomListFilter {
val filters: List
) : RoomListFilter
+ data class Identifiers(
+ val values: List,
+ ) : RoomListFilter
+
/**
* A filter that matches rooms that are unread.
*/
@@ -77,7 +81,5 @@ sealed interface RoomListFilter {
*/
data class NormalizedMatchRoomName(
val pattern: String
- ) : RoomListFilter {
- val normalizedPattern: String = pattern.withoutAccents()
- }
+ ) : RoomListFilter
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt
index c0cf7d57da..8acfcccdd8 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt
@@ -38,13 +38,11 @@ interface RoomListService {
/**
* Creates a room list that can be used to load more rooms and filter them dynamically.
* @param pageSize the number of rooms to load at once.
- * @param initialFilter the initial filter to apply to the rooms.
* @param source the source of the rooms, either all rooms or invites.
* @param coroutineScope the coroutine scope to use for the room list operations.
*/
fun createRoomList(
pageSize: Int,
- initialFilter: RoomListFilter,
source: RoomList.Source,
coroutineScope: CoroutineScope,
): DynamicRoomList
@@ -56,10 +54,10 @@ interface RoomListService {
suspend fun subscribeToVisibleRooms(roomIds: List)
/**
- * Returns a [DynamicRoomList] object of all rooms we want to display.
+ * Returns a [RoomList] object with all rooms locally known.
* If you want to get a filtered room list, consider using [createRoomList].
*/
- val allRooms: DynamicRoomList
+ val allRooms: RoomList
/**
* The sync indicator as a flow.
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt
index 89a4acfec1..aca093eab6 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt
@@ -19,6 +19,7 @@ data class RoomSummary(
is LatestEventValue.None -> null
is LatestEventValue.Local -> latestEvent.timestamp
is LatestEventValue.Remote -> latestEvent.timestamp
+ is LatestEventValue.RoomInvite -> latestEvent.timestamp
}
val isOneToOne get() = info.activeMembersCount == 2L
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/LeaveSpaceRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/LeaveSpaceRoom.kt
index 2b1389ea6e..dc098dc20d 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/LeaveSpaceRoom.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/LeaveSpaceRoom.kt
@@ -11,4 +11,5 @@ package io.element.android.libraries.matrix.api.spaces
data class LeaveSpaceRoom(
val spaceRoom: SpaceRoom,
val isLastOwner: Boolean,
+ val areCreatorsPrivileged: Boolean,
)
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt
index e2528bf117..2ccee8d1ee 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt
@@ -9,8 +9,11 @@
package io.element.android.libraries.matrix.api.spaces
import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
import java.util.Optional
interface SpaceRoomList {
@@ -19,13 +22,31 @@ interface SpaceRoomList {
data class Idle(val hasMoreToLoad: Boolean) : PaginationStatus
}
- val roomId: RoomId
+ val spaceId: RoomId
val currentSpaceFlow: StateFlow>
val spaceRoomsFlow: Flow>
val paginationStatusFlow: StateFlow
+
suspend fun paginate(): Result
+ suspend fun reset(): Result
fun destroy()
}
+
+/**
+ * Loads all space rooms incrementally by automatically paginating whenever more data is available.
+ * This function observes the pagination status and triggers [paginate] calls until the entire list is loaded.
+ *
+ * @param coroutineScope The scope in which the pagination flow will be collected.
+ */
+fun SpaceRoomList.loadAllIncrementally(coroutineScope: CoroutineScope) {
+ paginationStatusFlow
+ .onEach { paginationStatus ->
+ if (paginationStatus is SpaceRoomList.PaginationStatus.Idle && paginationStatus.hasMoreToLoad) {
+ paginate()
+ }
+ }
+ .launchIn(coroutineScope)
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomVisibility.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomVisibility.kt
index 47a74461db..e3d8e58b3d 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomVisibility.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomVisibility.kt
@@ -14,12 +14,12 @@ import io.element.android.libraries.matrix.api.room.join.JoinRule
sealed interface SpaceRoomVisibility {
data object Private : SpaceRoomVisibility
data object Public : SpaceRoomVisibility
- data object Restricted : SpaceRoomVisibility
+ data object SpaceMembers : SpaceRoomVisibility
companion object {
fun fromJoinRule(joinRule: JoinRule?): SpaceRoomVisibility = when (joinRule) {
JoinRule.Public -> Public
- is JoinRule.Restricted, is JoinRule.KnockRestricted -> Restricted
+ is JoinRule.Restricted, is JoinRule.KnockRestricted -> SpaceMembers
// Else fallback to Private
else -> Private
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt
index 1122415d58..299209e188 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt
@@ -12,9 +12,8 @@ import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.SharedFlow
interface SpaceService {
- val spaceRoomsFlow: SharedFlow>
- suspend fun joinedSpaces(): Result>
-
+ val topLevelSpacesFlow: SharedFlow>
+ val spaceFiltersFlow: SharedFlow>
suspend fun joinedParents(spaceId: RoomId): Result>
suspend fun getSpaceRoom(spaceId: RoomId): SpaceRoom?
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceServiceFilter.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceServiceFilter.kt
new file mode 100644
index 0000000000..e599353876
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceServiceFilter.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ * Copyright 2025 New Vector 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.spaces
+
+import io.element.android.libraries.matrix.api.core.RoomId
+
+/**
+ * Represents a space filter for filtering rooms by space membership.
+ *
+ * @property spaceRoom The space room associated with this filter.
+ * @property level The nesting level of the space (0 = top level, 1 = first level child, etc.).
+ * @property descendants The list of room IDs that are descendants of this space.
+ */
+data class SpaceServiceFilter(
+ val spaceRoom: SpaceRoom,
+ val level: Int,
+ val descendants: List,
+)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt
index ee9f1d3eac..690995ba77 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt
@@ -10,18 +10,23 @@ package io.element.android.libraries.matrix.impl
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.matrix.impl.core.SdkBackgroundTaskError
import io.element.android.libraries.matrix.impl.mapper.toSessionData
import io.element.android.libraries.matrix.impl.paths.getSessionPaths
import io.element.android.libraries.matrix.impl.util.anonymizedTokens
import io.element.android.libraries.sessionstorage.api.SessionStore
+import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.matrix.rustcomponents.sdk.ClientDelegate
import org.matrix.rustcomponents.sdk.ClientSessionDelegate
import org.matrix.rustcomponents.sdk.Session
import timber.log.Timber
+import uniffi.matrix_sdk_common.BackgroundTaskFailureReason
import java.lang.ref.WeakReference
import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.time.Duration.Companion.milliseconds
private val loggerTag = LoggerTag("RustClientSessionDelegate")
@@ -35,6 +40,7 @@ private val loggerTag = LoggerTag("RustClientSessionDelegate")
class RustClientSessionDelegate(
private val sessionStore: SessionStore,
private val appCoroutineScope: CoroutineScope,
+ private val analyticsService: AnalyticsService,
coroutineDispatchers: CoroutineDispatchers,
) : ClientSessionDelegate, ClientDelegate {
// Used to ensure several calls to `didReceiveAuthError` don't trigger multiple logouts
@@ -120,6 +126,21 @@ class RustClientSessionDelegate(
}
}
+ override fun onBackgroundTaskErrorReport(taskName: String, error: BackgroundTaskFailureReason) {
+ val backgroundTaskError = SdkBackgroundTaskError(taskName, error)
+ Timber.e(backgroundTaskError, "SDK background task failed")
+ analyticsService.trackError(backgroundTaskError)
+
+ if (error is BackgroundTaskFailureReason.Panic) {
+ appCoroutineScope.launch {
+ // The SDK failed in an unrecoverable way, so it will have indeterminate behaviour now.
+ // Crash the app instead after a small delay to send the error.
+ delay(500.milliseconds)
+ throw backgroundTaskError
+ }
+ }
+ }
+
override fun retrieveSessionFromKeychain(userId: String): Session {
// This should never be called, as it's only used for multi-process setups
error("retrieveSessionFromKeychain should never be called for Android")
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
index 065b0a506f..124db3dd1f 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
@@ -39,6 +39,7 @@ import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
+import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.roomlist.RoomListService
@@ -65,7 +66,7 @@ import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
import io.element.android.libraries.matrix.impl.room.RoomInfoMapper
import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber
import io.element.android.libraries.matrix.impl.room.RustRoomFactory
-import io.element.android.libraries.matrix.impl.room.TimelineEventTypeFilterFactory
+import io.element.android.libraries.matrix.impl.room.TimelineEventFilterFactory
import io.element.android.libraries.matrix.impl.room.history.map
import io.element.android.libraries.matrix.impl.room.join.map
import io.element.android.libraries.matrix.impl.room.preview.RoomPreviewInfoMapper
@@ -140,7 +141,7 @@ class RustMatrixClient(
dispatchers: CoroutineDispatchers,
baseCacheDirectory: File,
clock: SystemClock,
- timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory,
+ timelineEventFilterFactory: TimelineEventFilterFactory,
private val featureFlagService: FeatureFlagService,
private val analyticsService: AnalyticsService,
private val workManagerScheduler: WorkManagerScheduler,
@@ -224,7 +225,7 @@ class RustMatrixClient(
systemClock = clock,
roomContentForwarder = RoomContentForwarder(innerRoomListService),
roomSyncSubscriber = roomSyncSubscriber,
- timelineEventTypeFilterFactory = timelineEventTypeFilterFactory,
+ timelineEventFilterFactory = timelineEventFilterFactory,
roomMembershipObserver = roomMembershipObserver,
roomInfoMapper = roomInfoMapper,
featureFlagService = featureFlagService,
@@ -416,6 +417,7 @@ class RustMatrixClient(
isDirect = true,
visibility = RoomVisibility.Private,
preset = RoomPreset.TRUSTED_PRIVATE_CHAT,
+ historyVisibilityOverride = RoomHistoryVisibility.Invited,
invite = listOf(userId),
)
return createRoom(createRoomParams)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
index 5932acec20..85516ab762 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
@@ -21,7 +21,7 @@ import io.element.android.libraries.matrix.impl.certificates.UserCertificatesPro
import io.element.android.libraries.matrix.impl.paths.SessionPaths
import io.element.android.libraries.matrix.impl.paths.getSessionPaths
import io.element.android.libraries.matrix.impl.proxy.ProxyProvider
-import io.element.android.libraries.matrix.impl.room.TimelineEventTypeFilterFactory
+import io.element.android.libraries.matrix.impl.room.TimelineEventFilterFactory
import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider
import io.element.android.libraries.matrix.impl.util.anonymizedTokens
import io.element.android.libraries.network.useragent.UserAgentProvider
@@ -61,12 +61,17 @@ class RustMatrixClientFactory(
private val clock: SystemClock,
private val analyticsService: AnalyticsService,
private val featureFlagService: FeatureFlagService,
- private val timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory,
+ private val timelineEventFilterFactory: TimelineEventFilterFactory,
private val clientBuilderProvider: ClientBuilderProvider,
private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider,
private val workManagerScheduler: WorkManagerScheduler,
) {
- private val sessionDelegate = RustClientSessionDelegate(sessionStore, appCoroutineScope, coroutineDispatchers)
+ private val sessionDelegate = RustClientSessionDelegate(
+ sessionStore = sessionStore,
+ appCoroutineScope = appCoroutineScope,
+ analyticsService = analyticsService,
+ coroutineDispatchers = coroutineDispatchers
+ )
suspend fun create(sessionData: SessionData): RustMatrixClient = withContext(coroutineDispatchers.io) {
val client = getBaseClientBuilder(
@@ -115,7 +120,7 @@ class RustMatrixClientFactory(
dispatchers = coroutineDispatchers,
baseCacheDirectory = cacheDirectory,
clock = clock,
- timelineEventTypeFilterFactory = timelineEventTypeFilterFactory,
+ timelineEventFilterFactory = timelineEventFilterFactory,
featureFlagService = featureFlagService,
analyticsService = analyticsService,
workManagerScheduler = workManagerScheduler,
@@ -161,7 +166,8 @@ class RustMatrixClientFactory(
.requestConfig(
RequestConfig(
timeout = 30_000uL,
- retryLimit = 0u,
+ // retryLimit must be non-zero for the SDK to retry API calls in case of error (including 429 Too Many Requests error).
+ retryLimit = 3u,
// Use default values for the rest
maxConcurrentRequests = null,
maxRetryTime = null,
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrErrorMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrErrorMapper.kt
index 65e15b5f2b..701d1a0bbb 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrErrorMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrErrorMapper.kt
@@ -38,6 +38,7 @@ object QrErrorMapper {
is RustHumanQrLoginException.ConnectionInsecure -> QrLoginException.ConnectionInsecure
is RustHumanQrLoginException.Declined -> QrLoginException.Declined
is RustHumanQrLoginException.Expired -> QrLoginException.Expired
+ is RustHumanQrLoginException.NotFound -> QrLoginException.NotFound
is RustHumanQrLoginException.OtherDeviceNotSignedIn -> QrLoginException.OtherDeviceNotSignedIn
is RustHumanQrLoginException.LinkingNotSupported -> QrLoginException.LinkingNotSupported
is RustHumanQrLoginException.Unknown -> QrLoginException.Unknown
@@ -45,6 +46,5 @@ object QrErrorMapper {
is RustHumanQrLoginException.SlidingSyncNotAvailable -> QrLoginException.SlidingSyncNotAvailable
is RustHumanQrLoginException.CheckCodeAlreadySent -> QrLoginException.CheckCodeAlreadySent
is RustHumanQrLoginException.CheckCodeCannotBeSent -> QrLoginException.CheckCodeCannotBeSent
- is RustHumanQrLoginException.NotFound -> QrLoginException.NotFound
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/core/SdkBackgroundTaskError.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/core/SdkBackgroundTaskError.kt
new file mode 100644
index 0000000000..39b8709e0f
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/core/SdkBackgroundTaskError.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.core
+
+import uniffi.matrix_sdk_common.BackgroundTaskFailureReason
+
+/**
+ * Error thrown when a background SDK task panics and can't recover.
+ * @param task The name of the task that failed.
+ * @param reason The cause of this error.
+ */
+class SdkBackgroundTaskError(
+ task: String,
+ reason: BackgroundTaskFailureReason,
+) : Error() {
+ override val message: String = run {
+ val message = when (reason) {
+ is BackgroundTaskFailureReason.EarlyTermination -> "Early termination"
+ is BackgroundTaskFailureReason.Error -> "Error: ${reason.error}"
+ is BackgroundTaskFailureReason.Panic -> buildString {
+ append("Panic (unrecoverable): ")
+ reason.message?.let { append(it) }
+ reason.panicBacktrace?.let {
+ append("\n")
+ append(it)
+ }
+ }
+ }
+ "SDK background task '$task' failure: \n$message"
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt
index 00d4175ee3..bb58054998 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt
@@ -80,6 +80,7 @@ import org.matrix.rustcomponents.sdk.getElementCallRequiredPermissions
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import uniffi.matrix_sdk.RoomPowerLevelChanges
+import uniffi.matrix_sdk_ui.TimelineEventFocusThreadMode
import uniffi.matrix_sdk_ui.TimelineReadReceiptTracking
import kotlin.coroutines.cancellation.CancellationException
import org.matrix.rustcomponents.sdk.IdentityStatusChange as RustIdentityStateChange
@@ -177,21 +178,18 @@ class JoinedRustRoom(
): Result = withContext(roomDispatcher) {
val hideThreadedEvents = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
val focus = when (createTimelineParams) {
- is CreateTimelineParams.PinnedOnly -> TimelineFocus.PinnedEvents(
- maxEventsToLoad = 100u,
- maxConcurrentRequests = 10u,
- )
+ is CreateTimelineParams.PinnedOnly -> TimelineFocus.PinnedEvents
is CreateTimelineParams.MediaOnly -> TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents)
is CreateTimelineParams.Focused -> TimelineFocus.Event(
eventId = createTimelineParams.focusedEventId.value,
numContextEvents = 50u,
- hideThreadedEvents = hideThreadedEvents,
+ threadMode = TimelineEventFocusThreadMode.Automatic(hideThreadedEvents),
)
is CreateTimelineParams.MediaOnlyFocused -> TimelineFocus.Event(
eventId = createTimelineParams.focusedEventId.value,
numContextEvents = 50u,
// Never hide threaded events in media focused timeline
- hideThreadedEvents = false,
+ threadMode = TimelineEventFocusThreadMode.Automatic(false),
)
is CreateTimelineParams.Threaded -> TimelineFocus.Thread(
rootEventId = createTimelineParams.threadRootEventId.value,
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt
index cbc39b1c61..a3af54863c 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt
@@ -57,7 +57,7 @@ class RustRoomFactory(
private val roomListService: RoomListService,
private val innerRoomListService: InnerRoomListService,
private val roomSyncSubscriber: RoomSyncSubscriber,
- private val timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory,
+ private val timelineEventFilterFactory: TimelineEventFilterFactory,
private val featureFlagService: FeatureFlagService,
private val roomMembershipObserver: RoomMembershipObserver,
private val roomInfoMapper: RoomInfoMapper,
@@ -70,7 +70,7 @@ class RustRoomFactory(
private val eventFilters = TimelineConfig.excludedEvents
.takeIf { it.isNotEmpty() }
?.let { listStateEventType ->
- timelineEventTypeFilterFactory.create(listStateEventType)
+ timelineEventFilterFactory.create(listStateEventType)
}
suspend fun destroy() {
@@ -133,7 +133,7 @@ class RustRoomFactory(
sdkRoom.timelineWithConfiguration(
TimelineConfiguration(
focus = TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents),
- filter = eventFilters?.let(TimelineFilter::EventTypeFilter) ?: TimelineFilter.All,
+ filter = eventFilters?.let(TimelineFilter::EventFilter) ?: TimelineFilter.All,
internalIdPrefix = "live",
dateDividerMode = DateDividerMode.DAILY,
trackReadReceipts = TimelineReadReceiptTracking.ALL_EVENTS,
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/TimelineEventTypeFilterFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/TimelineEventFilterFactory.kt
similarity index 75%
rename from libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/TimelineEventTypeFilterFactory.kt
rename to libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/TimelineEventFilterFactory.kt
index d25f595569..b4735fdaf3 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/TimelineEventTypeFilterFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/TimelineEventFilterFactory.kt
@@ -12,16 +12,16 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.room.StateEventType
import org.matrix.rustcomponents.sdk.FilterTimelineEventType
-import org.matrix.rustcomponents.sdk.TimelineEventTypeFilter
+import org.matrix.rustcomponents.sdk.TimelineEventFilter
-interface TimelineEventTypeFilterFactory {
- fun create(listStateEventType: List): TimelineEventTypeFilter
+interface TimelineEventFilterFactory {
+ fun create(listStateEventType: List): TimelineEventFilter
}
@ContributesBinding(AppScope::class)
-class RustTimelineEventTypeFilterFactory : TimelineEventTypeFilterFactory {
- override fun create(listStateEventType: List): TimelineEventTypeFilter {
- return TimelineEventTypeFilter.exclude(
+class RustTimelineEventFilterFactory : TimelineEventFilterFactory {
+ override fun create(listStateEventType: List): TimelineEventFilter {
+ return TimelineEventFilter.excludeEventTypes(
listStateEventType.map { stateEventType ->
FilterTimelineEventType.State(stateEventType.map())
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/JoinRule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/JoinRule.kt
index aac57451db..7f65ad7b4b 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/JoinRule.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/JoinRule.kt
@@ -15,7 +15,8 @@ import org.matrix.rustcomponents.sdk.JoinRule as RustJoinRule
fun RustJoinRule.map(): JoinRule {
return when (this) {
RustJoinRule.Public -> JoinRule.Public
- RustJoinRule.Private -> JoinRule.Private
+ // Assume a private join rule is invite only instead. Private shouldn't be in use in Matrix.
+ RustJoinRule.Private -> JoinRule.Invite
RustJoinRule.Knock -> JoinRule.Knock
RustJoinRule.Invite -> JoinRule.Invite
is RustJoinRule.Restricted -> JoinRule.Restricted(rules.map { it.map() }.toImmutableList())
@@ -27,7 +28,6 @@ fun RustJoinRule.map(): JoinRule {
fun JoinRule.map(): RustJoinRule {
return when (this) {
JoinRule.Public -> RustJoinRule.Public
- JoinRule.Private -> RustJoinRule.Private
JoinRule.Knock -> RustJoinRule.Knock
JoinRule.Invite -> RustJoinRule.Invite
is JoinRule.Restricted -> RustJoinRule.Restricted(rules.map { it.map() })
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListDynamicEvents.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListDynamicEvents.kt
deleted file mode 100644
index 48d97cc684..0000000000
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListDynamicEvents.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-2025 New Vector 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 org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
-
-internal sealed interface RoomListDynamicEvents {
- data object Reset : RoomListDynamicEvents
- data object LoadMore : RoomListDynamicEvents
- data class SetFilter(val filter: RoomListEntriesDynamicFilterKind) : RoomListDynamicEvents
-}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt
index ae241e9c02..5fd5e0c75d 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt
@@ -18,9 +18,8 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
import org.matrix.rustcomponents.sdk.Room
+import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
import org.matrix.rustcomponents.sdk.RoomListEntriesListener
import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
@@ -57,8 +56,8 @@ fun RoomListInterface.loadingStateFlow(): Flow =
internal fun RoomListInterface.entriesFlow(
pageSize: Int,
- roomListDynamicEvents: Flow,
- initialFilterKind: RoomListEntriesDynamicFilterKind
+ initialFilterKind: RoomListEntriesDynamicFilterKind,
+ onControllerCreated: (RoomListDynamicEntriesController) -> Unit,
): Flow> =
callbackFlow {
val listener = object : RoomListEntriesListener {
@@ -73,19 +72,7 @@ internal fun RoomListInterface.entriesFlow(
)
val controller = result.controller()
controller.setFilter(initialFilterKind)
- roomListDynamicEvents.onEach { controllerEvents ->
- when (controllerEvents) {
- is RoomListDynamicEvents.SetFilter -> {
- controller.setFilter(controllerEvents.filter)
- }
- is RoomListDynamicEvents.LoadMore -> {
- controller.addOnePage()
- }
- is RoomListDynamicEvents.Reset -> {
- controller.resetToOnePage()
- }
- }
- }.launchIn(this)
+ onControllerCreated(controller)
awaitClose {
result.entriesStream().cancelAndDestroy()
controller.destroy()
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt
index 19bd3b7573..1d90b07104 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList
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.RoomListFilter.Companion.all
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService
@@ -18,24 +19,16 @@ import io.element.android.services.analytics.api.finishLongRunningTransaction
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.SharedFlow
-import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
-import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
+import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController
import org.matrix.rustcomponents.sdk.RoomListLoadingState
import org.matrix.rustcomponents.sdk.RoomListService
import kotlin.coroutines.CoroutineContext
import org.matrix.rustcomponents.sdk.RoomList as InnerRoomList
-private val ROOM_LIST_RUST_FILTERS = listOf(
- RoomListEntriesDynamicFilterKind.NonLeft,
- RoomListEntriesDynamicFilterKind.DeduplicateVersions
-)
-
internal class RoomListFactory(
private val innerRoomListService: RoomListService,
private val analyticsService: AnalyticsService,
@@ -49,18 +42,14 @@ internal class RoomListFactory(
pageSize: Int,
coroutineContext: CoroutineContext,
coroutineScope: CoroutineScope,
- initialFilter: RoomListFilter = RoomListFilter.all(),
+ initialFilter: RoomListFilter = all(),
innerProvider: suspend () -> InnerRoomList
): DynamicRoomList {
val loadingStateFlow: MutableStateFlow = MutableStateFlow(RoomList.LoadingState.NotLoaded)
- val filteredSummariesFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = 1)
val summariesFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = 1)
val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryFactory, analyticsService)
- // Makes sure we don't miss any events
- val dynamicEvents = MutableSharedFlow(replay = 100)
- val currentFilter = MutableStateFlow(initialFilter)
- val loadedPages = MutableStateFlow(1)
var innerRoomList: InnerRoomList? = null
+ var dynamicController: RoomListDynamicEntriesController? = null
val firstRoomsTransaction = analyticsService.startTransaction("Load first set of rooms", "innerRoomList.entriesFlow")
@@ -69,8 +58,10 @@ internal class RoomListFactory(
innerRoomList.let { innerRoomList ->
innerRoomList.entriesFlow(
pageSize = pageSize,
- roomListDynamicEvents = dynamicEvents,
- initialFilterKind = RoomListEntriesDynamicFilterKind.All(ROOM_LIST_RUST_FILTERS),
+ initialFilterKind = RoomListFilterMapper.toRustFilter(initialFilter),
+ onControllerCreated = { controller ->
+ dynamicController = controller
+ }
).onEach { update ->
if (!firstRoomsTransaction.isFinished()) {
analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.FirstRoomsDisplayed)
@@ -85,61 +76,20 @@ internal class RoomListFactory(
loadingStateFlow.value = it
}
.launchIn(this)
-
- combine(
- currentFilter,
- summariesFlow
- ) { filter, summaries ->
- summaries.filter(filter)
- }.onEach {
- filteredSummariesFlow.emit(it)
- }.launchIn(this)
}
}.invokeOnCompletion {
innerRoomList?.destroy()
}
return RustDynamicRoomList(
summaries = summariesFlow,
- filteredSummaries = filteredSummariesFlow,
loadingState = loadingStateFlow,
- currentFilter = currentFilter,
- loadedPages = loadedPages,
- dynamicEvents = dynamicEvents,
processor = processor,
pageSize = pageSize,
+ dynamicController = { dynamicController }
)
}
}
-private class RustDynamicRoomList(
- override val summaries: MutableSharedFlow>,
- override val filteredSummaries: SharedFlow>,
- override val loadingState: MutableStateFlow,
- override val currentFilter: MutableStateFlow,
- override val loadedPages: MutableStateFlow,
- private val dynamicEvents: MutableSharedFlow,
- private val processor: RoomSummaryListProcessor,
- override val pageSize: Int,
-) : DynamicRoomList {
- override suspend fun rebuildSummaries() {
- processor.rebuildRoomSummaries()
- }
-
- override suspend fun updateFilter(filter: RoomListFilter) {
- currentFilter.emit(filter)
- }
-
- override suspend fun loadMore() {
- dynamicEvents.emit(RoomListDynamicEvents.LoadMore)
- loadedPages.getAndUpdate { it + 1 }
- }
-
- override suspend fun reset() {
- dynamicEvents.emit(RoomListDynamicEvents.Reset)
- loadedPages.emit(1)
- }
-}
-
private fun RoomListLoadingState.toLoadingState(): RoomList.LoadingState {
return when (this) {
is RoomListLoadingState.Loaded -> RoomList.LoadingState.Loaded(maximumNumberOfRooms?.toInt() ?: 0)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilter.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilter.kt
deleted file mode 100644
index ed4d5735e0..0000000000
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilter.kt
+++ /dev/null
@@ -1,72 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2024, 2025 New Vector 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 io.element.android.libraries.core.extensions.withoutAccents
-import io.element.android.libraries.matrix.api.room.CurrentUserMembership
-import io.element.android.libraries.matrix.api.room.isDm
-import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
-import io.element.android.libraries.matrix.api.roomlist.RoomSummary
-
-val RoomListFilter.predicate
- get() = when (this) {
- is RoomListFilter.All -> { roomSummary -> NonSpacePredicate(roomSummary) || IsInvitedPredicate(roomSummary) }
- is RoomListFilter.Any -> { roomSummary -> NonSpacePredicate(roomSummary) || IsInvitedPredicate(roomSummary) }
- RoomListFilter.None -> { _ -> false }
- RoomListFilter.Category.Group -> { roomSummary: RoomSummary ->
- !roomSummary.info.isDm && NonInvitedPredicate(roomSummary) && NonSpacePredicate(roomSummary)
- }
- RoomListFilter.Category.People -> { roomSummary: RoomSummary ->
- roomSummary.info.isDm && NonInvitedPredicate(roomSummary) && NonSpacePredicate(roomSummary)
- }
- RoomListFilter.Category.Space -> IsSpacePredicate
- RoomListFilter.Favorite -> { roomSummary: RoomSummary ->
- roomSummary.info.isFavorite && NonInvitedPredicate(roomSummary) && NonSpacePredicate(roomSummary)
- }
- RoomListFilter.Unread -> { roomSummary: RoomSummary ->
- NonInvitedPredicate(roomSummary) &&
- NonSpacePredicate(roomSummary) &&
- (roomSummary.info.numUnreadNotifications > 0 || roomSummary.info.isMarkedUnread)
- }
- is RoomListFilter.NormalizedMatchRoomName -> { roomSummary: RoomSummary ->
- roomSummary.info.name?.withoutAccents().orEmpty().contains(normalizedPattern, ignoreCase = true) &&
- (NonSpacePredicate(roomSummary) || IsInvitedPredicate(roomSummary))
- }
- RoomListFilter.Invite -> IsInvitedPredicate
- }
-
-fun List.filter(filter: RoomListFilter): List {
- return when (filter) {
- is RoomListFilter.All -> {
- val predicates = if (filter.filters.isNotEmpty()) {
- filter.filters.map { it.predicate }
- } else {
- listOf(filter.predicate)
- }
- filter { roomSummary -> predicates.all { it(roomSummary) } }
- }
- is RoomListFilter.Any -> {
- val predicates = if (filter.filters.isNotEmpty()) {
- filter.filters.map { it.predicate }
- } else {
- listOf(filter.predicate)
- }
- filter { roomSummary -> predicates.any { it(roomSummary) } }
- }
- else -> filter(filter.predicate)
- }
-}
-
-private val IsSpacePredicate = { roomSummary: RoomSummary -> roomSummary.info.isSpace }
-
-private val NonSpacePredicate = { roomSummary: RoomSummary -> !IsSpacePredicate(roomSummary) }
-
-private val IsInvitedPredicate = { roomSummary: RoomSummary -> roomSummary.info.currentUserMembership == CurrentUserMembership.INVITED }
-
-private val NonInvitedPredicate = { roomSummary: RoomSummary -> !IsInvitedPredicate(roomSummary) }
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterMapper.kt
new file mode 100644
index 0000000000..648376a9cc
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterMapper.kt
@@ -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.libraries.matrix.impl.roomlist
+
+import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
+import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
+import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.All
+import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Any
+import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Category
+import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.DeduplicateVersions
+import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Favourite
+import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Identifiers
+import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Invite
+import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.NonLeft
+import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.NonSpace
+import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.None
+import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.NormalizedMatchRoomName
+import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Space
+import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Unread
+import org.matrix.rustcomponents.sdk.RoomListFilterCategory
+
+/**
+ * Mapper for converting RoomListFilter to Rust SDK filter kinds.
+ */
+internal object RoomListFilterMapper {
+ /**
+ * Base rust filters to always apply across all room lists.
+ * These filters ensure we show:
+ * - Non-space, non-left rooms (regular rooms user is part of)
+ * - OR space invites (pending space invitations)
+ * - With version deduplication enabled
+ */
+ private val RUST_BASE_FILTERS = listOf(
+ Any(
+ listOf(
+ All(listOf(NonSpace, NonLeft)),
+ All(listOf(Space, Invite)),
+ )
+ ),
+ DeduplicateVersions
+ )
+
+ /**
+ * Converts a RoomListFilter to a Rust SDK RoomListEntriesDynamicFilterKind.
+ * Applies base filters along with the provided filter.
+ */
+ fun toRustFilter(filter: RoomListFilter): RoomListEntriesDynamicFilterKind {
+ return All(RUST_BASE_FILTERS + mapFilter(filter))
+ }
+
+ /**
+ * Maps a RoomListFilter to its Rust SDK equivalent.
+ * This replaces the previous RoomListFilter.into() extension function.
+ */
+ private fun mapFilter(filter: RoomListFilter): RoomListEntriesDynamicFilterKind {
+ return when (filter) {
+ is RoomListFilter.All -> All(filters = filter.filters.map { mapFilter(it) })
+ is RoomListFilter.Any -> Any(filters = filter.filters.map { mapFilter(it) })
+ is RoomListFilter.Identifiers -> Identifiers(identifiers = filter.values.map { it.value })
+ RoomListFilter.None -> None
+ RoomListFilter.Category.Group -> Category(RoomListFilterCategory.GROUP)
+ RoomListFilter.Category.People -> Category(RoomListFilterCategory.PEOPLE)
+ RoomListFilter.Category.Space -> Space
+ RoomListFilter.Favorite -> Favourite
+ RoomListFilter.Unread -> Unread
+ is RoomListFilter.NormalizedMatchRoomName -> NormalizedMatchRoomName(
+ pattern = filter.pattern
+ )
+ RoomListFilter.Invite -> Invite
+ }
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryFactory.kt
index 738c1f72ea..06afb96d3f 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryFactory.kt
@@ -53,6 +53,11 @@ class RoomSummaryFactory(
senderProfile = event.profile.map(),
isOwn = event.isOwn,
)
+ is RustLatestEventValue.RemoteInvite -> LatestEventValue.RoomInvite(
+ timestamp = event.timestamp.toLong(),
+ inviterId = event.inviter?.let(::UserId),
+ invitedProfile = event.inviterProfile.map(),
+ )
}
}
return RoomSummary(
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomList.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomList.kt
new file mode 100644
index 0000000000..0d7b2394b3
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomList.kt
@@ -0,0 +1,62 @@
+/*
+ * 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 io.element.android.libraries.matrix.api.roomlist.DynamicRoomList
+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 kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController
+
+private const val DEFAULT_ADD_PAGES_COUNT = 3
+
+internal class RustDynamicRoomList(
+ override val summaries: MutableSharedFlow>,
+ override val loadingState: MutableStateFlow,
+ private val processor: RoomSummaryListProcessor,
+ override val pageSize: Int,
+ private val dynamicController: () -> RoomListDynamicEntriesController?,
+ private val addPagesCount: Int = DEFAULT_ADD_PAGES_COUNT
+) : DynamicRoomList {
+ private val mutex = Mutex()
+
+ override suspend fun rebuildSummaries() {
+ processor.rebuildRoomSummaries()
+ }
+
+ override suspend fun updateFilter(filter: RoomListFilter) {
+ mutex.withLock {
+ dynamicController()?.let { controller ->
+ // Reset pagination when filter changes
+ controller.resetToOnePage()
+ val rustFilter = RoomListFilterMapper.toRustFilter(filter)
+ controller.setFilter(rustFilter)
+ // Then preload some pages
+ controller.addPages(addPagesCount)
+ }
+ }
+ }
+
+ override suspend fun loadMore() {
+ mutex.withLock {
+ dynamicController()?.addPages(addPagesCount)
+ }
+ }
+
+ override suspend fun reset() {
+ mutex.withLock {
+ dynamicController()?.resetToOnePage()
+ }
+ }
+
+ private fun RoomListDynamicEntriesController.addPages(pageCount: Int) = repeat(pageCount) { addOnePage() }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt
index 69bddf328e..70a2ac9f93 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt
@@ -11,9 +11,7 @@ package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList
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.RoomListService
-import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally
import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
@@ -28,8 +26,6 @@ import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicator
import timber.log.Timber
import org.matrix.rustcomponents.sdk.RoomListService as InnerRustRoomListService
-private const val DEFAULT_PAGE_SIZE = 20
-
internal class RustRoomListService(
private val innerRoomListService: InnerRustRoomListService,
private val sessionDispatcher: CoroutineDispatcher,
@@ -39,13 +35,11 @@ internal class RustRoomListService(
) : RoomListService {
override fun createRoomList(
pageSize: Int,
- initialFilter: RoomListFilter,
source: RoomList.Source,
coroutineScope: CoroutineScope,
): DynamicRoomList {
return roomListFactory.createRoomList(
pageSize = pageSize,
- initialFilter = initialFilter,
coroutineContext = sessionDispatcher,
coroutineScope = coroutineScope,
) {
@@ -59,18 +53,14 @@ internal class RustRoomListService(
roomSyncSubscriber.batchSubscribe(roomIds)
}
- override val allRooms: DynamicRoomList = roomListFactory.createRoomList(
- pageSize = DEFAULT_PAGE_SIZE,
+ override val allRooms: RoomList = roomListFactory.createRoomList(
+ pageSize = Int.MAX_VALUE,
coroutineContext = sessionDispatcher,
coroutineScope = sessionCoroutineScope,
) {
innerRoomListService.allRooms()
}
- init {
- allRooms.loadAllIncrementally(sessionCoroutineScope)
- }
-
override val syncIndicator: StateFlow =
innerRoomListService.syncIndicator()
.map { it.toSyncIndicator() }
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustLeaveSpaceHandle.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustLeaveSpaceHandle.kt
index 16b5186c92..9411aa0bf9 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustLeaveSpaceHandle.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustLeaveSpaceHandle.kt
@@ -41,6 +41,7 @@ class RustLeaveSpaceHandle(
LeaveSpaceRoom(
spaceRoom = spaceRoomMapper.map(leaveSpaceRoom.spaceRoom),
isLastOwner = leaveSpaceRoom.isLastOwner,
+ areCreatorsPrivileged = leaveSpaceRoom.areCreatorsPrivileged,
)
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt
index 80652bee71..867c14d18f 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt
@@ -29,7 +29,7 @@ import java.util.Optional
import org.matrix.rustcomponents.sdk.SpaceRoomList as InnerSpaceRoomList
class RustSpaceRoomList(
- override val roomId: RoomId,
+ override val spaceId: RoomId,
private val innerProvider: suspend () -> InnerSpaceRoomList,
private val coroutineScope: CoroutineScope,
spaceRoomMapper: SpaceRoomMapper,
@@ -81,9 +81,15 @@ class RustSpaceRoomList(
}
}
+ override suspend fun reset(): Result {
+ return runCatchingExceptions {
+ innerCompletable.await().reset()
+ }
+ }
+
@OptIn(ExperimentalCoroutinesApi::class)
override fun destroy() {
- Timber.d("Destroying SpaceRoomList $roomId")
+ Timber.d("Destroying SpaceRoomList $spaceId")
coroutineScope.cancel()
try {
innerCompletable.getCompleted().destroy()
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt
index 4d9bc2fe61..0f2c92dae2 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt
@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.api.spaces.SpaceService
+import io.element.android.libraries.matrix.api.spaces.SpaceServiceFilter
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineDispatcher
@@ -31,9 +32,11 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
+import org.matrix.rustcomponents.sdk.SpaceFilterUpdate
import org.matrix.rustcomponents.sdk.SpaceListUpdate
import org.matrix.rustcomponents.sdk.SpaceServiceInterface
import org.matrix.rustcomponents.sdk.SpaceServiceJoinedSpacesListener
+import org.matrix.rustcomponents.sdk.SpaceServiceSpaceFiltersListener
import timber.log.Timber
import org.matrix.rustcomponents.sdk.SpaceService as ClientSpaceService
@@ -45,20 +48,20 @@ class RustSpaceService(
private val analyticsService: AnalyticsService,
) : SpaceService {
private val spaceRoomMapper = SpaceRoomMapper()
- override val spaceRoomsFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = 1)
+ private val spaceFilterMapper = SpaceServiceFilterMapper(spaceRoomMapper)
+
+ override val topLevelSpacesFlow = MutableSharedFlow