Merge branch 'release/26.02.0'

This commit is contained in:
ganfra 2026-02-11 17:44:49 +01:00
commit b9c6a8e530
3067 changed files with 10841 additions and 8400 deletions

View file

@ -22,7 +22,8 @@
{ {
"versioning": "semver", "versioning": "semver",
"matchPackageNames": [ "matchPackageNames": [
"/^org.maplibre/" "/^org.maplibre/",
"/^org.jetbrains.kotlinx:kotlinx-datetime/"
] ]
} }
] ]

22
.github/workflows/stale-issues.yml vendored Normal file
View file

@ -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."

4
.idea/kotlinc.xml generated
View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="2.3.0" /> <option name="version" value="2.3.10" />
</component> </component>
</project> </project>

View file

@ -9,29 +9,35 @@ appId: ${MAESTRO_APP_ID}
id: "login-continue" id: "login-continue"
## MAS page ## MAS page
## Conditional workflow to pass the Chrome first launch welcome page. ## Conditional workflow to pass the Chrome first launch welcome page.
- runFlow: - retry:
when: maxRetries: 3
visible: 'Use without an account'
commands: commands:
- tapOn: "Use without an account" - runFlow:
## For older chrome versions when:
- runFlow: visible: 'Use without an account'
when: commands:
visible: 'Accept & continue' - tapOn: "Use without an account"
commands: ## For older chrome versions
- tapOn: "Accept & continue" - runFlow:
- runFlow: when:
when: visible: 'Accept & continue'
visible: 'No thanks' commands:
commands: - tapOn: "Accept & continue"
- tapOn: "No thanks" - runFlow:
when:
visible: 'No thanks'
commands:
- tapOn: "No thanks"
## Working when running Maestro locally, but not on the CI yet. ## Working when running Maestro locally, but not on the CI yet.
- extendedWaitUntil: - retry:
visible: maxRetries: 3
id: "form-1" commands:
timeout: 10000 - extendedWaitUntil:
visible:
id: "form-1"
timeout: 10000
- tapOn: - tapOn:
id: "form-1" id: "form-1"
- inputText: ${MAESTRO_USERNAME} - inputText: ${MAESTRO_USERNAME}
- pressKey: Enter - pressKey: Enter
- tapOn: - tapOn:

View file

@ -13,3 +13,8 @@ appId: ${MAESTRO_APP_ID}
- scroll - scroll
- tapOn: "Leave room" - tapOn: "Leave room"
- tapOn: "Leave" - tapOn: "Leave"
- runFlow:
when:
visible: 'You need an invite in order to join'
commands:
- tapOn: "Back"

View file

@ -3,9 +3,9 @@ appId: ${MAESTRO_APP_ID}
# Purpose: Test the creation and deletion of a room # Purpose: Test the creation and deletion of a room
- tapOn: "Create a new conversation or room" - tapOn: "Create a new conversation or room"
- tapOn: "New room" - tapOn: "New room"
- tapOn: "e.g. your project name" - tapOn: "Add name…"
- inputText: "aRoomName" - inputText: "aRoomName"
- tapOn: "What is this room about?" - tapOn: "Add description…"
- inputText: "aRoomTopic" - inputText: "aRoomTopic"
- tapOn: "Create" - tapOn: "Create"
- takeScreenshot: build/maestro/320-createAndDeleteRoom - takeScreenshot: build/maestro/320-createAndDeleteRoom
@ -37,3 +37,8 @@ appId: ${MAESTRO_APP_ID}
- scroll - scroll
- tapOn: "Leave room" - tapOn: "Leave room"
- tapOn: "Leave" - tapOn: "Leave"
- runFlow:
when:
visible: 'You need an invite in order to join'
commands:
- tapOn: "Back"

View file

@ -1,3 +1,70 @@
Changes in Element X v26.01.2
=============================
<!-- Release notes generated using configuration in .github/release.yml at 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 Changes in Element X v26.01.1
============================= =============================

View file

@ -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.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeBaseRoom 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.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.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.ui.room.LoadingRoomState import io.element.android.libraries.matrix.ui.room.LoadingRoomState
import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory
@ -54,7 +55,8 @@ class LoadingBaseRoomStateFlowFactoryTest {
@Test @Test
fun `flow should emit Loading and then Loaded when there is a room in cache after SS is loaded`() = runTest { 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 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 matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService)
val flowFactory = LoadingRoomStateFlowFactory(matrixClient) val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
flowFactory flowFactory
@ -62,21 +64,22 @@ class LoadingBaseRoomStateFlowFactoryTest {
.test { .test {
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading) assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
matrixClient.givenGetRoomResult(A_ROOM_ID, room) 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)) assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room))
} }
} }
@Test @Test
fun `flow should emit Loading and then Error when there is no room in cache after SS is loaded`() = runTest { 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 matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService)
val flowFactory = LoadingRoomStateFlowFactory(matrixClient) val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
flowFactory flowFactory
.create(lifecycleScope = this, roomId = A_ROOM_ID, joinedRoom = null) .create(lifecycleScope = this, roomId = A_ROOM_ID, joinedRoom = null)
.test { .test {
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading) assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) roomList.loadingState.emit(RoomList.LoadingState.Loaded(1))
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Error) assertThat(awaitItem()).isEqualTo(LoadingRoomState.Error)
} }
} }

View file

@ -24,6 +24,7 @@
* [Logging](#logging) * [Logging](#logging)
* [Translations](#translations) * [Translations](#translations)
* [Rageshake](#rageshake) * [Rageshake](#rageshake)
* [Developer options](#developer-options)
* [Tips](#tips) * [Tips](#tips)
* [Happy coding!](#happy-coding) * [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. 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 ### 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 - 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. 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`; - When this is possible, prefer using `sealed interface` instead of `sealed class`;

View file

@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -149,15 +149,18 @@ class CallScreenPresenter(
.launchIn(this) .launchIn(this)
} }
LaunchedEffect(Unit) { if (callType is CallType.RoomCall) {
// Wait for the call to be joined, if it takes too long, we display an error // Note: For external calls isWidgetLoaded will always be false
delay(10.seconds) LaunchedEffect(Unit) {
// Wait for the call to be joined, if it takes too long, we display an error
delay(10.seconds)
if (!isWidgetLoaded) { if (!isWidgetLoaded) {
Timber.w("The call took too long to load. Displaying an error before exiting.") 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 // This will display a simple 'Sorry, an error occurred' dialog and force the user to exit the call
webViewError = "" webViewError = ""
}
} }
} }
} }

View file

@ -82,10 +82,6 @@ class ConfigureRoomPresenter(
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA) private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
private var pendingPermissionRequest = false private var pendingPermissionRequest = false
init {
dataStore.setIsSpace(isSpace)
}
@Composable @Composable
override fun present(): ConfigureRoomState { override fun present(): ConfigureRoomState {
val canAddRoomToSpace by featureFlagService.isFeatureEnabledFlow(FeatureFlags.CreateSpaces).collectAsState(false) val canAddRoomToSpace by featureFlagService.isFeatureEnabledFlow(FeatureFlags.CreateSpaces).collectAsState(false)
@ -123,9 +119,10 @@ class ConfigureRoomPresenter(
} else { } else {
persistentListOf() persistentListOf()
} }
val parentSpace = spaces.find { it.roomId == initialParentSpaceId } val parentSpace = spaces.find { it.roomId == initialParentSpaceId }
parentSpace?.let { dataStore.setParentSpace(it) } parentSpace?.let {
dataStore.setParentSpace(parentSpace = parentSpace, updateVisibility = true)
}
} }
LaunchedEffect(cameraPermissionState.permissionGranted) { LaunchedEffect(cameraPermissionState.permissionGranted) {
@ -152,21 +149,42 @@ class ConfigureRoomPresenter(
// 2. If it has a parent space. // 2. If it has a parent space.
// 3. If knocking is enabled. // 3. If knocking is enabled.
val parentSpace = createRoomConfig.parentSpace val parentSpace = createRoomConfig.parentSpace
val availableJoinRules = remember(createRoomConfig.parentSpace, isSpace, isKnockFeatureEnabled) { val availableJoinRules = remember(parentSpace, isSpace, isKnockFeatureEnabled) {
when { when {
isSpace && parentSpace != null -> TODO("Adding a space to a parent space is not supported yet! How did you get here?") isSpace && parentSpace != null -> TODO("Adding a space to a parent space is not supported yet! How did you get here?")
parentSpace == null || parentSpace.joinRule == JoinRule.Public -> listOfNotNull( parentSpace == null || parentSpace.joinRule == JoinRule.Public -> listOfNotNull(
JoinRuleItem.PublicVisibility.Public, JoinRuleItem.PublicVisibility.Public,
JoinRuleItem.PublicVisibility.AskToJoin.takeIf { !isSpace && isKnockFeatureEnabled }, JoinRuleItem.PublicVisibility.AskToJoin.takeIf { !isSpace && isKnockFeatureEnabled },
JoinRuleItem.Private, JoinRuleItem.PrivateVisibility.Private,
).toImmutableList() ).toImmutableList()
else -> listOfNotNull( else -> listOfNotNull(
JoinRuleItem.PublicVisibility.Restricted(parentSpace.roomId), JoinRuleItem.PrivateVisibility.Restricted(parentSpace.roomId),
JoinRuleItem.PublicVisibility.AskToJoinRestricted(parentSpace.roomId).takeIf { !isSpace && isKnockFeatureEnabled }, JoinRuleItem.PrivateVisibility.AskToJoinRestricted(parentSpace.roomId).takeIf { isKnockFeatureEnabled },
JoinRuleItem.Private, JoinRuleItem.PrivateVisibility.Private,
).toImmutableList() ).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<JoinRuleItem.PrivateVisibility.Restricted>().firstOrNull()
is JoinRuleItem.PrivateVisibility.AskToJoinRestricted ->
availableJoinRules.filterIsInstance<JoinRuleItem.PrivateVisibility.AskToJoinRestricted>().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) { fun createRoom(config: CreateRoomConfig) {
createRoomAction.value = AsyncAction.Uninitialized createRoomAction.value = AsyncAction.Uninitialized
@ -193,7 +211,7 @@ class ConfigureRoomPresenter(
} }
} }
is ConfigureRoomEvents.SetParentSpace -> { is ConfigureRoomEvents.SetParentSpace -> {
dataStore.setParentSpace(event.space) dataStore.setParentSpace(event.space, false)
} }
ConfigureRoomEvents.CancelCreateRoom -> { ConfigureRoomEvents.CancelCreateRoom -> {
createRoomAction.value = AsyncAction.Uninitialized createRoomAction.value = AsyncAction.Uninitialized
@ -210,6 +228,7 @@ class ConfigureRoomPresenter(
roomAddressValidity = roomAddressValidity.value, roomAddressValidity = roomAddressValidity.value,
availableJoinRules = availableJoinRules, availableJoinRules = availableJoinRules,
spaces = spaces, spaces = spaces,
isSpace = isSpace,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@ -220,35 +239,41 @@ class ConfigureRoomPresenter(
) = launch { ) = launch {
suspend { suspend {
val avatarUrl = config.avatarUri?.let { uploadAvatar(it.toUri()) } val avatarUrl = config.avatarUri?.let { uploadAvatar(it.toUri()) }
val params = if (config.visibilityState is RoomVisibilityState.Public) { val params = when (config.visibilityState) {
CreateRoomParameters( is RoomVisibilityState.Public -> {
name = config.roomName, CreateRoomParameters(
topic = config.topic, name = config.roomName,
isEncrypted = false, topic = config.topic,
isDirect = false, isEncrypted = false,
visibility = RoomVisibility.Public, isDirect = false,
joinRuleOverride = config.visibilityState.joinRuleItem.toJoinRule() visibility = RoomVisibility.Public,
// No need to specify the public join rule override, since the preset is already PUBLIC_CHAT joinRuleOverride = config.visibilityState.joinRuleItem.toJoinRule()
.takeIf { it != JoinRule.Public }, // No need to specify the public join rule override, since the preset is already PUBLIC_CHAT
preset = RoomPreset.PUBLIC_CHAT, .takeIf { it != JoinRule.Public },
invite = config.invites.map { it.userId }, preset = RoomPreset.PUBLIC_CHAT,
avatar = avatarUrl, invite = config.invites.map { it.userId },
roomAliasName = config.visibilityState.roomAddress(), avatar = avatarUrl,
isSpace = isSpace, roomAliasName = config.visibilityState.roomAddress(),
) isSpace = isSpace,
} else { )
CreateRoomParameters( }
name = config.roomName, is RoomVisibilityState.Private -> {
topic = config.topic, CreateRoomParameters(
isEncrypted = config.visibilityState is RoomVisibilityState.Private, name = config.roomName,
isDirect = false, topic = config.topic,
visibility = RoomVisibility.Private, isEncrypted = true,
historyVisibilityOverride = RoomHistoryVisibility.Invited, isDirect = false,
preset = RoomPreset.PRIVATE_CHAT, visibility = RoomVisibility.Private,
invite = config.invites.map { it.userId }, historyVisibilityOverride = RoomHistoryVisibility.Invited,
avatar = avatarUrl, joinRuleOverride = config.visibilityState.joinRuleItem.toJoinRule()
isSpace = isSpace, // 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) val roomId = matrixClient.createRoom(params)
.onFailure { failure -> .onFailure { failure ->

View file

@ -17,6 +17,7 @@ import io.element.android.libraries.permissions.api.PermissionsState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class ConfigureRoomState( data class ConfigureRoomState(
val isSpace: Boolean,
val config: CreateRoomConfig, val config: CreateRoomConfig,
val avatarActions: ImmutableList<AvatarAction>, val avatarActions: ImmutableList<AvatarAction>,
val createRoomAction: AsyncAction<RoomId>, val createRoomAction: AsyncAction<RoomId>,
@ -28,5 +29,6 @@ data class ConfigureRoomState(
val eventSink: (ConfigureRoomEvents) -> Unit val eventSink: (ConfigureRoomEvents) -> Unit
) { ) {
val isValid: Boolean = config.roomName?.isNotEmpty() == true && 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
} }

View file

@ -82,8 +82,8 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
roomAddressValidity = RoomAddressValidity.Valid, roomAddressValidity = RoomAddressValidity.Valid,
), ),
aConfigureRoomState( aConfigureRoomState(
isSpace = true,
config = CreateRoomConfig( config = CreateRoomConfig(
isSpace = true,
roomName = "Space 101", roomName = "Space 101",
topic = "Space topic for this space when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines", topic = "Space topic for this space when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
visibilityState = RoomVisibilityState.Public( visibilityState = RoomVisibilityState.Public(
@ -95,13 +95,11 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
), ),
aConfigureRoomState( aConfigureRoomState(
config = CreateRoomConfig( config = CreateRoomConfig(
isSpace = false,
roomName = "Room 101", roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines", topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
parentSpace = null, parentSpace = null,
visibilityState = RoomVisibilityState.Public( visibilityState = RoomVisibilityState.Private(
roomAddress = RoomAddress.AutoFilled("Space-101"), joinRuleItem = JoinRuleItem.PrivateVisibility.Restricted(aSpaceRoom().roomId),
joinRuleItem = JoinRuleItem.PublicVisibility.Restricted(aSpaceRoom().roomId),
), ),
), ),
spaces = listOf(aSpaceRoom()), spaces = listOf(aSpaceRoom()),
@ -109,13 +107,11 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
), ),
aConfigureRoomState( aConfigureRoomState(
config = CreateRoomConfig( config = CreateRoomConfig(
isSpace = false,
roomName = "Room 101", roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines", topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
parentSpace = aSpaceRoom(canonicalAlias = RoomAlias("#a-space-room:example.org")), parentSpace = aSpaceRoom(canonicalAlias = RoomAlias("#a-space-room:example.org")),
visibilityState = RoomVisibilityState.Public( visibilityState = RoomVisibilityState.Private(
roomAddress = RoomAddress.AutoFilled("Space-101"), joinRuleItem = JoinRuleItem.PrivateVisibility.Restricted(aSpaceRoom().roomId),
joinRuleItem = JoinRuleItem.PublicVisibility.Restricted(aSpaceRoom().roomId),
), ),
), ),
spaces = listOf(aSpaceRoom()), spaces = listOf(aSpaceRoom()),
@ -126,6 +122,7 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
fun aConfigureRoomState( fun aConfigureRoomState(
config: CreateRoomConfig = CreateRoomConfig(), config: CreateRoomConfig = CreateRoomConfig(),
isSpace: Boolean = false,
isKnockFeatureEnabled: Boolean = true, isKnockFeatureEnabled: Boolean = true,
avatarActions: List<AvatarAction> = emptyList(), avatarActions: List<AvatarAction> = emptyList(),
createRoomAction: AsyncAction<RoomId> = AsyncAction.Uninitialized, createRoomAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
@ -134,21 +131,22 @@ fun aConfigureRoomState(
roomAddressValidity: RoomAddressValidity = RoomAddressValidity.Valid, roomAddressValidity: RoomAddressValidity = RoomAddressValidity.Valid,
availableVisibilityOptions: List<JoinRuleItem> = if (config.parentSpace != null) { availableVisibilityOptions: List<JoinRuleItem> = if (config.parentSpace != null) {
listOfNotNull( listOfNotNull(
JoinRuleItem.PublicVisibility.Restricted(config.parentSpace.roomId), JoinRuleItem.PrivateVisibility.Restricted(config.parentSpace.roomId),
JoinRuleItem.PublicVisibility.AskToJoinRestricted(config.parentSpace.roomId).takeIf { isKnockFeatureEnabled }, JoinRuleItem.PrivateVisibility.AskToJoinRestricted(config.parentSpace.roomId).takeIf { isKnockFeatureEnabled },
JoinRuleItem.Private, JoinRuleItem.PrivateVisibility.Private,
) )
} else { } else {
listOfNotNull( listOfNotNull(
JoinRuleItem.PublicVisibility.Public, JoinRuleItem.PublicVisibility.Public,
JoinRuleItem.PublicVisibility.AskToJoin.takeIf { isKnockFeatureEnabled }, JoinRuleItem.PublicVisibility.AskToJoin.takeIf { isKnockFeatureEnabled },
JoinRuleItem.Private, JoinRuleItem.PrivateVisibility.Private,
) )
}, },
spaces: List<SpaceRoom> = emptyList(), spaces: List<SpaceRoom> = emptyList(),
eventSink: (ConfigureRoomEvents) -> Unit = { }, eventSink: (ConfigureRoomEvents) -> Unit = { },
) = ConfigureRoomState( ) = ConfigureRoomState(
config = config, config = config,
isSpace = isSpace,
avatarActions = avatarActions.toImmutableList(), avatarActions = avatarActions.toImmutableList(),
createRoomAction = createRoomAction, createRoomAction = createRoomAction,
cameraPermissionState = cameraPermissionState, cameraPermissionState = cameraPermissionState,

View file

@ -14,7 +14,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -76,7 +78,7 @@ fun ConfigureRoomView(
onCreateRoomSuccess: (RoomId) -> Unit, onCreateRoomSuccess: (RoomId) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val isSpace = state.config.isSpace val isSpace = state.isSpace
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val isAvatarActionsSheetVisible = remember { mutableStateOf(false) } val isAvatarActionsSheetVisible = remember { mutableStateOf(false) }
@ -105,7 +107,6 @@ fun ConfigureRoomView(
.imePadding() .imePadding()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.consumeWindowInsets(padding), .consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
RoomNameWithAvatar( RoomNameWithAvatar(
isSpace = isSpace, isSpace = isSpace,
@ -115,20 +116,20 @@ fun ConfigureRoomView(
onAvatarClick = ::onAvatarClick, onAvatarClick = ::onAvatarClick,
onChangeRoomName = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) }, onChangeRoomName = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) },
) )
Spacer(modifier = Modifier.height(16.dp))
RoomTopic( RoomTopic(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
topic = state.config.topic.orEmpty(), topic = state.config.topic.orEmpty(),
onTopicChange = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) }, onTopicChange = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
) )
Spacer(modifier = Modifier.height(16.dp))
if (!state.config.isSpace && state.spaces.isNotEmpty()) { if (!state.isSpace && state.spaces.isNotEmpty()) {
SelectParentSpaceOptions( SelectParentSpaceOptions(
spaces = state.spaces, spaces = state.spaces,
selectedSpace = state.config.parentSpace, selectedSpace = state.config.parentSpace,
onSelectSpace = { state.eventSink(ConfigureRoomEvents.SetParentSpace(it)) }, onSelectSpace = { state.eventSink(ConfigureRoomEvents.SetParentSpace(it)) },
) )
} }
RoomJoinRuleOptions( RoomJoinRuleOptions(
options = state.availableJoinRules, options = state.availableJoinRules,
selected = state.config.visibilityState.joinRuleItem, selected = state.config.visibilityState.joinRuleItem,
@ -138,20 +139,17 @@ fun ConfigureRoomView(
state.eventSink(ConfigureRoomEvents.JoinRuleChanged(it)) state.eventSink(ConfigureRoomEvents.JoinRuleChanged(it))
}, },
) )
if (state.config.visibilityState !is RoomVisibilityState.Private) { if (state.config.visibilityState !is RoomVisibilityState.Private) {
Column { ListSectionHeader(title = stringResource(R.string.screen_create_room_room_address_section_title))
ListSectionHeader(title = stringResource(R.string.screen_create_room_room_address_section_title)) RoomAddressField(
RoomAddressField( modifier = Modifier.padding(horizontal = 16.dp),
modifier = Modifier.padding(horizontal = 16.dp), address = state.config.visibilityState.roomAddress().getOrNull().orEmpty(),
address = state.config.visibilityState.roomAddress().getOrNull().orEmpty(), homeserverName = state.homeserverName,
homeserverName = state.homeserverName, addressValidity = state.roomAddressValidity,
addressValidity = state.roomAddressValidity, onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) },
onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) }, label = null,
label = null, supportingText = stringResource(R.string.screen_create_room_room_address_section_footer),
supportingText = stringResource(R.string.screen_create_room_room_address_section_footer), )
)
}
} }
} }
} }
@ -217,7 +215,9 @@ private fun RoomNameWithAvatar(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Box( Box(
modifier = Modifier.padding(end = 8.dp).size(AvatarSize.EditRoomDetails.dp), modifier = Modifier
.padding(end = 8.dp)
.size(AvatarSize.EditRoomDetails.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
val avatarState = remember(avatarUri) { val avatarState = remember(avatarUri) {
@ -272,12 +272,13 @@ private fun RoomTopic(
internal fun ConfigureRoomOptions( internal fun ConfigureRoomOptions(
title: String, title: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
hasDivider: Boolean = true,
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
Column( Column(
modifier = modifier.selectableGroup() modifier = modifier.selectableGroup()
) { ) {
ListSectionHeader(title = title) ListSectionHeader(title = title, hasDivider = hasDivider)
content() content()
} }
} }
@ -302,10 +303,10 @@ private fun RoomJoinRuleOptions(
size = RoundedIconAtomSize.Big, size = RoundedIconAtomSize.Big,
imageVector = when (item) { imageVector = when (item) {
JoinRuleItem.PublicVisibility.Public -> CompoundIcons.Public() JoinRuleItem.PublicVisibility.Public -> CompoundIcons.Public()
is JoinRuleItem.PublicVisibility.Restricted -> CompoundIcons.Space() is JoinRuleItem.PrivateVisibility.Restricted -> CompoundIcons.Space()
JoinRuleItem.PublicVisibility.AskToJoin, JoinRuleItem.PublicVisibility.AskToJoin,
is JoinRuleItem.PublicVisibility.AskToJoinRestricted -> CompoundIcons.UserAdd() is JoinRuleItem.PrivateVisibility.AskToJoinRestricted -> CompoundIcons.UserAdd()
JoinRuleItem.Private -> CompoundIcons.Lock() JoinRuleItem.PrivateVisibility.Private -> CompoundIcons.Lock()
}, },
tint = if (isSelected) ElementTheme.colors.iconPrimary else ElementTheme.colors.iconSecondary, tint = if (isSelected) ElementTheme.colors.iconPrimary else ElementTheme.colors.iconSecondary,
backgroundTint = Color.Transparent, backgroundTint = Color.Transparent,
@ -314,28 +315,28 @@ private fun RoomJoinRuleOptions(
headlineContent = { headlineContent = {
val title = when (item) { val title = when (item) {
JoinRuleItem.PublicVisibility.Public -> stringResource(R.string.screen_create_room_room_access_section_public_option_title) 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) 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 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) Text(text = title)
}, },
supportingContent = { supportingContent = {
val description = when (item) { val description = when (item) {
JoinRuleItem.PublicVisibility.Public -> stringResource(R.string.screen_create_room_room_access_section_public_option_description) 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, R.string.screen_create_room_room_access_section_restricted_option_description,
parentSpace?.displayName.orEmpty() parentSpace?.displayName.orEmpty()
) )
JoinRuleItem.PublicVisibility.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_description) 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, R.string.screen_create_room_room_access_section_knocking_restricted_option_description,
parentSpace?.displayName.orEmpty() 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) Text(text = description)
}, },

View file

@ -14,11 +14,10 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
data class CreateRoomConfig( data class CreateRoomConfig(
val isSpace: Boolean = false,
val roomName: String? = null, val roomName: String? = null,
val topic: String? = null, val topic: String? = null,
val avatarUri: String? = null, val avatarUri: String? = null,
val invites: ImmutableList<MatrixUser> = persistentListOf(), val invites: ImmutableList<MatrixUser> = persistentListOf(),
val visibilityState: RoomVisibilityState = RoomVisibilityState.Private(), val visibilityState: RoomVisibilityState = RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private),
val parentSpace: SpaceRoom? = null, val parentSpace: SpaceRoom? = null,
) )

View file

@ -72,7 +72,9 @@ class CreateRoomConfigStore(
createRoomConfigFlow.getAndUpdate { config -> createRoomConfigFlow.getAndUpdate { config ->
config.copy( config.copy(
visibilityState = when (joinRule) { visibilityState = when (joinRule) {
JoinRuleItem.Private -> RoomVisibilityState.Private() is JoinRuleItem.PrivateVisibility -> RoomVisibilityState.Private(
joinRuleItem = joinRule
)
is JoinRuleItem.PublicVisibility -> { is JoinRuleItem.PublicVisibility -> {
val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(config.roomName.orEmpty()) val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(config.roomName.orEmpty())
RoomVisibilityState.Public( RoomVisibilityState.Public(
@ -99,17 +101,16 @@ class CreateRoomConfigStore(
} }
} }
fun setIsSpace(isSpace: Boolean) { fun setParentSpace(parentSpace: SpaceRoom?, updateVisibility: Boolean) {
createRoomConfigFlow.getAndUpdate { config ->
config.copy(isSpace = isSpace)
}
}
fun setParentSpace(parentSpace: SpaceRoom?) {
createRoomConfigFlow.getAndUpdate { config -> createRoomConfigFlow.getAndUpdate { config ->
val visibilityState = if (parentSpace != null && updateVisibility) {
RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Restricted(parentSpace.roomId))
} else {
config.visibilityState
}
config.copy( config.copy(
parentSpace = parentSpace, parentSpace = parentSpace,
visibilityState = RoomVisibilityState.Private(), visibilityState = visibilityState
) )
} }
} }

View file

@ -18,7 +18,11 @@ import kotlinx.collections.immutable.persistentListOf
*/ */
@Immutable @Immutable
sealed interface JoinRuleItem { 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. * Those join rule items that represent public visibility of the room/space.
@ -27,18 +31,16 @@ sealed interface JoinRuleItem {
sealed interface PublicVisibility : JoinRuleItem { sealed interface PublicVisibility : JoinRuleItem {
data object Public : PublicVisibility data object Public : PublicVisibility
data object AskToJoin : 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]. * Transforms a [JoinRuleItem] option into a [JoinRule].
*/ */
fun toJoinRule(): JoinRule = when (this) { 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.Public -> JoinRule.Public
PublicVisibility.AskToJoin -> JoinRule.Knock PublicVisibility.AskToJoin -> JoinRule.Knock
is PublicVisibility.Restricted -> JoinRule.Restricted(persistentListOf(AllowRule.RoomMembership(parentSpaceId)))
is PublicVisibility.AskToJoinRestricted -> JoinRule.KnockRestricted(persistentListOf(AllowRule.RoomMembership(parentSpaceId)))
} }
} }

View file

@ -12,7 +12,7 @@ import java.util.Optional
sealed interface RoomVisibilityState { sealed interface RoomVisibilityState {
val joinRuleItem: JoinRuleItem 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( data class Public(
val roomAddress: RoomAddress, val roomAddress: RoomAddress,

View file

@ -8,7 +8,6 @@
package io.element.android.features.createroom.impl.configureroom package io.element.android.features.createroom.impl.configureroom
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -21,7 +20,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource 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.features.createroom.impl.R
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
@ -55,6 +53,7 @@ internal fun SelectParentSpaceOptions(
var displaySelectSpaceBottomSheet by remember { mutableStateOf(false) } var displaySelectSpaceBottomSheet by remember { mutableStateOf(false) }
ConfigureRoomOptions( ConfigureRoomOptions(
title = stringResource(CommonStrings.common_space), title = stringResource(CommonStrings.common_space),
hasDivider = false,
modifier = modifier modifier = modifier
) { ) {
ListItem( ListItem(
@ -62,22 +61,16 @@ internal fun SelectParentSpaceOptions(
Text( Text(
text = selectedSpace?.displayName text = selectedSpace?.displayName
?: stringResource(R.string.screen_create_room_space_selection_no_space_title), ?: stringResource(R.string.screen_create_room_space_selection_no_space_title),
maxLines = 1 maxLines = 1,
color = ElementTheme.colors.textPrimary
) )
}, },
supportingContent = { supportingContent = selectedSpace?.canonicalAlias?.let { alias ->
Text( {
text = if (selectedSpace != null) { Text(text = alias.value, maxLines = 1)
selectedSpace.canonicalAlias?.value.orEmpty() }
} else {
stringResource(R.string.screen_create_room_space_selection_no_space_description)
},
maxLines = 1
)
}, },
leadingContent = if (selectedSpace == null) { leadingContent = selectedSpace?.let {
ListItemContent.Icon(IconSource.Vector(CompoundIcons.Home()))
} else {
ListItemContent.Custom({ ListItemContent.Custom({
val avatarData = AvatarData( val avatarData = AvatarData(
id = selectedSpace.roomId.value, id = selectedSpace.roomId.value,
@ -119,7 +112,7 @@ internal fun SelectParentSpaceOptions(
} }
@Composable @Composable
private fun ColumnScope.SelectParentSpaceBottomSheet( private fun SelectParentSpaceBottomSheet(
spaces: ImmutableList<SpaceRoom>, spaces: ImmutableList<SpaceRoom>,
selectedSpace: SpaceRoom?, selectedSpace: SpaceRoom?,
onSelectSpace: (SpaceRoom?) -> Unit, onSelectSpace: (SpaceRoom?) -> Unit,
@ -133,19 +126,10 @@ private fun ColumnScope.SelectParentSpaceBottomSheet(
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( 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 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( trailingContent = ListItemContent.RadioButton(
selected = selectedSpace == null selected = selectedSpace == null
), ),
@ -157,29 +141,31 @@ private fun ColumnScope.SelectParentSpaceBottomSheet(
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
space.displayName, text = space.displayName,
maxLines = 1 maxLines = 1
) )
}, },
supportingContent = { supportingContent = space.canonicalAlias?.let { alias ->
Text( {
space.canonicalAlias?.value.orEmpty(), Text(
maxLines = 1 text = alias.value,
) maxLines = 1
)
}
}, },
leadingContent = ListItemContent.Custom({ leadingContent = ListItemContent.Custom({
val avatarData = val avatarData =
AvatarData( AvatarData(
id = space.roomId.value, id = space.roomId.value,
name = space.displayName, name = space.displayName,
url = space.avatarUrl, url = space.avatarUrl,
size = AvatarSize.SelectParentSpace, size = AvatarSize.SelectParentSpace,
)
Avatar(
avatarData = avatarData,
avatarType = AvatarType.Space()
) )
}), Avatar(
avatarData = avatarData,
avatarType = AvatarType.Space()
)
}),
trailingContent = ListItemContent.RadioButton( trailingContent = ListItemContent.RadioButton(
selected = selectedSpace == space selected = selectedSpace == space
), ),
@ -201,7 +187,8 @@ internal fun SelectParentSpaceBottomSheetPreview() =
canonicalAlias = RoomAlias( canonicalAlias = RoomAlias(
"#a-room-alias:example.org" "#a-room-alias:example.org"
) )
) ),
aSpaceRoom()
), ),
selectedSpace = null, selectedSpace = null,
) {} ) {}

View file

@ -3,16 +3,33 @@
<string name="screen_create_room_action_create_room">"Nová místnost"</string> <string name="screen_create_room_action_create_room">"Nová místnost"</string>
<string name="screen_create_room_add_people_title">"Pozvat přátele"</string> <string name="screen_create_room_add_people_title">"Pozvat přátele"</string>
<string name="screen_create_room_error_creating_room">"Při vytváření místnosti došlo k chybě"</string> <string name="screen_create_room_error_creating_room">"Při vytváření místnosti došlo k chybě"</string>
<string name="screen_create_room_private_option_description">"Do této místnosti mají přístup pouze pozvaní lidé. Všechny zprávy jsou koncově šifrovány."</string> <string name="screen_create_room_error_creating_space">"Prostor se nepodařilo vytvořit kvůli neznámé chybě. Zkuste to znovu později."</string>
<string name="screen_create_room_name_placeholder">"Přidat název…"</string>
<string name="screen_create_room_new_room_title">"Nová místnost"</string>
<string name="screen_create_room_new_space_title">"Nový prostor"</string>
<string name="screen_create_room_private_option_description">"Do této místnosti mohou vstoupit pouze pozvaní."</string>
<string name="screen_create_room_private_option_title">"Soukromý"</string>
<string name="screen_create_room_public_option_description">"Tuto místnost může najít kdokoli. <string name="screen_create_room_public_option_description">"Tuto místnost může najít kdokoli.
To můžete kdykoli změnit v nastavení místnosti."</string> To můžete kdykoli změnit v nastavení místnosti."</string>
<string name="screen_create_room_public_option_short_description">"Vstoupit může kdokoli."</string>
<string name="screen_create_room_public_option_title">"Veřejná místnost"</string> <string name="screen_create_room_public_option_title">"Veřejná místnost"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Kdokoli může požádat o vstup do místnosti, ale správce nebo moderátor bude muset žádost přijmout"</string> <string name="screen_create_room_room_access_section_knocking_option_description">"Kdokoli může požádat o vstup do místnosti, ale správce nebo moderátor bude muset žádost přijmout."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Požádat o připojení"</string> <string name="screen_create_room_room_access_section_knocking_option_title">"Povolit žádost o vstup"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Do této místnosti může vstoupit kdokoli"</string> <string name="screen_create_room_room_access_section_knocking_restricted_option_description">"Kdokoli v %1$s může vstoupit, ale všichni ostatní si musí o přístup požádat."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Požádat o vstup"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Vstoupit mohou pouze pozvaní lidé."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Soukromý"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Vstoupit může kdokoli."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Kdokoliv"</string> <string name="screen_create_room_room_access_section_public_option_title">"Kdokoliv"</string>
<string name="screen_create_room_room_address_section_footer">"Aby byla tato místnost viditelná v adresáři veřejných místností, budete potřebovat adresu místnosti."</string> <string name="screen_create_room_room_access_section_restricted_option_description">"Kdokoli může vstoupit do %1$s."</string>
<string name="screen_create_room_room_address_section_title">"Adresa místnosti"</string> <string name="screen_create_room_room_access_section_restricted_option_title">"Standard"</string>
<string name="screen_create_room_room_access_section_title">"Kdo má přístup"</string>
<string name="screen_create_room_room_address_section_footer">"Budete potřebovat adresu, aby se zobrazovala ve veřejném adresáři."</string>
<string name="screen_create_room_room_address_section_title">"Adresa"</string>
<string name="screen_create_room_room_visibility_section_title">"Viditelnost místnosti"</string> <string name="screen_create_room_room_visibility_section_title">"Viditelnost místnosti"</string>
<string name="screen_create_room_space_selection_no_space_description">"(bez prostoru)"</string>
<string name="screen_create_room_space_selection_no_space_title">"Domov"</string>
<string name="screen_create_room_space_selection_sheet_title">"Přidat do prostoru"</string>
<string name="screen_create_room_topic_label">"Téma (nepovinné)"</string> <string name="screen_create_room_topic_label">"Téma (nepovinné)"</string>
<string name="screen_create_room_topic_placeholder">"Přidat popis…"</string>
</resources> </resources>

View file

@ -28,6 +28,7 @@ Du kannst dies jederzeit in den Einstellungen des Chats ändern."</string>
<string name="screen_create_room_room_address_section_title">"Adresse"</string> <string name="screen_create_room_room_address_section_title">"Adresse"</string>
<string name="screen_create_room_room_visibility_section_title">" Sichtbarkeit des Chats"</string> <string name="screen_create_room_room_visibility_section_title">" Sichtbarkeit des Chats"</string>
<string name="screen_create_room_space_selection_no_space_description">"(kein Space)"</string> <string name="screen_create_room_space_selection_no_space_description">"(kein Space)"</string>
<string name="screen_create_room_space_selection_no_space_title">"Home"</string>
<string name="screen_create_room_space_selection_sheet_title">"Space hinzufügen"</string> <string name="screen_create_room_space_selection_sheet_title">"Space hinzufügen"</string>
<string name="screen_create_room_topic_label">"Thema (optional)"</string> <string name="screen_create_room_topic_label">"Thema (optional)"</string>
<string name="screen_create_room_topic_placeholder">"Beschreibung hinzufügen…"</string> <string name="screen_create_room_topic_placeholder">"Beschreibung hinzufügen…"</string>

View file

@ -3,16 +3,33 @@
<string name="screen_create_room_action_create_room">"Uus jututuba"</string> <string name="screen_create_room_action_create_room">"Uus jututuba"</string>
<string name="screen_create_room_add_people_title">"Kutsu osalejaid"</string> <string name="screen_create_room_add_people_title">"Kutsu osalejaid"</string>
<string name="screen_create_room_error_creating_room">"Jututoa loomisel tekkis viga"</string> <string name="screen_create_room_error_creating_room">"Jututoa loomisel tekkis viga"</string>
<string name="screen_create_room_private_option_description">"Ligipääs siia jututuppa on vaid kutse alusel. Kõik sõnumid siin jututoas on läbivalt krüptitud."</string> <string name="screen_create_room_error_creating_space">"Kogukonda polnud tundmatu vea tõttu võimalik luua. Palun proovi hiljem uuesti."</string>
<string name="screen_create_room_name_placeholder">"Sisesta nimi…"</string>
<string name="screen_create_room_new_room_title">"Uus jututuba"</string>
<string name="screen_create_room_new_space_title">"Uus kogukond"</string>
<string name="screen_create_room_private_option_description">"Ligipääs siia jututuppa on vaid kutse alusel."</string>
<string name="screen_create_room_private_option_title">"Privaatne"</string>
<string name="screen_create_room_public_option_description">"Kõik saavad seda jututuba leida. <string name="screen_create_room_public_option_description">"Kõik saavad seda jututuba leida.
Sa võid seda jututoa seadistustest alati muuta."</string> Sa võid seda jututoa seadistustest alati muuta."</string>
<string name="screen_create_room_public_option_short_description">"Kõik võivad selle jututoaga liituda."</string>
<string name="screen_create_room_public_option_title">"Avalik jututuba"</string> <string name="screen_create_room_public_option_title">"Avalik jututuba"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Kõik võivad paluda selle jututoaga liitumist, kuid peakasutaja või moderaator peavad selle kinnitama"</string> <string name="screen_create_room_room_access_section_knocking_option_description">"Kõik võivad paluda selle jututoaga liitumist, kuid peakasutaja või moderaator peavad selle kinnitama."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Küsi võimalust liitumiseks"</string> <string name="screen_create_room_room_access_section_knocking_option_title">"Luba küsida liitumisvõimalust"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Kõik võivad selle jututoaga liituda"</string> <string name="screen_create_room_room_access_section_knocking_restricted_option_description">"Kõik „%1$s“ kogukonna liikmed võivad liituda, kuid kõik teised peavad liitumiseks küsima luba."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Küsi võimalust liitumiseks"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Ligipääs siia jututuppa on vaid kutse alusel."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Privaatne"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Kõik võivad selle jututoaga liituda."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Kõik kasutajad"</string> <string name="screen_create_room_room_access_section_public_option_title">"Kõik kasutajad"</string>
<string name="screen_create_room_room_address_section_footer">"Selleks, et see jututuba oleks nähtav jututubade avalikus kataloogis, sa vajad jututoa aadressi."</string> <string name="screen_create_room_room_access_section_restricted_option_description">"Liituda võivad kõik „%1$s“ kogukonna liikmed."</string>
<string name="screen_create_room_room_address_section_title">"Jututoa aadress"</string> <string name="screen_create_room_room_access_section_restricted_option_title">"Standardne"</string>
<string name="screen_create_room_room_access_section_title">"Kellel on ligipääs"</string>
<string name="screen_create_room_room_address_section_footer">"Selleks, et jututuba oleks nähtav jututubade avalikus kataloogis, vajab ta aadressi."</string>
<string name="screen_create_room_room_address_section_title">"Aadress"</string>
<string name="screen_create_room_room_visibility_section_title">"Jututoa nähtavus"</string> <string name="screen_create_room_room_visibility_section_title">"Jututoa nähtavus"</string>
<string name="screen_create_room_space_selection_no_space_description">"(kogukonda pole)"</string>
<string name="screen_create_room_space_selection_no_space_title">"Avaleht"</string>
<string name="screen_create_room_space_selection_sheet_title">"Lisa kogukonda"</string>
<string name="screen_create_room_topic_label">"Teema (kui soovid lisada)"</string> <string name="screen_create_room_topic_label">"Teema (kui soovid lisada)"</string>
<string name="screen_create_room_topic_placeholder">"Lisa kirjeldus…"</string>
</resources> </resources>

View file

@ -20,7 +20,7 @@ Vous pouvez modifier cela à tout moment dans les paramètres du salon."</string
<string name="screen_create_room_room_access_section_private_option_description">"Seules les personnes invitées peuvent joindre."</string> <string name="screen_create_room_room_access_section_private_option_description">"Seules les personnes invitées peuvent joindre."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Privé"</string> <string name="screen_create_room_room_access_section_private_option_title">"Privé"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Tout le monde peut joindre"</string> <string name="screen_create_room_room_access_section_public_option_description">"Tout le monde peut joindre"</string>
<string name="screen_create_room_room_access_section_public_option_title">"Tout le monde"</string> <string name="screen_create_room_room_access_section_public_option_title">"Public"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Toute membre de %1$s peut joindre le salon."</string> <string name="screen_create_room_room_access_section_restricted_option_description">"Toute membre de %1$s peut joindre le salon."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Standard"</string> <string name="screen_create_room_room_access_section_restricted_option_title">"Standard"</string>
<string name="screen_create_room_room_access_section_title">"Qui a accès"</string> <string name="screen_create_room_room_access_section_title">"Qui a accès"</string>
@ -28,7 +28,8 @@ Vous pouvez modifier cela à tout moment dans les paramètres du salon."</string
<string name="screen_create_room_room_address_section_title">"Adresse"</string> <string name="screen_create_room_room_address_section_title">"Adresse"</string>
<string name="screen_create_room_room_visibility_section_title">"Visibilité du salon"</string> <string name="screen_create_room_room_visibility_section_title">"Visibilité du salon"</string>
<string name="screen_create_room_space_selection_no_space_description">"(pas despace)"</string> <string name="screen_create_room_space_selection_no_space_description">"(pas despace)"</string>
<string name="screen_create_room_space_selection_no_space_title">"Accueil"</string> <string name="screen_create_room_space_selection_no_space_option">"Ne pas ajouter à un espace"</string>
<string name="screen_create_room_space_selection_no_space_title">"Aucun espace sélectionné"</string>
<string name="screen_create_room_space_selection_sheet_title">"Ajouter à lespace"</string> <string name="screen_create_room_space_selection_sheet_title">"Ajouter à lespace"</string>
<string name="screen_create_room_topic_label">"Sujet (facultatif)"</string> <string name="screen_create_room_topic_label">"Sujet (facultatif)"</string>
<string name="screen_create_room_topic_placeholder">"Ajouter une description…"</string> <string name="screen_create_room_topic_placeholder">"Ajouter une description…"</string>

View file

@ -3,17 +3,25 @@
<string name="screen_create_room_action_create_room">"Nytt rom"</string> <string name="screen_create_room_action_create_room">"Nytt rom"</string>
<string name="screen_create_room_add_people_title">"Inviter folk"</string> <string name="screen_create_room_add_people_title">"Inviter folk"</string>
<string name="screen_create_room_error_creating_room">"Det oppsto en feil under opprettelsen av rommet"</string> <string name="screen_create_room_error_creating_room">"Det oppsto en feil under opprettelsen av rommet"</string>
<string name="screen_create_room_name_placeholder">"Legg til navn…"</string>
<string name="screen_create_room_new_room_title">"Nytt rom"</string>
<string name="screen_create_room_new_space_title">"Nytt område"</string>
<string name="screen_create_room_private_option_description">"Bare inviterte personer kan bli med."</string> <string name="screen_create_room_private_option_description">"Bare inviterte personer kan bli med."</string>
<string name="screen_create_room_private_option_title">"Privat"</string> <string name="screen_create_room_private_option_title">"Privat"</string>
<string name="screen_create_room_public_option_description">"Alle kan finne dette rommet. <string name="screen_create_room_public_option_description">"Alle kan finne dette rommet.
Du kan endre dette når som helst i rominnstillingene."</string> Du kan endre dette når som helst i rominnstillingene."</string>
<string name="screen_create_room_public_option_short_description">"Alle kan bli med."</string>
<string name="screen_create_room_public_option_title">"Offentlig rom"</string> <string name="screen_create_room_public_option_title">"Offentlig rom"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Alle kan be om å få bli med, men en administrator eller moderator må godta forespørselen."</string> <string name="screen_create_room_room_access_section_knocking_option_description">"Alle kan be om å få bli med, men en administrator eller moderator må godta forespørselen."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Be om å bli med"</string> <string name="screen_create_room_room_access_section_knocking_option_title">"Be om å bli med"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Bare inviterte personer kan bli med."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Privat"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Alle kan bli med."</string> <string name="screen_create_room_room_access_section_public_option_description">"Alle kan bli med."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Alle"</string> <string name="screen_create_room_room_access_section_public_option_title">"Alle"</string>
<string name="screen_create_room_room_access_section_title">"Hvem har tilgang"</string>
<string name="screen_create_room_room_address_section_footer">"Du trenger en adresse for å gjøre den synlig i den offentlige katalogen."</string> <string name="screen_create_room_room_address_section_footer">"Du trenger en adresse for å gjøre den synlig i den offentlige katalogen."</string>
<string name="screen_create_room_room_address_section_title">"Adresse"</string> <string name="screen_create_room_room_address_section_title">"Adresse"</string>
<string name="screen_create_room_room_visibility_section_title">"Romsynlighet"</string> <string name="screen_create_room_room_visibility_section_title">"Romsynlighet"</string>
<string name="screen_create_room_topic_label">"Emne (valgfritt)"</string> <string name="screen_create_room_topic_label">"Emne (valgfritt)"</string>
<string name="screen_create_room_topic_placeholder">"Legg til beskrivelse…"</string>
</resources> </resources>

View file

@ -3,16 +3,16 @@
<string name="screen_create_room_action_create_room">"Nytt rum"</string> <string name="screen_create_room_action_create_room">"Nytt rum"</string>
<string name="screen_create_room_add_people_title">"Bjud in personer"</string> <string name="screen_create_room_add_people_title">"Bjud in personer"</string>
<string name="screen_create_room_error_creating_room">"Ett fel uppstod när rummet skapades"</string> <string name="screen_create_room_error_creating_room">"Ett fel uppstod när rummet skapades"</string>
<string name="screen_create_room_private_option_description">"Endast inbjudna personer har tillgång till detta rum. Alla meddelanden är totalsträckskrypterade."</string> <string name="screen_create_room_private_option_description">"Endast inbjudna personer kan gå med."</string>
<string name="screen_create_room_public_option_description">"Vem som helst kan hitta det här rummet. <string name="screen_create_room_public_option_description">"Vem som helst kan hitta det här rummet.
Du kan ändra detta när som helst i rumsinställningarna."</string> Du kan ändra detta när som helst i rumsinställningarna."</string>
<string name="screen_create_room_public_option_title">"Offentligt rum"</string> <string name="screen_create_room_public_option_title">"Offentligt rum"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Vem som helst kan be om att gå med i rummet men en administratör eller en moderator måste acceptera begäran"</string> <string name="screen_create_room_room_access_section_knocking_option_description">"Vem som helst kan be om att gå med men en administratör eller en moderator måste acceptera begäran"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Be om att gå med"</string> <string name="screen_create_room_room_access_section_knocking_option_title">"Tillåt att be om att gå med"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Vem som helst kan gå med i det här rummet"</string> <string name="screen_create_room_room_access_section_public_option_description">"Vem som helst kan gå med."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Vem som helst"</string> <string name="screen_create_room_room_access_section_public_option_title">"Offentligt"</string>
<string name="screen_create_room_room_address_section_footer">"För att detta rum ska vara synligt i den allmänna rumskatalogen behöver du en rumsadress."</string> <string name="screen_create_room_room_address_section_footer">"Du behöver en adress för att den ska synas i den offentliga katalogen."</string>
<string name="screen_create_room_room_address_section_title">"Rumsadress"</string> <string name="screen_create_room_room_address_section_title">"Adress"</string>
<string name="screen_create_room_room_visibility_section_title">"Rumssynlighet"</string> <string name="screen_create_room_room_visibility_section_title">"Rumssynlighet"</string>
<string name="screen_create_room_topic_label">"Ämne (valfritt)"</string> <string name="screen_create_room_topic_label">"Ämne (valfritt)"</string>
</resources> </resources>

View file

@ -7,8 +7,6 @@
<string name="screen_create_room_name_placeholder">"Add name…"</string> <string name="screen_create_room_name_placeholder">"Add name…"</string>
<string name="screen_create_room_new_room_title">"New room"</string> <string name="screen_create_room_new_room_title">"New room"</string>
<string name="screen_create_room_new_space_title">"New space"</string> <string name="screen_create_room_new_space_title">"New space"</string>
<string name="screen_create_room_parent_space_home_description">"(no space)"</string>
<string name="screen_create_room_parent_space_home_title">"Home"</string>
<string name="screen_create_room_private_option_description">"Only people invited can join."</string> <string name="screen_create_room_private_option_description">"Only people invited can join."</string>
<string name="screen_create_room_private_option_title">"Private"</string> <string name="screen_create_room_private_option_title">"Private"</string>
<string name="screen_create_room_public_option_description">"Anyone can find this room. <string name="screen_create_room_public_option_description">"Anyone can find this room.
@ -30,7 +28,8 @@ You can change this anytime in room settings."</string>
<string name="screen_create_room_room_address_section_title">"Address"</string> <string name="screen_create_room_room_address_section_title">"Address"</string>
<string name="screen_create_room_room_visibility_section_title">"Room visibility"</string> <string name="screen_create_room_room_visibility_section_title">"Room visibility"</string>
<string name="screen_create_room_space_selection_no_space_description">"(no space)"</string> <string name="screen_create_room_space_selection_no_space_description">"(no space)"</string>
<string name="screen_create_room_space_selection_no_space_title">"Home"</string> <string name="screen_create_room_space_selection_no_space_option">"Do not add to a space"</string>
<string name="screen_create_room_space_selection_no_space_title">"No space selected"</string>
<string name="screen_create_room_space_selection_sheet_title">"Add to space"</string> <string name="screen_create_room_space_selection_sheet_title">"Add to space"</string>
<string name="screen_create_room_topic_label">"Topic (optional)"</string> <string name="screen_create_room_topic_label">"Topic (optional)"</string>
<string name="screen_create_room_topic_placeholder">"Add description…"</string> <string name="screen_create_room_topic_placeholder">"Add description…"</string>

View file

@ -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.RoomInfo
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias 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.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.RoomPowerLevels
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_AVATAR_URL
@ -89,7 +90,7 @@ class ConfigureRoomPresenterTest {
assertThat(initialState.config.topic).isNull() assertThat(initialState.config.topic).isNull()
assertThat(initialState.config.invites).isEmpty() assertThat(initialState.config.invites).isEmpty()
assertThat(initialState.config.avatarUri).isNull() 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.createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(initialState.homeserverName).isEqualTo("matrix.org") 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 { 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<RoomId, RoomId, Result<Unit>> { _, _ -> Result.success(Unit) } val addChildToSpaceResult = lambdaRecorder<RoomId, RoomId, Result<Unit>> { _, _ -> Result.success(Unit) }
val spaceService = FakeSpaceService( val spaceService = FakeSpaceService(
editableSpacesResult = { Result.success(emptyList()) },
addChildToSpaceResult = addChildToSpaceResult, addChildToSpaceResult = addChildToSpaceResult,
) )
val roomInfoFlow = MutableStateFlow<Optional<RoomInfo>>(Optional.empty()) val roomInfoFlow = MutableStateFlow<Optional<RoomInfo>>(Optional.empty())
@ -234,7 +236,8 @@ class ConfigureRoomPresenterTest {
matrixClient.givenCreateRoomResult(createRoomResult) 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)) initialState.eventSink(ConfigureRoomEvents.SetParentSpace(parentSpace))
assertThat(awaitItem().config.parentSpace).isEqualTo(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 { fun `present - creating a room and adding it into a parent space works when all the data is available`() = runTest {
val addChildToSpaceResult = lambdaRecorder<RoomId, RoomId, Result<Unit>> { _, _ -> Result.success(Unit) } val addChildToSpaceResult = lambdaRecorder<RoomId, RoomId, Result<Unit>> { _, _ -> Result.success(Unit) }
val spaceService = FakeSpaceService( val spaceService = FakeSpaceService(
editableSpacesResult = { Result.success(emptyList()) },
addChildToSpaceResult = addChildToSpaceResult, addChildToSpaceResult = addChildToSpaceResult,
) )
val roomInfoFlow = MutableStateFlow<Optional<RoomInfo>>(Optional.empty()) val roomInfoFlow = MutableStateFlow<Optional<RoomInfo>>(Optional.empty())
@ -275,7 +279,8 @@ class ConfigureRoomPresenterTest {
matrixClient.givenCreateRoomResult(createRoomResult) 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)) initialState.eventSink(ConfigureRoomEvents.SetParentSpace(parentSpace))
assertThat(awaitItem().config.parentSpace).isEqualTo(parentSpace) assertThat(awaitItem().config.parentSpace).isEqualTo(parentSpace)
@ -484,16 +489,19 @@ class ConfigureRoomPresenterTest {
assertThat(awaitItem().config.visibilityState).isInstanceOf(RoomVisibilityState.Public::class.java) assertThat(awaitItem().config.visibilityState).isInstanceOf(RoomVisibilityState.Public::class.java)
// Then check changing the parent space resets it to private // 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())) 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 // If we change the join rule back to public
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.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)) 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( private fun createMatrixClient(
isAliasAvailable: Boolean = true, isAliasAvailable: Boolean = true,
spaceService: FakeSpaceService = FakeSpaceService(), spaceService: FakeSpaceService = FakeSpaceService(
editableSpacesResult = { Result.success(emptyList()) }
),
) = FakeMatrixClient( ) = FakeMatrixClient(
userIdServerNameLambda = { "matrix.org" }, userIdServerNameLambda = { "matrix.org" },
resolveRoomAliasResult = { resolveRoomAliasResult = {

View file

@ -18,12 +18,12 @@ import org.junit.Test
class JoinRuleItemTest { class JoinRuleItemTest {
@Test @Test
fun `toJoinRule works as expected`() { 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.Public.toJoinRule()).isEqualTo(JoinRule.Public)
assertThat(JoinRuleItem.PublicVisibility.AskToJoin.toJoinRule()).isEqualTo(JoinRule.Knock) 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)))) .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)))) .isEqualTo(JoinRule.KnockRestricted(persistentListOf(AllowRule.RoomMembership(A_ROOM_ID))))
} }
} }

View file

@ -10,7 +10,7 @@ package io.element.android.features.home.impl
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
sealed interface HomeEvents { sealed interface HomeEvent {
data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvents data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent
data class SwitchToAccount(val sessionId: SessionId) : HomeEvents data class SwitchToAccount(val sessionId: SessionId) : HomeEvent
} }

View file

@ -34,7 +34,7 @@ import io.element.android.annotations.ContributesNode
import io.element.android.features.home.api.HomeEntryPoint import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.home.impl.components.RoomListMenuAction 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.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.InviteData
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView
import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint
@ -159,7 +159,7 @@ class HomeFlowNode(
} }
private fun onNewOwnersSelected(roomId: RoomId) { 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 { private fun rootNode(buildContext: BuildContext): Node {

View file

@ -80,15 +80,15 @@ class HomePresenter(
val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator() val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator()
val directLogoutState = logoutPresenter.present() val directLogoutState = logoutPresenter.present()
fun handleEvent(event: HomeEvents) { fun handleEvent(event: HomeEvent) {
when (event) { when (event) {
is HomeEvents.SelectHomeNavigationBarItem -> coroutineState.launch { is HomeEvent.SelectHomeNavigationBarItem -> coroutineState.launch {
if (event.item == HomeNavigationBarItem.Spaces) { if (event.item == HomeNavigationBarItem.Spaces) {
announcementService.showAnnouncement(Announcement.Space) announcementService.showAnnouncement(Announcement.Space)
} }
currentHomeNavigationBarItemOrdinal = event.item.ordinal currentHomeNavigationBarItemOrdinal = event.item.ordinal
} }
is HomeEvents.SwitchToAccount -> coroutineState.launch { is HomeEvent.SwitchToAccount -> coroutineState.launch {
sessionStore.setLatestSession(event.sessionId.value) sessionStore.setLatestSession(event.sessionId.value)
} }
} }

View file

@ -9,6 +9,7 @@
package io.element.android.features.home.impl package io.element.android.features.home.impl
import io.element.android.features.home.impl.roomlist.RoomListState 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.home.impl.spaces.HomeSpacesState
import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@ -29,8 +30,9 @@ data class HomeState(
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
val canReportBug: Boolean, val canReportBug: Boolean,
val directLogoutState: DirectLogoutState, 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 displayActions = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats
val displayRoomListFilters = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats && roomListState.displayFilters val displayRoomListFilters = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats && roomListState.displayFilters
val showNavigationBar = homeSpacesState.canCreateSpaces || homeSpacesState.spaceRooms.isNotEmpty() val showNavigationBar = homeSpacesState.canCreateSpaces || homeSpacesState.spaceRooms.isNotEmpty()

View file

@ -59,7 +59,7 @@ internal fun aHomeState(
homeSpacesState: HomeSpacesState = aHomeSpacesState(), homeSpacesState: HomeSpacesState = aHomeSpacesState(),
canReportBug: Boolean = true, canReportBug: Boolean = true,
directLogoutState: DirectLogoutState = aDirectLogoutState(), directLogoutState: DirectLogoutState = aDirectLogoutState(),
eventSink: (HomeEvents) -> Unit = {} eventSink: (HomeEvent) -> Unit = {}
) = HomeState( ) = HomeState(
currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(), currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(),
showAvatarIndicator = showAvatarIndicator, showAvatarIndicator = showAvatarIndicator,

View file

@ -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.model.RoomListRoomSummary
import io.element.android.features.home.impl.roomlist.RoomListContextMenu 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.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.roomlist.RoomListState
import io.element.android.features.home.impl.search.RoomListSearchView 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.features.home.impl.spaces.HomeSpacesView
import io.element.android.libraries.androidutils.throttler.FirstThrottler import io.element.android.libraries.androidutils.throttler.FirstThrottler
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -153,10 +156,15 @@ private fun HomeScaffold(
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
val roomListState: RoomListState = state.roomListState val roomListState: RoomListState = state.roomListState
BackHandler( BackHandler(enabled = state.isBackHandlerEnabled) {
enabled = state.currentHomeNavigationBarItem != HomeNavigationBarItem.Chats, if (state.currentHomeNavigationBarItem != HomeNavigationBarItem.Chats) {
) { state.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Chats))
state.eventSink(HomeEvents.SelectHomeNavigationBarItem(HomeNavigationBarItem.Chats)) } else {
val spaceFiltersState = state.roomListState.spaceFiltersState
if (spaceFiltersState is SpaceFiltersState.Selected) {
spaceFiltersState.eventSink(SpaceFiltersEvent.Selected.ClearSelection)
}
}
} }
val hazeState = rememberHazeState() val hazeState = rememberHazeState()
@ -168,20 +176,20 @@ private fun HomeScaffold(
topBar = { topBar = {
HomeTopBar( HomeTopBar(
selectedNavigationItem = state.currentHomeNavigationBarItem, selectedNavigationItem = state.currentHomeNavigationBarItem,
title = stringResource(state.currentHomeNavigationBarItem.labelRes),
currentUserAndNeighbors = state.currentUserAndNeighbors, currentUserAndNeighbors = state.currentUserAndNeighbors,
showAvatarIndicator = state.showAvatarIndicator, showAvatarIndicator = state.showAvatarIndicator,
areSearchResultsDisplayed = roomListState.searchState.isSearchActive, areSearchResultsDisplayed = roomListState.searchState.isSearchActive,
onToggleSearch = { roomListState.eventSink(RoomListEvents.ToggleSearchResults) }, onToggleSearch = { roomListState.eventSink(RoomListEvent.ToggleSearchResults) },
onMenuActionClick = onMenuActionClick, onMenuActionClick = onMenuActionClick,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onAccountSwitch = { onAccountSwitch = {
state.eventSink(HomeEvents.SwitchToAccount(it)) state.eventSink(HomeEvent.SwitchToAccount(it))
}, },
onCreateSpace = onCreateSpaceClick, onCreateSpace = onCreateSpaceClick,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
displayFilters = state.displayRoomListFilters, displayFilters = state.displayRoomListFilters,
filtersState = roomListState.filtersState, filtersState = roomListState.filtersState,
spaceFiltersState = roomListState.spaceFiltersState,
canCreateSpaces = state.homeSpacesState.canCreateSpaces, canCreateSpaces = state.homeSpacesState.canCreateSpaces,
canReportBug = state.canReportBug, canReportBug = state.canReportBug,
modifier = Modifier.hazeEffect( modifier = Modifier.hazeEffect(
@ -211,7 +219,7 @@ private fun HomeScaffold(
lazyListStateTarget.animateScrollToItem(0) lazyListStateTarget.animateScrollToItem(0)
} }
} else { } else {
state.eventSink(HomeEvents.SelectHomeNavigationBarItem(item)) state.eventSink(HomeEvent.SelectHomeNavigationBarItem(item))
} }
}, },
modifier = Modifier.hazeEffect( modifier = Modifier.hazeEffect(
@ -227,6 +235,7 @@ private fun HomeScaffold(
RoomListContentView( RoomListContentView(
contentState = roomListState.contentState, contentState = roomListState.contentState,
filtersState = roomListState.filtersState, filtersState = roomListState.filtersState,
spaceFiltersState = roomListState.spaceFiltersState,
lazyListState = roomsLazyListState, lazyListState = roomsLazyListState,
hideInvitesAvatars = roomListState.hideInvitesAvatars, hideInvitesAvatars = roomListState.hideInvitesAvatars,
eventSink = roomListState.eventSink, eventSink = roomListState.eventSink,
@ -256,6 +265,7 @@ private fun HomeScaffold(
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
.hazeSource(state = hazeState) .hazeSource(state = hazeState)
) )
SpaceFiltersView(roomListState.spaceFiltersState)
} }
HomeNavigationBarItem.Spaces -> { HomeNavigationBarItem.Spaces -> {
HomeSpacesView( HomeSpacesView(

View file

@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.pager.VerticalPager import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberTopAppBarState 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.RoomListFiltersState
import io.element.android.features.home.impl.filters.RoomListFiltersView 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.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.atomic.atoms.RedIndicatorAtom
import io.element.android.libraries.designsystem.components.TopAppBarScrollBehaviorLayout import io.element.android.libraries.designsystem.components.TopAppBarScrollBehaviorLayout
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -75,7 +80,6 @@ import kotlinx.collections.immutable.toImmutableList
@Composable @Composable
fun HomeTopBar( fun HomeTopBar(
selectedNavigationItem: HomeNavigationBarItem, selectedNavigationItem: HomeNavigationBarItem,
title: String,
currentUserAndNeighbors: ImmutableList<MatrixUser>, currentUserAndNeighbors: ImmutableList<MatrixUser>,
showAvatarIndicator: Boolean, showAvatarIndicator: Boolean,
areSearchResultsDisplayed: Boolean, areSearchResultsDisplayed: Boolean,
@ -89,6 +93,7 @@ fun HomeTopBar(
canReportBug: Boolean, canReportBug: Boolean,
displayFilters: Boolean, displayFilters: Boolean,
filtersState: RoomListFiltersState, filtersState: RoomListFiltersState,
spaceFiltersState: SpaceFiltersState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier) { Column(modifier) {
@ -103,12 +108,21 @@ fun HomeTopBar(
scrolledContainerColor = Color.Transparent, scrolledContainerColor = Color.Transparent,
), ),
title = { 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( Text(
modifier = Modifier.semantics { modifier = Modifier.semantics {
heading() heading()
}, },
style = ElementTheme.typography.aliasScreenTitle, style = ElementTheme.typography.aliasScreenTitle,
text = title, text = displayTitle,
) )
}, },
navigationIcon = { navigationIcon = {
@ -124,7 +138,8 @@ fun HomeTopBar(
HomeNavigationBarItem.Chats -> RoomListMenuItems( HomeNavigationBarItem.Chats -> RoomListMenuItems(
onToggleSearch = onToggleSearch, onToggleSearch = onToggleSearch,
onMenuActionClick = onMenuActionClick, onMenuActionClick = onMenuActionClick,
canReportBug = canReportBug canReportBug = canReportBug,
spaceFiltersState = spaceFiltersState,
) )
HomeNavigationBarItem.Spaces -> SpacesMenuItems( HomeNavigationBarItem.Spaces -> SpacesMenuItems(
canCreateSpaces = canCreateSpaces, canCreateSpaces = canCreateSpaces,
@ -154,6 +169,7 @@ private fun RoomListMenuItems(
onToggleSearch: () -> Unit, onToggleSearch: () -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit, onMenuActionClick: (RoomListMenuAction) -> Unit,
canReportBug: Boolean, canReportBug: Boolean,
spaceFiltersState: SpaceFiltersState,
) { ) {
IconButton( IconButton(
onClick = onToggleSearch, onClick = onToggleSearch,
@ -163,6 +179,7 @@ private fun RoomListMenuItems(
contentDescription = stringResource(CommonStrings.action_search), contentDescription = stringResource(CommonStrings.action_search),
) )
} }
SpaceFilterButton(spaceFiltersState = spaceFiltersState)
if (RoomListConfig.HAS_DROP_DOWN_MENU) { if (RoomListConfig.HAS_DROP_DOWN_MENU) {
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
IconButton( 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 @Composable
private fun NavigationIcon( private fun NavigationIcon(
currentUserAndNeighbors: ImmutableList<MatrixUser>, currentUserAndNeighbors: ImmutableList<MatrixUser>,
@ -309,7 +358,6 @@ private fun AccountIcon(
internal fun HomeTopBarPreview() = ElementPreview { internal fun HomeTopBarPreview() = ElementPreview {
HomeTopBar( HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats, selectedNavigationItem = HomeNavigationBarItem.Chats,
title = stringResource(R.string.screen_roomlist_main_space_title),
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")), currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
showAvatarIndicator = false, showAvatarIndicator = false,
areSearchResultsDisplayed = false, areSearchResultsDisplayed = false,
@ -322,6 +370,30 @@ internal fun HomeTopBarPreview() = ElementPreview {
canReportBug = true, canReportBug = true,
displayFilters = true, displayFilters = true,
filtersState = aRoomListFiltersState(), 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 = {}, onMenuActionClick = {},
) )
} }
@ -332,7 +404,6 @@ internal fun HomeTopBarPreview() = ElementPreview {
internal fun HomeTopBarSpacesPreview() = ElementPreview { internal fun HomeTopBarSpacesPreview() = ElementPreview {
HomeTopBar( HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Spaces, selectedNavigationItem = HomeNavigationBarItem.Spaces,
title = stringResource(R.string.screen_home_tab_spaces),
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")), currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
showAvatarIndicator = false, showAvatarIndicator = false,
areSearchResultsDisplayed = false, areSearchResultsDisplayed = false,
@ -345,6 +416,7 @@ internal fun HomeTopBarSpacesPreview() = ElementPreview {
canReportBug = true, canReportBug = true,
displayFilters = false, displayFilters = false,
filtersState = aRoomListFiltersState(), filtersState = aRoomListFiltersState(),
spaceFiltersState = anUnselectedSpaceFiltersState(),
onMenuActionClick = {}, onMenuActionClick = {},
) )
} }
@ -355,7 +427,6 @@ internal fun HomeTopBarSpacesPreview() = ElementPreview {
internal fun HomeTopBarWithIndicatorPreview() = ElementPreview { internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
HomeTopBar( HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats, selectedNavigationItem = HomeNavigationBarItem.Chats,
title = stringResource(R.string.screen_roomlist_main_space_title),
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")), currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
showAvatarIndicator = true, showAvatarIndicator = true,
areSearchResultsDisplayed = false, areSearchResultsDisplayed = false,
@ -368,6 +439,7 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
canReportBug = true, canReportBug = true,
displayFilters = true, displayFilters = true,
filtersState = aRoomListFiltersState(), filtersState = aRoomListFiltersState(),
spaceFiltersState = anUnselectedSpaceFiltersState(),
onMenuActionClick = {}, onMenuActionClick = {},
) )
} }
@ -378,7 +450,6 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
internal fun HomeTopBarMultiAccountPreview() = ElementPreview { internal fun HomeTopBarMultiAccountPreview() = ElementPreview {
HomeTopBar( HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats, selectedNavigationItem = HomeNavigationBarItem.Chats,
title = stringResource(R.string.screen_roomlist_main_space_title),
currentUserAndNeighbors = aMatrixUserList().take(3).toImmutableList(), currentUserAndNeighbors = aMatrixUserList().take(3).toImmutableList(),
showAvatarIndicator = false, showAvatarIndicator = false,
areSearchResultsDisplayed = false, areSearchResultsDisplayed = false,
@ -391,6 +462,7 @@ internal fun HomeTopBarMultiAccountPreview() = ElementPreview {
canReportBug = true, canReportBug = true,
displayFilters = true, displayFilters = true,
filtersState = aRoomListFiltersState(), filtersState = aRoomListFiltersState(),
spaceFiltersState = anUnselectedSpaceFiltersState(),
onMenuActionClick = {}, onMenuActionClick = {},
) )
} }

View file

@ -23,11 +23,7 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource 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.model.RoomSummaryDisplayType
import io.element.android.features.home.impl.roomlist.RoomListContentState 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.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.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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button 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.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -62,9 +61,10 @@ import kotlinx.collections.immutable.ImmutableList
fun RoomListContentView( fun RoomListContentView(
contentState: RoomListContentState, contentState: RoomListContentState,
filtersState: RoomListFiltersState, filtersState: RoomListFiltersState,
spaceFiltersState: SpaceFiltersState,
lazyListState: LazyListState, lazyListState: LazyListState,
hideInvitesAvatars: Boolean, hideInvitesAvatars: Boolean,
eventSink: (RoomListEvents) -> Unit, eventSink: (RoomListEvent) -> Unit,
onSetUpRecoveryClick: () -> Unit, onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit, onRoomClick: (RoomListRoomSummary) -> Unit,
@ -96,6 +96,7 @@ fun RoomListContentView(
state = contentState, state = contentState,
hideInvitesAvatars = hideInvitesAvatars, hideInvitesAvatars = hideInvitesAvatars,
filtersState = filtersState, filtersState = filtersState,
spaceFiltersState = spaceFiltersState,
eventSink = eventSink, eventSink = eventSink,
onSetUpRecoveryClick = onSetUpRecoveryClick, onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
@ -131,7 +132,7 @@ private fun SkeletonView(
@Composable @Composable
private fun EmptyView( private fun EmptyView(
state: RoomListContentState.Empty, state: RoomListContentState.Empty,
eventSink: (RoomListEvents) -> Unit, eventSink: (RoomListEvent) -> Unit,
onSetUpRecoveryClick: () -> Unit, onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit,
onCreateRoomClick: () -> Unit, onCreateRoomClick: () -> Unit,
@ -155,13 +156,13 @@ private fun EmptyView(
SecurityBannerState.SetUpRecovery -> { SecurityBannerState.SetUpRecovery -> {
SetUpRecoveryKeyBanner( SetUpRecoveryKeyBanner(
onContinueClick = onSetUpRecoveryClick, onContinueClick = onSetUpRecoveryClick,
onDismissClick = { eventSink(RoomListEvents.DismissBanner) }, onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
) )
} }
SecurityBannerState.RecoveryKeyConfirmation -> { SecurityBannerState.RecoveryKeyConfirmation -> {
ConfirmRecoveryKeyBanner( ConfirmRecoveryKeyBanner(
onContinueClick = onConfirmRecoveryKeyClick, onContinueClick = onConfirmRecoveryKeyClick,
onDismissClick = { eventSink(RoomListEvents.DismissBanner) }, onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
) )
} }
SecurityBannerState.None -> Unit SecurityBannerState.None -> Unit
@ -175,7 +176,8 @@ private fun RoomsView(
state: RoomListContentState.Rooms, state: RoomListContentState.Rooms,
hideInvitesAvatars: Boolean, hideInvitesAvatars: Boolean,
filtersState: RoomListFiltersState, filtersState: RoomListFiltersState,
eventSink: (RoomListEvents) -> Unit, spaceFiltersState: SpaceFiltersState,
eventSink: (RoomListEvent) -> Unit,
onSetUpRecoveryClick: () -> Unit, onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit, onRoomClick: (RoomListRoomSummary) -> Unit,
@ -183,9 +185,12 @@ private fun RoomsView(
lazyListState: LazyListState, lazyListState: LazyListState,
modifier: Modifier = Modifier, 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( EmptyViewForFilterStates(
selectedFilters = filtersState.selectedFilters(), selectedFilters = filtersState.selectedFilters(),
isSpaceFilterSelected = isSpaceFilterSelected,
modifier = modifier.fillMaxSize() modifier = modifier.fillMaxSize()
) )
} else { } else {
@ -207,7 +212,7 @@ private fun RoomsView(
private fun RoomsViewList( private fun RoomsViewList(
state: RoomListContentState.Rooms, state: RoomListContentState.Rooms,
hideInvitesAvatars: Boolean, hideInvitesAvatars: Boolean,
eventSink: (RoomListEvents) -> Unit, eventSink: (RoomListEvent) -> Unit,
onSetUpRecoveryClick: () -> Unit, onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit, onRoomClick: (RoomListRoomSummary) -> Unit,
@ -215,17 +220,8 @@ private fun RoomsViewList(
lazyListState: LazyListState, lazyListState: LazyListState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val visibleRange by remember { OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
derivedStateOf { eventSink(RoomListEvent.UpdateVisibleRange(visibleRange))
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))
} }
LazyColumn( LazyColumn(
state = lazyListState, state = lazyListState,
@ -237,7 +233,7 @@ private fun RoomsViewList(
item { item {
SetUpRecoveryKeyBanner( SetUpRecoveryKeyBanner(
onContinueClick = onSetUpRecoveryClick, onContinueClick = onSetUpRecoveryClick,
onDismissClick = { updatedEventSink(RoomListEvents.DismissBanner) }, onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
) )
} }
} }
@ -245,7 +241,7 @@ private fun RoomsViewList(
item { item {
ConfirmRecoveryKeyBanner( ConfirmRecoveryKeyBanner(
onContinueClick = onConfirmRecoveryKeyClick, onContinueClick = onConfirmRecoveryKeyClick,
onDismissClick = { updatedEventSink(RoomListEvents.DismissBanner) }, onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
) )
} }
} }
@ -260,7 +256,7 @@ private fun RoomsViewList(
} else if (state.showNewNotificationSoundBanner) { } else if (state.showNewNotificationSoundBanner) {
item { item {
NewNotificationSoundBanner( NewNotificationSoundBanner(
onDismissClick = { updatedEventSink(RoomListEvents.DismissNewNotificationSoundBanner) }, onDismissClick = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) },
) )
} }
} }
@ -290,9 +286,10 @@ private fun RoomsViewList(
@Composable @Composable
private fun EmptyViewForFilterStates( private fun EmptyViewForFilterStates(
selectedFilters: ImmutableList<RoomListFilter>, selectedFilters: ImmutableList<RoomListFilter>,
isSpaceFilterSelected: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val emptyStateResources = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters) ?: return val emptyStateResources = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected) ?: return
EmptyScaffold( EmptyScaffold(
title = emptyStateResources.title, title = emptyStateResources.title,
subtitle = emptyStateResources.subtitle, subtitle = emptyStateResources.subtitle,
@ -343,6 +340,7 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr
) )
} }
), ),
spaceFiltersState = anUnselectedSpaceFiltersState(),
hideInvitesAvatars = false, hideInvitesAvatars = false,
eventSink = {}, eventSink = {},
onSetUpRecoveryClick = {}, onSetUpRecoveryClick = {},

View file

@ -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.RoomListRoomSummary
import io.element.android.features.home.impl.model.RoomListRoomSummaryProvider 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.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.orEmpty
import io.element.android.libraries.core.extensions.toSafeLength import io.element.android.libraries.core.extensions.toSafeLength
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
@ -74,7 +74,7 @@ internal fun RoomSummaryRow(
hideInviteAvatars: Boolean, hideInviteAvatars: Boolean,
isInviteSeen: Boolean, isInviteSeen: Boolean,
onClick: (RoomListRoomSummary) -> Unit, onClick: (RoomListRoomSummary) -> Unit,
eventSink: (RoomListEvents) -> Unit, eventSink: (RoomListEvent) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box(modifier = modifier) { Box(modifier = modifier) {
@ -104,10 +104,10 @@ internal fun RoomSummaryRow(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
InviteButtonsRowMolecule( InviteButtonsRowMolecule(
onAcceptClick = { onAcceptClick = {
eventSink(RoomListEvents.AcceptInvite(room)) eventSink(RoomListEvent.AcceptInvite(room))
}, },
onDeclineClick = { onDeclineClick = {
eventSink(RoomListEvents.ShowDeclineInviteMenu(room)) eventSink(RoomListEvent.ShowDeclineInviteMenu(room))
} }
) )
} }
@ -117,7 +117,7 @@ internal fun RoomSummaryRow(
room = room, room = room,
onClick = onClick, onClick = onClick,
onLongClick = { onLongClick = {
eventSink(RoomListEvents.ShowContextMenu(room)) eventSink(RoomListEvent.ShowContextMenu(room))
}, },
) { ) {
NameAndTimestampRow( NameAndTimestampRow(

View file

@ -9,33 +9,48 @@
package io.element.android.features.home.impl.datasource package io.element.android.features.home.impl.datasource
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.features.home.impl.model.RoomListRoomSummary import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.libraries.androidutils.diff.DiffCacheUpdater import io.element.android.libraries.androidutils.diff.DiffCacheUpdater
import io.element.android.libraries.androidutils.diff.MutableListDiffCache import io.element.android.libraries.androidutils.diff.MutableListDiffCache
import io.element.android.libraries.androidutils.system.DateTimeObserver import io.element.android.libraries.androidutils.system.DateTimeObserver
import io.element.android.libraries.core.coroutine.CoroutineDispatchers 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.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService 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.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary 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 io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview 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.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.lang.IllegalStateException
import kotlin.time.Duration.Companion.seconds 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 @Inject
@SingleIn(SessionScope::class)
class RoomListDataSource( class RoomListDataSource(
private val roomListService: RoomListService, private val roomListService: RoomListService,
private val roomListRoomSummaryFactory: RoomListRoomSummaryFactory, private val roomListRoomSummaryFactory: RoomListRoomSummaryFactory,
@ -51,7 +66,12 @@ class RoomListDataSource(
observeDateTimeChanges() observeDateTimeChanges()
} }
private val _allRooms = MutableSharedFlow<ImmutableList<RoomListRoomSummary>>(replay = 1) private val roomList = roomListService.createRoomList(
pageSize = PAGE_SIZE,
source = RoomList.Source.All,
coroutineScope = sessionCoroutineScope
)
private val _roomSummariesFlow = MutableSharedFlow<ImmutableList<RoomListRoomSummary>>(replay = 1)
private val lock = Mutex() private val lock = Mutex()
private val diffCache = MutableListDiffCache<RoomListRoomSummary>() private val diffCache = MutableListDiffCache<RoomListRoomSummary>()
@ -59,22 +79,49 @@ class RoomListDataSource(
old?.roomId == new?.roomId old?.roomId == new?.roomId
} }
val allRooms: Flow<ImmutableList<RoomListRoomSummary>> = _allRooms val roomSummariesFlow: Flow<ImmutableList<RoomListRoomSummary>> = _roomSummariesFlow
val loadingState = roomListService.allRooms.loadingState val loadingState = roomList.loadingState
fun launchIn(coroutineScope: CoroutineScope) { fun launchIn(coroutineScope: CoroutineScope) {
roomListService roomList
.allRooms .summaries
.filteredSummaries
.onEach { roomSummaries -> .onEach { roomSummaries ->
replaceWith(roomSummaries) replaceWith(roomSummaries)
} }
.launchIn(coroutineScope) .launchIn(coroutineScope)
} }
suspend fun subscribeToVisibleRooms(roomIds: List<RoomId>) { suspend fun updateFilter(filter: RoomListFilter) {
roomListService.subscribeToVisibleRooms(roomIds) 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) @OptIn(FlowPreview::class)
@ -82,7 +129,7 @@ class RoomListDataSource(
notificationSettingsService.notificationSettingsChangeFlow notificationSettingsService.notificationSettingsChangeFlow
.debounce(0.5.seconds) .debounce(0.5.seconds)
.onEach { .onEach {
roomListService.allRooms.rebuildSummaries() roomList.rebuildSummaries()
} }
.launchIn(sessionCoroutineScope) .launchIn(sessionCoroutineScope)
} }
@ -108,6 +155,7 @@ class RoomListDataSource(
private suspend fun buildAndEmitAllRooms(roomSummaries: List<RoomSummary>, useCache: Boolean = true) { private suspend fun buildAndEmitAllRooms(roomSummaries: List<RoomSummary>, useCache: Boolean = true) {
// Used to detect duplicates in the room list summaries - see comment below // Used to detect duplicates in the room list summaries - see comment below
data class CacheResult(val index: Int, val fromCache: Boolean) data class CacheResult(val index: Int, val fromCache: Boolean)
val cachingResults = mutableMapOf<RoomId, MutableList<CacheResult>>() val cachingResults = mutableMapOf<RoomId, MutableList<CacheResult>>()
val roomListRoomSummaries = diffCache.indices().mapNotNull { index -> val roomListRoomSummaries = diffCache.indices().mapNotNull { index ->
@ -144,14 +192,14 @@ class RoomListDataSource(
analyticsService.trackError( analyticsService.trackError(
IllegalStateException( IllegalStateException(
"Found duplicates in room summaries after a local UI update: $duplicates. " + "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 // Remove duplicates before emitting the new values
_allRooms.emit(roomListRoomSummaries.distinctBy { it.roomId }.toImmutableList()) _roomSummariesFlow.emit(roomListRoomSummaries.distinctBy { it.roomId }.toImmutableList())
} else { } else {
_allRooms.emit(roomListRoomSummaries.toImmutableList()) _roomSummariesFlow.emit(roomListRoomSummaries.toImmutableList())
} }
} }
@ -163,7 +211,7 @@ class RoomListDataSource(
private suspend fun rebuildAllRoomSummaries() { private suspend fun rebuildAllRoomSummaries() {
lock.withLock { lock.withLock {
roomListService.allRooms.filteredSummaries.replayCache.firstOrNull()?.let { roomSummaries -> roomList.summaries.replayCache.firstOrNull()?.let { roomSummaries ->
buildAndEmitAllRooms(roomSummaries, useCache = false) buildAndEmitAllRooms(roomSummaries, useCache = false)
} }
} }

View file

@ -95,6 +95,7 @@ class RoomListRoomSummaryFactory(
content = content, content = content,
) )
} }
is LatestEventValue.RoomInvite -> LatestEvent.None
} }
} }
} }

View file

@ -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.roomlist.RoomListState
import io.element.android.features.home.impl.search.RoomListSearchPresenter 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.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.architecture.Presenter
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
@ -31,4 +33,7 @@ interface RoomListModule {
@Binds @Binds
fun bindFiltersPresenter(presenter: RoomListFiltersPresenter): Presenter<RoomListFiltersState> fun bindFiltersPresenter(presenter: RoomListFiltersPresenter): Presenter<RoomListFiltersState>
@Binds
fun bindSpaceFiltersPresenter(presenter: SpaceFiltersPresenter): Presenter<SpaceFiltersState>
} }

View file

@ -9,6 +9,7 @@
package io.element.android.features.home.impl.filters package io.element.android.features.home.impl.filters
import io.element.android.features.home.impl.R 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. * 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) 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
}
}

View file

@ -24,8 +24,12 @@ data class RoomListFiltersEmptyStateResources(
/** /**
* Create a [RoomListFiltersEmptyStateResources] from a list of selected filters. * Create a [RoomListFiltersEmptyStateResources] from a list of selected filters.
*/ */
fun fromSelectedFilters(selectedFilters: List<RoomListFilter>): RoomListFiltersEmptyStateResources? { fun fromSelectedFilters(selectedFilters: List<RoomListFilter>, isSpaceFilterSelected: Boolean): RoomListFiltersEmptyStateResources? {
return when { 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.isEmpty() -> null
selectedFilters.size == 1 -> { selectedFilters.size == 1 -> {
when (selectedFilters.first()) { when (selectedFilters.first()) {

View file

@ -8,7 +8,7 @@
package io.element.android.features.home.impl.filters package io.element.android.features.home.impl.filters
sealed interface RoomListFiltersEvents { sealed interface RoomListFiltersEvent {
data class ToggleFilter(val filter: RoomListFilter) : RoomListFiltersEvents data class ToggleFilter(val filter: RoomListFilter) : RoomListFiltersEvent
data object ClearSelectedFilters : RoomListFiltersEvents data object ClearSelectedFilters : RoomListFiltersEvent
} }

View file

@ -9,61 +9,33 @@
package io.element.android.features.home.impl.filters package io.element.android.features.home.impl.filters
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.home.impl.filters.selection.FilterSelectionStrategy import io.element.android.features.home.impl.filters.selection.FilterSelectionStrategy
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.map
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter
@Inject @Inject
class RoomListFiltersPresenter( class RoomListFiltersPresenter(
private val roomListService: RoomListService,
private val filterSelectionStrategy: FilterSelectionStrategy, private val filterSelectionStrategy: FilterSelectionStrategy,
) : Presenter<RoomListFiltersState> { ) : Presenter<RoomListFiltersState> {
private val initialFilters = filterSelectionStrategy.filterSelectionStates.value.toImmutableList()
@Composable @Composable
override fun present(): RoomListFiltersState { override fun present(): RoomListFiltersState {
fun handleEvent(event: RoomListFiltersEvents) { fun handleEvent(event: RoomListFiltersEvent) {
when (event) { when (event) {
RoomListFiltersEvents.ClearSelectedFilters -> { RoomListFiltersEvent.ClearSelectedFilters -> {
filterSelectionStrategy.clear() filterSelectionStrategy.clear()
} }
is RoomListFiltersEvents.ToggleFilter -> { is RoomListFiltersEvent.ToggleFilter -> {
filterSelectionStrategy.toggle(event.filter) filterSelectionStrategy.toggle(event.filter)
} }
} }
} }
val filters by produceState(initialValue = initialFilters) { val filters by filterSelectionStrategy.filterSelectionStates.collectAsState()
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)
}
}
return RoomListFiltersState( return RoomListFiltersState(
filterSelectionStates = filters, filterSelectionStates = filters.toImmutableList(),
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }

View file

@ -14,7 +14,7 @@ import kotlinx.collections.immutable.toImmutableList
data class RoomListFiltersState( data class RoomListFiltersState(
val filterSelectionStates: ImmutableList<FilterSelectionState>, val filterSelectionStates: ImmutableList<FilterSelectionState>,
val eventSink: (RoomListFiltersEvents) -> Unit, val eventSink: (RoomListFiltersEvent) -> Unit,
) { ) {
val hasAnyFilterSelected = filterSelectionStates.any { it.isSelected } val hasAnyFilterSelected = filterSelectionStates.any { it.isSelected }

View file

@ -24,7 +24,7 @@ class RoomListFiltersStateProvider : PreviewParameterProvider<RoomListFiltersSta
fun aRoomListFiltersState( fun aRoomListFiltersState(
filterSelectionStates: List<FilterSelectionState> = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = false) }, filterSelectionStates: List<FilterSelectionState> = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = false) },
eventSink: (RoomListFiltersEvents) -> Unit = {}, eventSink: (RoomListFiltersEvent) -> Unit = {},
) = RoomListFiltersState( ) = RoomListFiltersState(
filterSelectionStates = filterSelectionStates.toImmutableList(), filterSelectionStates = filterSelectionStates.toImmutableList(),
eventSink = eventSink, eventSink = eventSink,

View file

@ -60,11 +60,11 @@ fun RoomListFiltersView(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
fun onClearFiltersClick() { fun onClearFiltersClick() {
state.eventSink(RoomListFiltersEvents.ClearSelectedFilters) state.eventSink(RoomListFiltersEvent.ClearSelectedFilters)
} }
fun onToggleFilter(filter: RoomListFilter) { fun onToggleFilter(filter: RoomListFilter) {
state.eventSink(RoomListFiltersEvents.ToggleFilter(filter)) state.eventSink(RoomListFiltersEvent.ToggleFilter(filter))
} }
var scrollToStart by remember { mutableIntStateOf(0) } var scrollToStart by remember { mutableIntStateOf(0) }

View file

@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
class DefaultFilterSelectionStrategy : FilterSelectionStrategy { class DefaultFilterSelectionStrategy : FilterSelectionStrategy {
private val selectedFilters = LinkedHashSet<RoomListFilter>() private val selectedFilters = LinkedHashSet<RoomListFilter>()
private val availableFilters
get() = RoomListFilter.entries.toSet()
override val filterSelectionStates = MutableStateFlow(buildFilters()) override val filterSelectionStates = MutableStateFlow(buildFilters())
@ -45,7 +47,7 @@ class DefaultFilterSelectionStrategy : FilterSelectionStrategy {
isSelected = true 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 { val unselectedFilterStates = unselectedFilters.map {
FilterSelectionState( FilterSelectionState(
filter = it, filter = it,

View file

@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.StateFlow
interface FilterSelectionStrategy { interface FilterSelectionStrategy {
val filterSelectionStates: StateFlow<Set<FilterSelectionState>> val filterSelectionStates: StateFlow<Set<FilterSelectionState>>
fun select(filter: RoomListFilter) fun select(filter: RoomListFilter)
fun deselect(filter: RoomListFilter) fun deselect(filter: RoomListFilter)
fun isSelected(filter: RoomListFilter): Boolean fun isSelected(filter: RoomListFilter): Boolean

View file

@ -37,41 +37,41 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun RoomListContextMenu( fun RoomListContextMenu(
contextMenu: RoomListState.ContextMenu.Shown, contextMenu: RoomListState.ContextMenu.Shown,
canReportRoom: Boolean, canReportRoom: Boolean,
eventSink: (RoomListEvents.ContextMenuEvents) -> Unit, eventSink: (RoomListEvent.ContextMenuEvent) -> Unit,
onRoomSettingsClick: (roomId: RoomId) -> Unit, onRoomSettingsClick: (roomId: RoomId) -> Unit,
onReportRoomClick: (roomId: RoomId) -> Unit onReportRoomClick: (roomId: RoomId) -> Unit
) { ) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { eventSink(RoomListEvents.HideContextMenu) }, onDismissRequest = { eventSink(RoomListEvent.HideContextMenu) },
) { ) {
RoomListModalBottomSheetContent( RoomListModalBottomSheetContent(
contextMenu = contextMenu, contextMenu = contextMenu,
canReportRoom = canReportRoom, canReportRoom = canReportRoom,
onRoomMarkReadClick = { onRoomMarkReadClick = {
eventSink(RoomListEvents.HideContextMenu) eventSink(RoomListEvent.HideContextMenu)
eventSink(RoomListEvents.MarkAsRead(contextMenu.roomId)) eventSink(RoomListEvent.MarkAsRead(contextMenu.roomId))
}, },
onRoomMarkUnreadClick = { onRoomMarkUnreadClick = {
eventSink(RoomListEvents.HideContextMenu) eventSink(RoomListEvent.HideContextMenu)
eventSink(RoomListEvents.MarkAsUnread(contextMenu.roomId)) eventSink(RoomListEvent.MarkAsUnread(contextMenu.roomId))
}, },
onRoomSettingsClick = { onRoomSettingsClick = {
eventSink(RoomListEvents.HideContextMenu) eventSink(RoomListEvent.HideContextMenu)
onRoomSettingsClick(contextMenu.roomId) onRoomSettingsClick(contextMenu.roomId)
}, },
onLeaveRoomClick = { onLeaveRoomClick = {
eventSink(RoomListEvents.HideContextMenu) eventSink(RoomListEvent.HideContextMenu)
eventSink(RoomListEvents.LeaveRoom(contextMenu.roomId, needsConfirmation = true)) eventSink(RoomListEvent.LeaveRoom(contextMenu.roomId, needsConfirmation = true))
}, },
onFavoriteChange = { isFavorite -> onFavoriteChange = { isFavorite ->
eventSink(RoomListEvents.SetRoomIsFavorite(contextMenu.roomId, isFavorite)) eventSink(RoomListEvent.SetRoomIsFavorite(contextMenu.roomId, isFavorite))
}, },
onClearCacheRoomClick = { onClearCacheRoomClick = {
eventSink(RoomListEvents.HideContextMenu) eventSink(RoomListEvent.HideContextMenu)
eventSink(RoomListEvents.ClearCacheOfRoom(contextMenu.roomId)) eventSink(RoomListEvent.ClearCacheOfRoom(contextMenu.roomId))
}, },
onReportRoomClick = { onReportRoomClick = {
eventSink(RoomListEvents.HideContextMenu) eventSink(RoomListEvent.HideContextMenu)
onReportRoomClick(contextMenu.roomId) onReportRoomClick(contextMenu.roomId)
}, },
) )
@ -131,16 +131,21 @@ private fun RoomListModalBottomSheetContent(
style = ListItemStyle.Primary, style = ListItemStyle.Primary,
) )
} }
val (textResId, icon) = if (contextMenu.isFavorite) {
CommonStrings.common_favourited to CompoundIcons.FavouriteSolid()
} else {
CommonStrings.common_favourite to CompoundIcons.Favourite()
}
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
text = stringResource(id = CommonStrings.common_favourite), text = stringResource(id = textResId),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
) )
}, },
leadingContent = ListItemContent.Icon( leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector( iconSource = IconSource.Vector(
CompoundIcons.Favourite(), icon,
) )
), ),
trailingContent = ListItemContent.Switch( trailingContent = ListItemContent.Switch(

View file

@ -38,27 +38,27 @@ fun RoomListDeclineInviteMenu(
menu: RoomListState.DeclineInviteMenu.Shown, menu: RoomListState.DeclineInviteMenu.Shown,
canReportRoom: Boolean, canReportRoom: Boolean,
onDeclineAndBlockClick: (RoomListRoomSummary) -> Unit, onDeclineAndBlockClick: (RoomListRoomSummary) -> Unit,
eventSink: (RoomListEvents) -> Unit, eventSink: (RoomListEvent) -> Unit,
) { ) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { eventSink(RoomListEvents.HideDeclineInviteMenu) }, onDismissRequest = { eventSink(RoomListEvent.HideDeclineInviteMenu) },
) { ) {
RoomListDeclineInviteMenuContent( RoomListDeclineInviteMenuContent(
roomName = menu.roomSummary.name ?: menu.roomSummary.roomId.value, roomName = menu.roomSummary.name ?: menu.roomSummary.roomId.value,
onDeclineClick = { onDeclineClick = {
eventSink(RoomListEvents.HideDeclineInviteMenu) eventSink(RoomListEvent.HideDeclineInviteMenu)
eventSink(RoomListEvents.DeclineInvite(menu.roomSummary, false)) eventSink(RoomListEvent.DeclineInvite(menu.roomSummary, false))
}, },
onDeclineAndBlockClick = { onDeclineAndBlockClick = {
eventSink(RoomListEvents.HideDeclineInviteMenu) eventSink(RoomListEvent.HideDeclineInviteMenu)
if (canReportRoom) { if (canReportRoom) {
onDeclineAndBlockClick(menu.roomSummary) onDeclineAndBlockClick(menu.roomSummary)
} else { } else {
eventSink(RoomListEvents.DeclineInvite(menu.roomSummary, true)) eventSink(RoomListEvent.DeclineInvite(menu.roomSummary, true))
} }
}, },
onCancelClick = { onCancelClick = {
eventSink(RoomListEvents.HideDeclineInviteMenu) eventSink(RoomListEvent.HideDeclineInviteMenu)
} }
) )
} }

View file

@ -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.features.home.impl.model.RoomListRoomSummary
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
sealed interface RoomListEvents { sealed interface RoomListEvent {
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents data class UpdateVisibleRange(val range: IntRange) : RoomListEvent
data object DismissRequestVerificationPrompt : RoomListEvents data object DismissRequestVerificationPrompt : RoomListEvent
data object DismissBanner : RoomListEvents data object DismissBanner : RoomListEvent
data object DismissNewNotificationSoundBanner : RoomListEvents data object DismissNewNotificationSoundBanner : RoomListEvent
data object ToggleSearchResults : RoomListEvents data object ToggleSearchResults : RoomListEvent
data class ShowContextMenu(val roomSummary: RoomListRoomSummary) : RoomListEvents data class ShowContextMenu(val roomSummary: RoomListRoomSummary) : RoomListEvent
data class AcceptInvite(val roomSummary: RoomListRoomSummary) : RoomListEvents data class AcceptInvite(val roomSummary: RoomListRoomSummary) : RoomListEvent
data class DeclineInvite(val roomSummary: RoomListRoomSummary, val blockUser: Boolean) : RoomListEvents data class DeclineInvite(val roomSummary: RoomListRoomSummary, val blockUser: Boolean) : RoomListEvent
data class ShowDeclineInviteMenu(val roomSummary: RoomListRoomSummary) : RoomListEvents data class ShowDeclineInviteMenu(val roomSummary: RoomListRoomSummary) : RoomListEvent
data object HideDeclineInviteMenu : RoomListEvents data object HideDeclineInviteMenu : RoomListEvent
sealed interface ContextMenuEvents : RoomListEvents sealed interface ContextMenuEvent : RoomListEvent
data object HideContextMenu : ContextMenuEvents data object HideContextMenu : ContextMenuEvent
data class LeaveRoom(val roomId: RoomId, val needsConfirmation: Boolean) : ContextMenuEvents data class LeaveRoom(val roomId: RoomId, val needsConfirmation: Boolean) : ContextMenuEvent
data class MarkAsRead(val roomId: RoomId) : ContextMenuEvents data class MarkAsRead(val roomId: RoomId) : ContextMenuEvent
data class MarkAsUnread(val roomId: RoomId) : ContextMenuEvents data class MarkAsUnread(val roomId: RoomId) : ContextMenuEvent
data class SetRoomIsFavorite(val roomId: RoomId, val isFavorite: Boolean) : ContextMenuEvents data class SetRoomIsFavorite(val roomId: RoomId, val isFavorite: Boolean) : ContextMenuEvent
data class ClearCacheOfRoom(val roomId: RoomId) : ContextMenuEvents data class ClearCacheOfRoom(val roomId: RoomId) : ContextMenuEvent
} }

View file

@ -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.Announcement
import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.home.impl.datasource.RoomListDataSource 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.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.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.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.AcceptInvite import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.AcceptInvite
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.DeclineInvite 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.core.RoomId
import io.element.android.libraries.matrix.api.encryption.RecoveryState 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.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.api.timeline.ReceiptType
import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar
import io.element.android.libraries.preferences.api.store.AppPreferencesStore 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.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -68,9 +72,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private const val EXTENDED_RANGE_SIZE = 40
private const val SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS = 300L
@Inject @Inject
class RoomListPresenter( class RoomListPresenter(
private val client: MatrixClient, private val client: MatrixClient,
@ -88,6 +89,7 @@ class RoomListPresenter(
private val seenInvitesStore: SeenInvitesStore, private val seenInvitesStore: SeenInvitesStore,
private val announcementService: AnnouncementService, private val announcementService: AnnouncementService,
private val coldStartWatcher: AnalyticsColdStartWatcher, private val coldStartWatcher: AnalyticsColdStartWatcher,
private val spaceFiltersPresenter: Presenter<SpaceFiltersState>,
) : Presenter<RoomListState> { ) : Presenter<RoomListState> {
private val encryptionService = client.encryptionService private val encryptionService = client.encryptionService
@ -97,6 +99,7 @@ class RoomListPresenter(
val leaveRoomState = leaveRoomPresenter.present() val leaveRoomState = leaveRoomPresenter.present()
val filtersState = filtersPresenter.present() val filtersState = filtersPresenter.present()
val searchState = searchPresenter.present() val searchState = searchPresenter.present()
val spaceFiltersState = spaceFiltersPresenter.present()
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present() val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -116,45 +119,52 @@ class RoomListPresenter(
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) } val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(RoomListState.DeclineInviteMenu.Hidden) } val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(RoomListState.DeclineInviteMenu.Hidden) }
fun handleEvent(event: RoomListEvents) { fun handleEvent(event: RoomListEvent) {
when (event) { when (event) {
is RoomListEvents.UpdateVisibleRange -> coroutineScope.launch { is RoomListEvent.UpdateVisibleRange -> coroutineScope.launch {
updateVisibleRange(event.range) roomListDataSource.updateVisibleRange(event.range)
} }
RoomListEvents.DismissRequestVerificationPrompt -> securityBannerDismissed = true RoomListEvent.DismissRequestVerificationPrompt -> securityBannerDismissed = true
RoomListEvents.DismissBanner -> securityBannerDismissed = true RoomListEvent.DismissBanner -> securityBannerDismissed = true
RoomListEvents.DismissNewNotificationSoundBanner -> coroutineScope.launch { RoomListEvent.DismissNewNotificationSoundBanner -> coroutineScope.launch {
announcementService.onAnnouncementDismissed(Announcement.NewNotificationSound) announcementService.onAnnouncementDismissed(Announcement.NewNotificationSound)
} }
RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility) RoomListEvent.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvent.ToggleSearchVisibility)
is RoomListEvents.ShowContextMenu -> { is RoomListEvent.ShowContextMenu -> {
coroutineScope.showContextMenu(event, contextMenu) coroutineScope.showContextMenu(event, contextMenu)
} }
is RoomListEvents.HideContextMenu -> { is RoomListEvent.HideContextMenu -> {
contextMenu.value = RoomListState.ContextMenu.Hidden contextMenu.value = RoomListState.ContextMenu.Hidden
} }
is RoomListEvents.LeaveRoom -> { is RoomListEvent.LeaveRoom -> {
leaveRoomState.eventSink(LeaveRoomEvent.LeaveRoom(event.roomId, needsConfirmation = event.needsConfirmation)) leaveRoomState.eventSink(LeaveRoomEvent.LeaveRoom(event.roomId, needsConfirmation = event.needsConfirmation))
} }
is RoomListEvents.SetRoomIsFavorite -> coroutineScope.setRoomIsFavorite(event.roomId, event.isFavorite) is RoomListEvent.SetRoomIsFavorite -> coroutineScope.setRoomIsFavorite(event.roomId, event.isFavorite)
is RoomListEvents.MarkAsRead -> coroutineScope.markAsRead(event.roomId) is RoomListEvent.MarkAsRead -> coroutineScope.markAsRead(event.roomId)
is RoomListEvents.MarkAsUnread -> coroutineScope.markAsUnread(event.roomId) is RoomListEvent.MarkAsUnread -> coroutineScope.markAsUnread(event.roomId)
is RoomListEvents.AcceptInvite -> { is RoomListEvent.AcceptInvite -> {
acceptDeclineInviteState.eventSink( acceptDeclineInviteState.eventSink(
AcceptInvite(event.roomSummary.toInviteData()) AcceptInvite(event.roomSummary.toInviteData())
) )
} }
is RoomListEvents.DeclineInvite -> { is RoomListEvent.DeclineInvite -> {
acceptDeclineInviteState.eventSink( acceptDeclineInviteState.eventSink(
DeclineInvite(event.roomSummary.toInviteData(), blockUser = event.blockUser, shouldConfirm = false) DeclineInvite(event.roomSummary.toInviteData(), blockUser = event.blockUser, shouldConfirm = false)
) )
} }
is RoomListEvents.ShowDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Shown(event.roomSummary) is RoomListEvent.ShowDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Shown(event.roomSummary)
RoomListEvents.HideDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Hidden RoomListEvent.HideDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Hidden
is RoomListEvents.ClearCacheOfRoom -> coroutineScope.clearCacheOfRoom(event.roomId) 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( val contentState = roomListContentState(
securityBannerDismissed, securityBannerDismissed,
showNewNotificationSoundBanner, showNewNotificationSoundBanner,
@ -168,6 +178,7 @@ class RoomListPresenter(
leaveRoomState = leaveRoomState, leaveRoomState = leaveRoomState,
filtersState = filtersState, filtersState = filtersState,
searchState = searchState, searchState = searchState,
spaceFiltersState = spaceFiltersState,
contentState = contentState, contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState, acceptDeclineInviteState = acceptDeclineInviteState,
hideInvitesAvatars = hideInvitesAvatar, hideInvitesAvatars = hideInvitesAvatar,
@ -217,7 +228,7 @@ class RoomListPresenter(
showNewNotificationSoundBanner: Boolean, showNewNotificationSoundBanner: Boolean,
): RoomListContentState { ): RoomListContentState {
val roomSummaries by produceState(initialValue = AsyncData.Loading()) { 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 loadingState by roomListDataSource.loadingState.collectAsState()
val showEmpty by remember { val showEmpty by remember {
@ -253,7 +264,7 @@ class RoomListPresenter(
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
private fun CoroutineScope.showContextMenu(event: RoomListEvents.ShowContextMenu, contextMenuState: MutableState<RoomListState.ContextMenu>) = launch { private fun CoroutineScope.showContextMenu(event: RoomListEvent.ShowContextMenu, contextMenuState: MutableState<RoomListState.ContextMenu>) = launch {
val initialState = RoomListState.ContextMenu.Shown( val initialState = RoomListState.ContextMenu.Shown(
roomId = event.roomSummary.roomId, roomId = event.roomSummary.roomId,
roomName = event.roomSummary.name, roomName = event.roomSummary.name,
@ -322,23 +333,4 @@ class RoomListPresenter(
room.clearEventCacheStorage() 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)
}
}
} }

View file

@ -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.filters.RoomListFiltersState
import io.element.android.features.home.impl.model.RoomListRoomSummary 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.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.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
@ -26,11 +27,12 @@ data class RoomListState(
val leaveRoomState: LeaveRoomState, val leaveRoomState: LeaveRoomState,
val filtersState: RoomListFiltersState, val filtersState: RoomListFiltersState,
val searchState: RoomListSearchState, val searchState: RoomListSearchState,
val spaceFiltersState: SpaceFiltersState,
val contentState: RoomListContentState, val contentState: RoomListContentState,
val acceptDeclineInviteState: AcceptDeclineInviteState, val acceptDeclineInviteState: AcceptDeclineInviteState,
val hideInvitesAvatars: Boolean, val hideInvitesAvatars: Boolean,
val canReportRoom: Boolean, val canReportRoom: Boolean,
val eventSink: (RoomListEvents) -> Unit, val eventSink: (RoomListEvent) -> Unit,
) { ) {
val displayFilters = contentState is RoomListContentState.Rooms val displayFilters = contentState is RoomListContentState.Rooms

View file

@ -15,7 +15,7 @@ open class RoomListStateContextMenuShownProvider : PreviewParameterProvider<Room
override val values: Sequence<RoomListState.ContextMenu.Shown> override val values: Sequence<RoomListState.ContextMenu.Shown>
get() = sequenceOf( get() = sequenceOf(
aContextMenuShown(hasNewContent = true), aContextMenuShown(hasNewContent = true),
aContextMenuShown(isDm = true), aContextMenuShown(isDm = true, isFavorite = true),
aContextMenuShown(roomName = null) aContextMenuShown(roomName = null)
) )
} }

View file

@ -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.model.anInviteSender
import io.element.android.features.home.impl.search.RoomListSearchState 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.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.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomEvent
@ -52,17 +54,19 @@ internal fun aRoomListState(
leaveRoomState: LeaveRoomState = aLeaveRoomState(), leaveRoomState: LeaveRoomState = aLeaveRoomState(),
searchState: RoomListSearchState = aRoomListSearchState(), searchState: RoomListSearchState = aRoomListSearchState(),
filtersState: RoomListFiltersState = aRoomListFiltersState(), filtersState: RoomListFiltersState = aRoomListFiltersState(),
spaceFiltersState: SpaceFiltersState = anUnselectedSpaceFiltersState(),
contentState: RoomListContentState = aRoomsContentState(), contentState: RoomListContentState = aRoomsContentState(),
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(), acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
hideInvitesAvatars: Boolean = false, hideInvitesAvatars: Boolean = false,
canReportRoom: Boolean = true, canReportRoom: Boolean = true,
eventSink: (RoomListEvents) -> Unit = {} eventSink: (RoomListEvent) -> Unit = {}
) = RoomListState( ) = RoomListState(
contextMenu = contextMenu, contextMenu = contextMenu,
declineInviteMenu = declineInviteMenu, declineInviteMenu = declineInviteMenu,
leaveRoomState = leaveRoomState, leaveRoomState = leaveRoomState,
filtersState = filtersState, filtersState = filtersState,
searchState = searchState, searchState = searchState,
spaceFiltersState = spaceFiltersState,
contentState = contentState, contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState, acceptDeclineInviteState = acceptDeclineInviteState,
hideInvitesAvatars = hideInvitesAvatars, hideInvitesAvatars = hideInvitesAvatars,

View file

@ -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.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter 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.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.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -42,12 +42,11 @@ class RoomListSearchDataSource(
private val roomList = roomListService.createRoomList( private val roomList = roomListService.createRoomList(
pageSize = PAGE_SIZE, pageSize = PAGE_SIZE,
initialFilter = RoomListFilter.None,
source = RoomList.Source.All, source = RoomList.Source.All,
coroutineScope = coroutineScope coroutineScope = coroutineScope
) )
val roomSummaries: Flow<ImmutableList<RoomListRoomSummary>> = roomList.filteredSummaries val roomSummaries: Flow<ImmutableList<RoomListRoomSummary>> = roomList.summaries
.map { roomSummaries -> .map { roomSummaries ->
roomSummaries roomSummaries
.map(roomSummaryFactory::create) .map(roomSummaryFactory::create)
@ -55,12 +54,8 @@ class RoomListSearchDataSource(
} }
.flowOn(coroutineDispatchers.computation) .flowOn(coroutineDispatchers.computation)
suspend fun setIsActive(isActive: Boolean) = coroutineScope { suspend fun updateVisibleRange(visibleRange: IntRange) {
if (isActive) { roomList.updateVisibleRange(visibleRange)
roomList.loadAllIncrementally(this)
} else {
roomList.reset()
}
} }
suspend fun setSearchQuery(searchQuery: String) = coroutineScope { suspend fun setSearchQuery(searchQuery: String) = coroutineScope {

View file

@ -8,7 +8,8 @@
package io.element.android.features.home.impl.search package io.element.android.features.home.impl.search
sealed interface RoomListSearchEvents { sealed interface RoomListSearchEvent {
data object ToggleSearchVisibility : RoomListSearchEvents data object ToggleSearchVisibility : RoomListSearchEvent
data object ClearQuery : RoomListSearchEvents data object ClearQuery : RoomListSearchEvent
data class UpdateVisibleRange(val range: IntRange) : RoomListSearchEvent
} }

View file

@ -21,6 +21,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
@Inject @Inject
class RoomListSearchPresenter( class RoomListSearchPresenter(
@ -37,23 +38,22 @@ class RoomListSearchPresenter(
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val dataSource = remember { dataSourceFactory.create(coroutineScope) } val dataSource = remember { dataSourceFactory.create(coroutineScope) }
LaunchedEffect(isSearchActive) {
dataSource.setIsActive(isSearchActive)
}
LaunchedEffect(searchQuery.text) { LaunchedEffect(searchQuery.text) {
dataSource.setSearchQuery(searchQuery.text.toString()) dataSource.setSearchQuery(searchQuery.text.toString())
} }
fun handleEvent(event: RoomListSearchEvents) { fun handleEvent(event: RoomListSearchEvent) {
when (event) { when (event) {
RoomListSearchEvents.ClearQuery -> { RoomListSearchEvent.ClearQuery -> {
searchQuery.clearText() searchQuery.clearText()
} }
RoomListSearchEvents.ToggleSearchVisibility -> { RoomListSearchEvent.ToggleSearchVisibility -> {
isSearchActive = !isSearchActive isSearchActive = !isSearchActive
searchQuery.clearText() searchQuery.clearText()
} }
is RoomListSearchEvent.UpdateVisibleRange -> coroutineScope.launch {
dataSource.updateVisibleRange(visibleRange = event.range)
}
} }
} }

View file

@ -16,5 +16,5 @@ data class RoomListSearchState(
val isSearchActive: Boolean, val isSearchActive: Boolean,
val query: TextFieldState, val query: TextFieldState,
val results: ImmutableList<RoomListRoomSummary>, val results: ImmutableList<RoomListRoomSummary>,
val eventSink: (RoomListSearchEvents) -> Unit val eventSink: (RoomListSearchEvent) -> Unit
) )

View file

@ -31,7 +31,7 @@ fun aRoomListSearchState(
isSearchActive: Boolean = false, isSearchActive: Boolean = false,
query: String = "", query: String = "",
results: ImmutableList<RoomListRoomSummary> = persistentListOf(), results: ImmutableList<RoomListRoomSummary> = persistentListOf(),
eventSink: (RoomListSearchEvents) -> Unit = { }, eventSink: (RoomListSearchEvent) -> Unit = { },
) = RoomListSearchState( ) = RoomListSearchState(
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
query = TextFieldState(initialText = query), query = TextFieldState(initialText = query),

View file

@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme 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.components.RoomSummaryRow
import io.element.android.features.home.impl.contentType import io.element.android.features.home.impl.contentType
import io.element.android.features.home.impl.model.RoomListRoomSummary 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.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -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.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar 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.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -54,12 +56,12 @@ import io.element.android.libraries.ui.strings.CommonStrings
internal fun RoomListSearchView( internal fun RoomListSearchView(
state: RoomListSearchState, state: RoomListSearchState,
hideInvitesAvatars: Boolean, hideInvitesAvatars: Boolean,
eventSink: (RoomListEvents) -> Unit, eventSink: (RoomListEvent) -> Unit,
onRoomClick: (RoomId) -> Unit, onRoomClick: (RoomId) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
BackHandler(enabled = state.isSearchActive) { BackHandler(enabled = state.isSearchActive) {
state.eventSink(RoomListSearchEvents.ToggleSearchVisibility) state.eventSink(RoomListSearchEvent.ToggleSearchVisibility)
} }
AnimatedVisibility( AnimatedVisibility(
@ -83,13 +85,13 @@ internal fun RoomListSearchView(
private fun RoomListSearchContent( private fun RoomListSearchContent(
state: RoomListSearchState, state: RoomListSearchState,
hideInvitesAvatars: Boolean, hideInvitesAvatars: Boolean,
eventSink: (RoomListEvents) -> Unit, eventSink: (RoomListEvent) -> Unit,
onRoomClick: (RoomId) -> Unit, onRoomClick: (RoomId) -> Unit,
) { ) {
val borderColor = MaterialTheme.colorScheme.tertiary val borderColor = MaterialTheme.colorScheme.tertiary
val strokeWidth = 1.dp val strokeWidth = 1.dp
fun onBackButtonClick() { fun onBackButtonClick() {
state.eventSink(RoomListSearchEvents.ToggleSearchVisibility) state.eventSink(RoomListSearchEvent.ToggleSearchVisibility)
} }
fun onRoomClick(room: RoomListRoomSummary) { fun onRoomClick(room: RoomListRoomSummary) {
@ -127,7 +129,7 @@ private fun RoomListSearchContent(
), ),
trailingIcon = if (state.query.text.isNotEmpty()) { trailingIcon = if (state.query.text.isNotEmpty()) {
@Composable { @Composable {
IconButton(onClick = { state.eventSink(RoomListSearchEvents.ClearQuery) }) { IconButton(onClick = { state.eventSink(RoomListSearchEvent.ClearQuery) }) {
Icon( Icon(
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_cancel) contentDescription = stringResource(CommonStrings.action_cancel)
@ -154,7 +156,12 @@ private fun RoomListSearchContent(
.padding(padding) .padding(padding)
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
) { ) {
val lazyListState = rememberLazyListState()
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
state.eventSink(RoomListSearchEvent.UpdateVisibleRange(visibleRange))
}
LazyColumn( LazyColumn(
state = lazyListState,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { ) {
items( items(

View file

@ -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
}
}

View file

@ -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<SpaceFiltersState> {
@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>(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
}

View file

@ -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<SpaceServiceFilter>,
val searchQuery: TextFieldState,
val eventSink: (SpaceFiltersEvent.Selecting) -> Unit,
) : SpaceFiltersState {
val visibleFilters: ImmutableList<SpaceServiceFilter>
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) }
}

View file

@ -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<SpaceFiltersState> {
override val values: Sequence<SpaceFiltersState>
get() = sequenceOf(
aSelectingSpaceFiltersState(),
aSelectingSpaceFiltersState(searchQuery = "Pr")
)
}
fun aDisabledSpaceFiltersState() = SpaceFiltersState.Disabled
fun anUnselectedSpaceFiltersState(
eventSink: (SpaceFiltersEvent.Unselected) -> Unit = {},
) = SpaceFiltersState.Unselected(
eventSink = eventSink,
)
fun aSelectingSpaceFiltersState(
availableFilters: List<SpaceServiceFilter> = 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<RoomId> = emptyList(),
) = SpaceServiceFilter(
spaceRoom = aSpaceRoom(displayName = displayName, roomId = roomId, canonicalAlias = canonicalAlias),
level = level,
descendants = descendants,
)

View file

@ -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<SpaceServiceFilter>,
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)
}

View file

@ -36,7 +36,7 @@ class HomeSpacesPresenter(
val canCreateSpaces by featureFlagsService.isFeatureEnabledFlow(FeatureFlags.CreateSpaces).collectAsState(false) val canCreateSpaces by featureFlagsService.isFeatureEnabledFlow(FeatureFlags.CreateSpaces).collectAsState(false)
val hideInvitesAvatar by client.rememberHideInvitesAvatar() val hideInvitesAvatar by client.rememberHideInvitesAvatar()
val spaceRooms by remember { val spaceRooms by remember {
client.spaceService.spaceRoomsFlow.map { it.toImmutableList() } client.spaceService.topLevelSpacesFlow.map { it.toImmutableList() }
}.collectAsState(persistentListOf()) }.collectAsState(persistentListOf())
val seenSpaceInvites by remember { val seenSpaceInvites by remember {

View file

@ -76,6 +76,7 @@ fun HomeSpacesView(
item { item {
SpaceHeaderView( SpaceHeaderView(
avatarData = space.spaceRoom.getAvatarData(AvatarSize.SpaceHeader), avatarData = space.spaceRoom.getAvatarData(AvatarSize.SpaceHeader),
alias = space.spaceRoom.canonicalAlias,
name = space.spaceRoom.displayName, name = space.spaceRoom.displayName,
topic = space.spaceRoom.topic, topic = space.spaceRoom.topic,
visibility = space.spaceRoom.visibility, visibility = space.spaceRoom.visibility,

View file

@ -50,6 +50,7 @@ Nemáte žádné nepřečtené zprávy!"</string>
<string name="screen_roomlist_mark_as_read">"Označit jako přečtené"</string> <string name="screen_roomlist_mark_as_read">"Označit jako přečtené"</string>
<string name="screen_roomlist_mark_as_unread">"Označit jako nepřečtené"</string> <string name="screen_roomlist_mark_as_unread">"Označit jako nepřečtené"</string>
<string name="screen_roomlist_tombstoned_room_description">"Tato místnost byla aktualizována"</string> <string name="screen_roomlist_tombstoned_room_description">"Tato místnost byla aktualizována"</string>
<string name="screen_roomlist_your_spaces">"Vaše prostory"</string>
<string name="session_verification_banner_message">"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."</string> <string name="session_verification_banner_message">"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."</string>
<string name="session_verification_banner_title">"Ověřte, že jste to vy"</string> <string name="session_verification_banner_title">"Ověřte, že jste to vy"</string>
</resources> </resources>

View file

@ -50,6 +50,7 @@ Sul pole ühtegi lugemata sõnumit!"</string>
<string name="screen_roomlist_mark_as_read">"Märgi loetuks"</string> <string name="screen_roomlist_mark_as_read">"Märgi loetuks"</string>
<string name="screen_roomlist_mark_as_unread">"Märgi mitteloetuks"</string> <string name="screen_roomlist_mark_as_unread">"Märgi mitteloetuks"</string>
<string name="screen_roomlist_tombstoned_room_description">"See jututuba on uuendatud"</string> <string name="screen_roomlist_tombstoned_room_description">"See jututuba on uuendatud"</string>
<string name="screen_roomlist_your_spaces">"Sinu kogukonnad"</string>
<string name="session_verification_banner_message">"Tundub, et kasutad uut seadet. Oma krüptitud sõnumite lugemiseks verifitseeri ta mõne muu oma seadmega."</string> <string name="session_verification_banner_message">"Tundub, et kasutad uut seadet. Oma krüptitud sõnumite lugemiseks verifitseeri ta mõne muu oma seadmega."</string>
<string name="session_verification_banner_title">"Verifitseeri, et see oled sina"</string> <string name="session_verification_banner_title">"Verifitseeri, et see oled sina"</string>
</resources> </resources>

View file

@ -8,9 +8,6 @@
package io.element.android.features.home.impl 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 com.google.common.truth.Truth.assertThat
import io.element.android.features.announcement.api.Announcement import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.announcement.api.AnnouncementService
@ -70,9 +67,7 @@ class HomePresenterTest {
), ),
), ),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo( assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo(
MatrixUser(A_USER_ID, null, null) MatrixUser(A_USER_ID, null, null)
@ -96,9 +91,7 @@ class HomePresenterTest {
updateUserProfileResult = { _, _, _ -> }, updateUserProfileResult = { _, _, _ -> },
), ),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.canReportBug).isFalse() assertThat(initialState.canReportBug).isFalse()
val finalState = awaitItem() val finalState = awaitItem()
@ -115,9 +108,7 @@ class HomePresenterTest {
updateUserProfileResult = { _, _, _ -> }, updateUserProfileResult = { _, _, _ -> },
), ),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showAvatarIndicator).isFalse() assertThat(initialState.showAvatarIndicator).isFalse()
indicatorService.setShowRoomListTopBarIndicator(true) indicatorService.setShowRoomListTopBarIndicator(true)
@ -139,9 +130,7 @@ class HomePresenterTest {
updateUserProfileResult = { _, _, _ -> }, updateUserProfileResult = { _, _, _ -> },
), ),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo(MatrixUser(matrixClient.sessionId)) assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo(MatrixUser(matrixClient.sessionId))
// No new state is coming // No new state is coming
@ -159,12 +148,10 @@ class HomePresenterTest {
showAnnouncementResult = showAnnouncementResult, showAnnouncementResult = showAnnouncementResult,
) )
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats) assertThat(initialState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats)
initialState.eventSink(HomeEvents.SelectHomeNavigationBarItem(HomeNavigationBarItem.Spaces)) initialState.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Spaces))
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces) assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces)
showAnnouncementResult.assertions().isCalledOnce() showAnnouncementResult.assertions().isCalledOnce()
@ -189,7 +176,7 @@ class HomePresenterTest {
assertThat(initialState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats) assertThat(initialState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats)
assertThat(initialState.showNavigationBar).isTrue() assertThat(initialState.showNavigationBar).isTrue()
// User navigate to Spaces // User navigate to Spaces
initialState.eventSink(HomeEvents.SelectHomeNavigationBarItem(HomeNavigationBarItem.Spaces)) initialState.eventSink(HomeEvent.SelectHomeNavigationBarItem(HomeNavigationBarItem.Spaces))
val spaceState = awaitItem() val spaceState = awaitItem()
assertThat(spaceState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces) assertThat(spaceState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces)
// The last space is left // The last space is left

View file

@ -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.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService 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.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.roomlist.FakeRoomListService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -27,9 +29,13 @@ import java.time.Instant
class RoomListDataSourceTest { class RoomListDataSourceTest {
@Test @Test
fun `when DateTimeObserver gets a date change, the room summaries are refreshed`() = runTest { 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) postState(RoomListService.State.Running)
postAllRooms(listOf(aRoomSummary()))
} }
val dateTimeObserver = FakeDateTimeObserver() val dateTimeObserver = FakeDateTimeObserver()
var dateFormatterResult = "Today" var dateFormatterResult = "Today"
@ -42,7 +48,7 @@ class RoomListDataSourceTest {
dateTimeObserver = dateTimeObserver, dateTimeObserver = dateTimeObserver,
) )
roomListDataSource.allRooms.test { roomListDataSource.roomSummariesFlow.test {
// Observe room list items changes // Observe room list items changes
roomListDataSource.launchIn(backgroundScope) roomListDataSource.launchIn(backgroundScope)
// Get the initial room list // Get the initial room list
@ -61,9 +67,11 @@ class RoomListDataSourceTest {
@Test @Test
fun `when DateTimeObserver gets a time zone change, the room summaries are refreshed`() = runTest { 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) postState(RoomListService.State.Running)
postAllRooms(listOf(aRoomSummary()))
} }
val dateTimeObserver = FakeDateTimeObserver() val dateTimeObserver = FakeDateTimeObserver()
var dateFormatterResult = "Today" var dateFormatterResult = "Today"
@ -75,7 +83,7 @@ class RoomListDataSourceTest {
), ),
dateTimeObserver = dateTimeObserver, dateTimeObserver = dateTimeObserver,
) )
roomListDataSource.allRooms.test { roomListDataSource.roomSummariesFlow.test {
// Observe room list items changes // Observe room list items changes
roomListDataSource.launchIn(backgroundScope) roomListDataSource.launchIn(backgroundScope)
// Get the initial room list // Get the initial room list

View file

@ -16,14 +16,14 @@ class RoomListFiltersEmptyStateResourcesTest {
@Test @Test
fun `fromSelectedFilters should return null when selectedFilters is empty`() { fun `fromSelectedFilters should return null when selectedFilters is empty`() {
val selectedFilters = emptyList<RoomListFilter>() val selectedFilters = emptyList<RoomListFilter>()
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters) val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected = false)
assertThat(result).isNull() assertThat(result).isNull()
} }
@Test @Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only unread filter`() { fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only unread filter`() {
val selectedFilters = listOf(RoomListFilter.Unread) val selectedFilters = listOf(RoomListFilter.Unread)
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters) val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected = false)
assertThat(result).isNotNull() assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_unreads_empty_state_title) 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) assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
@ -32,7 +32,7 @@ class RoomListFiltersEmptyStateResourcesTest {
@Test @Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only people filter`() { fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only people filter`() {
val selectedFilters = listOf(RoomListFilter.People) val selectedFilters = listOf(RoomListFilter.People)
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters) val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected = false)
assertThat(result).isNotNull() assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_people_empty_state_title) 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) assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
@ -41,7 +41,7 @@ class RoomListFiltersEmptyStateResourcesTest {
@Test @Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only rooms filter`() { fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only rooms filter`() {
val selectedFilters = listOf(RoomListFilter.Rooms) val selectedFilters = listOf(RoomListFilter.Rooms)
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters) val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected = false)
assertThat(result).isNotNull() assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_rooms_empty_state_title) 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) assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
@ -50,7 +50,7 @@ class RoomListFiltersEmptyStateResourcesTest {
@Test @Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only favourites filter`() { fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only favourites filter`() {
val selectedFilters = listOf(RoomListFilter.Favourites) val selectedFilters = listOf(RoomListFilter.Favourites)
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters) val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected = false)
assertThat(result).isNotNull() assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_favourites_empty_state_title) 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) assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_favourites_empty_state_subtitle)
@ -59,7 +59,7 @@ class RoomListFiltersEmptyStateResourcesTest {
@Test @Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only invites filter`() { fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only invites filter`() {
val selectedFilters = listOf(RoomListFilter.Invites) val selectedFilters = listOf(RoomListFilter.Invites)
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters) val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected = false)
assertThat(result).isNotNull() assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_invites_empty_state_title) 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) assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
@ -68,7 +68,15 @@ class RoomListFiltersEmptyStateResourcesTest {
@Test @Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has multiple filters`() { fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has multiple filters`() {
val selectedFilters = listOf(RoomListFilter.Unread, RoomListFilter.People, RoomListFilter.Rooms, RoomListFilter.Favourites) 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).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_title) 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) assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)

View file

@ -8,26 +8,22 @@
package io.element.android.features.home.impl.filters 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 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.DefaultFilterSelectionStrategy
import io.element.android.features.home.impl.filters.selection.FilterSelectionState 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.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 kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter
class RoomListFiltersPresenterTest { class RoomListFiltersPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createRoomListFiltersPresenter() val presenter = createRoomListFiltersPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.hasAnyFilterSelected).isFalse() assertThat(state.hasAnyFilterSelected).isFalse()
assertThat(state.filterSelectionStates).containsExactly( assertThat(state.filterSelectionStates).containsExactly(
@ -43,15 +39,12 @@ class RoomListFiltersPresenterTest {
} }
@Test @Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `present - toggle rooms filter`() = runTest { fun `present - toggle rooms filter`() = runTest {
val roomListService = FakeRoomListService() val presenter = createRoomListFiltersPresenter()
val presenter = createRoomListFiltersPresenter(roomListService) presenter.test {
moleculeFlow(RecompositionMode.Immediate) { awaitItem().eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms))
presenter.present()
}.test {
awaitItem().eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms))
awaitLastSequentialItem().let { state -> awaitLastSequentialItem().let { state ->
assertThat(state.hasAnyFilterSelected).isTrue() assertThat(state.hasAnyFilterSelected).isTrue()
assertThat(state.filterSelectionStates).containsExactly( assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.Rooms, true), filterSelectionState(RoomListFilter.Rooms, true),
@ -62,12 +55,9 @@ class RoomListFiltersPresenterTest {
assertThat(state.selectedFilters()).containsExactly( assertThat(state.selectedFilters()).containsExactly(
RoomListFilter.Rooms, RoomListFilter.Rooms,
) )
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms))
assertThat(roomListCurrentFilter.filters).containsExactly(
MatrixRoomListFilter.Category.Group,
)
state.eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms))
} }
advanceUntilIdle()
awaitLastSequentialItem().let { state -> awaitLastSequentialItem().let { state ->
assertThat(state.hasAnyFilterSelected).isFalse() assertThat(state.hasAnyFilterSelected).isFalse()
assertThat(state.filterSelectionStates).containsExactly( assertThat(state.filterSelectionStates).containsExactly(
@ -78,24 +68,21 @@ class RoomListFiltersPresenterTest {
filterSelectionState(RoomListFilter.Invites, false), filterSelectionState(RoomListFilter.Invites, false),
).inOrder() ).inOrder()
assertThat(state.selectedFilters()).isEmpty() assertThat(state.selectedFilters()).isEmpty()
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
assertThat(roomListCurrentFilter.filters).isEmpty()
} }
} }
} }
@Test @Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `present - clear filters event`() = runTest { fun `present - clear filters event`() = runTest {
val roomListService = FakeRoomListService() val presenter = createRoomListFiltersPresenter()
val presenter = createRoomListFiltersPresenter(roomListService) presenter.test {
moleculeFlow(RecompositionMode.Immediate) { awaitItem().eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms))
presenter.present()
}.test {
awaitItem().eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms))
awaitLastSequentialItem().let { state -> awaitLastSequentialItem().let { state ->
assertThat(state.hasAnyFilterSelected).isTrue() assertThat(state.hasAnyFilterSelected).isTrue()
state.eventSink.invoke(RoomListFiltersEvents.ClearSelectedFilters) state.eventSink.invoke(RoomListFiltersEvent.ClearSelectedFilters)
} }
advanceUntilIdle()
awaitLastSequentialItem().let { state -> awaitLastSequentialItem().let { state ->
assertThat(state.hasAnyFilterSelected).isFalse() assertThat(state.hasAnyFilterSelected).isFalse()
} }
@ -108,11 +95,8 @@ private fun filterSelectionState(filter: RoomListFilter, selected: Boolean) = Fi
isSelected = selected, isSelected = selected,
) )
private fun createRoomListFiltersPresenter( private fun TestScope.createRoomListFiltersPresenter(): RoomListFiltersPresenter {
roomListService: RoomListService = FakeRoomListService(),
): RoomListFiltersPresenter {
return RoomListFiltersPresenter( return RoomListFiltersPresenter(
roomListService = roomListService,
filterSelectionStrategy = DefaultFilterSelectionStrategy(), filterSelectionStrategy = DefaultFilterSelectionStrategy(),
) )
} }

View file

@ -27,7 +27,7 @@ class RoomListFiltersViewTest {
@Test @Test
fun `clicking on filters generates expected Event`() { fun `clicking on filters generates expected Event`() {
val eventsRecorder = EventsRecorder<RoomListFiltersEvents>() val eventsRecorder = EventsRecorder<RoomListFiltersEvent>()
rule.setContent { rule.setContent {
RoomListFiltersView( RoomListFiltersView(
state = aRoomListFiltersState(eventSink = eventsRecorder), state = aRoomListFiltersState(eventSink = eventsRecorder),
@ -36,14 +36,14 @@ class RoomListFiltersViewTest {
rule.clickOn(R.string.screen_roomlist_filter_rooms) rule.clickOn(R.string.screen_roomlist_filter_rooms)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms), RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms),
) )
) )
} }
@Test @Test
fun `clicking on clear filters generates expected Event`() { fun `clicking on clear filters generates expected Event`() {
val eventsRecorder = EventsRecorder<RoomListFiltersEvents>() val eventsRecorder = EventsRecorder<RoomListFiltersEvent>()
rule.setContent { rule.setContent {
RoomListFiltersView( RoomListFiltersView(
state = aRoomListFiltersState( state = aRoomListFiltersState(
@ -55,7 +55,7 @@ class RoomListFiltersViewTest {
rule.pressTag(TestTags.homeScreenClearFilters.value) rule.pressTag(TestTags.homeScreenClearFilters.value)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
RoomListFiltersEvents.ClearSelectedFilters, RoomListFiltersEvent.ClearSelectedFilters,
) )
) )
} }

View file

@ -30,7 +30,7 @@ class RoomListContextMenuTest {
@Test @Test
fun `clicking on Mark as read generates expected Events`() { fun `clicking on Mark as read generates expected Events`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val contextMenu = aContextMenuShown(hasNewContent = true) val contextMenu = aContextMenuShown(hasNewContent = true)
rule.setRoomListContextMenu( rule.setRoomListContextMenu(
contextMenu = contextMenu, contextMenu = contextMenu,
@ -39,15 +39,15 @@ class RoomListContextMenuTest {
rule.clickOn(R.string.screen_roomlist_mark_as_read) rule.clickOn(R.string.screen_roomlist_mark_as_read)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
RoomListEvents.HideContextMenu, RoomListEvent.HideContextMenu,
RoomListEvents.MarkAsRead(contextMenu.roomId), RoomListEvent.MarkAsRead(contextMenu.roomId),
) )
) )
} }
@Test @Test
fun `clicking on Mark as unread generates expected Events`() { fun `clicking on Mark as unread generates expected Events`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val contextMenu = aContextMenuShown(hasNewContent = false) val contextMenu = aContextMenuShown(hasNewContent = false)
rule.setRoomListContextMenu( rule.setRoomListContextMenu(
contextMenu = contextMenu, contextMenu = contextMenu,
@ -56,15 +56,15 @@ class RoomListContextMenuTest {
rule.clickOn(R.string.screen_roomlist_mark_as_unread) rule.clickOn(R.string.screen_roomlist_mark_as_unread)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
RoomListEvents.HideContextMenu, RoomListEvent.HideContextMenu,
RoomListEvents.MarkAsUnread(contextMenu.roomId), RoomListEvent.MarkAsUnread(contextMenu.roomId),
) )
) )
} }
@Test @Test
fun `clicking on Leave room generates expected Events`() { fun `clicking on Leave room generates expected Events`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val contextMenu = aContextMenuShown(isDm = false) val contextMenu = aContextMenuShown(isDm = false)
rule.setRoomListContextMenu( rule.setRoomListContextMenu(
contextMenu = contextMenu, contextMenu = contextMenu,
@ -73,15 +73,15 @@ class RoomListContextMenuTest {
rule.clickOn(CommonStrings.action_leave_room) rule.clickOn(CommonStrings.action_leave_room)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
RoomListEvents.HideContextMenu, RoomListEvent.HideContextMenu,
RoomListEvents.LeaveRoom(contextMenu.roomId, needsConfirmation = true), RoomListEvent.LeaveRoom(contextMenu.roomId, needsConfirmation = true),
) )
) )
} }
@Test @Test
fun `clicking on Report room invokes the expected callback and generates expected Event`() { fun `clicking on Report room invokes the expected callback and generates expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val contextMenu = aContextMenuShown() val contextMenu = aContextMenuShown()
val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit) val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit)
rule.setRoomListContextMenu( rule.setRoomListContextMenu(
@ -92,13 +92,13 @@ class RoomListContextMenuTest {
onReportRoomClick = callback, onReportRoomClick = callback,
) )
rule.clickOn(CommonStrings.action_report_room) rule.clickOn(CommonStrings.action_report_room)
eventsRecorder.assertSingle(RoomListEvents.HideContextMenu) eventsRecorder.assertSingle(RoomListEvent.HideContextMenu)
callback.assertSuccess() callback.assertSuccess()
} }
@Test @Test
fun `clicking on Settings invokes the expected callback and generates expected Event`() { fun `clicking on Settings invokes the expected callback and generates expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val contextMenu = aContextMenuShown() val contextMenu = aContextMenuShown()
val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit) val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit)
rule.setRoomListContextMenu( rule.setRoomListContextMenu(
@ -107,13 +107,13 @@ class RoomListContextMenuTest {
onRoomSettingsClick = callback, onRoomSettingsClick = callback,
) )
rule.clickOn(CommonStrings.common_settings) rule.clickOn(CommonStrings.common_settings)
eventsRecorder.assertSingle(RoomListEvents.HideContextMenu) eventsRecorder.assertSingle(RoomListEvent.HideContextMenu)
callback.assertSuccess() callback.assertSuccess()
} }
@Test @Test
fun `clicking on Favourites generates expected Event`() { fun `clicking on Favourites generates expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val contextMenu = aContextMenuShown(isDm = false, isFavorite = false) val contextMenu = aContextMenuShown(isDm = false, isFavorite = false)
val callback = EnsureNeverCalledWithParam<RoomId>() val callback = EnsureNeverCalledWithParam<RoomId>()
rule.setRoomListContextMenu( rule.setRoomListContextMenu(
@ -124,7 +124,7 @@ class RoomListContextMenuTest {
rule.clickOn(CommonStrings.common_favourite) rule.clickOn(CommonStrings.common_favourite)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
RoomListEvents.SetRoomIsFavorite(contextMenu.roomId, true), RoomListEvent.SetRoomIsFavorite(contextMenu.roomId, true),
) )
) )
} }
@ -132,7 +132,7 @@ class RoomListContextMenuTest {
private fun AndroidComposeTestRule<*, *>.setRoomListContextMenu( private fun AndroidComposeTestRule<*, *>.setRoomListContextMenu(
contextMenu: RoomListState.ContextMenu.Shown, contextMenu: RoomListState.ContextMenu.Shown,
canReportRoom: Boolean = false, canReportRoom: Boolean = false,
eventSink: (RoomListEvents) -> Unit, eventSink: (RoomListEvent) -> Unit,
onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
) { ) {

View file

@ -28,7 +28,7 @@ class RoomListDeclineInviteMenuTest {
@Test @Test
fun `clicking on decline emits the expected Events`() { fun `clicking on decline emits the expected Events`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary()) val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setSafeContent { rule.setSafeContent {
RoomListDeclineInviteMenu( RoomListDeclineInviteMenu(
@ -41,15 +41,15 @@ class RoomListDeclineInviteMenuTest {
rule.clickOn(CommonStrings.action_decline) rule.clickOn(CommonStrings.action_decline)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
RoomListEvents.HideDeclineInviteMenu, RoomListEvent.HideDeclineInviteMenu,
RoomListEvents.DeclineInvite(menu.roomSummary, blockUser = false), RoomListEvent.DeclineInvite(menu.roomSummary, blockUser = false),
) )
) )
} }
@Test @Test
fun `clicking on decline and block when canReportRoom=true, it emits the expected Events and callback`() { fun `clicking on decline and block when canReportRoom=true, it emits the expected Events and callback`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary()) val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setSafeContent { rule.setSafeContent {
RoomListDeclineInviteMenu( RoomListDeclineInviteMenu(
@ -60,13 +60,13 @@ class RoomListDeclineInviteMenuTest {
) )
} }
rule.clickOn(CommonStrings.action_decline_and_block) rule.clickOn(CommonStrings.action_decline_and_block)
val expectedEvents = listOf(RoomListEvents.HideDeclineInviteMenu) val expectedEvents = listOf(RoomListEvent.HideDeclineInviteMenu)
eventsRecorder.assertList(expectedEvents) eventsRecorder.assertList(expectedEvents)
} }
@Test @Test
fun `clicking on decline and block when canReportRoom=false, it emits the expected Events`() { fun `clicking on decline and block when canReportRoom=false, it emits the expected Events`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary()) val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setSafeContent { rule.setSafeContent {
RoomListDeclineInviteMenu( RoomListDeclineInviteMenu(
@ -78,15 +78,15 @@ class RoomListDeclineInviteMenuTest {
} }
rule.clickOn(CommonStrings.action_decline_and_block) rule.clickOn(CommonStrings.action_decline_and_block)
val expectedEvents = listOf( val expectedEvents = listOf(
RoomListEvents.HideDeclineInviteMenu, RoomListEvent.HideDeclineInviteMenu,
RoomListEvents.DeclineInvite(menu.roomSummary, blockUser = true), RoomListEvent.DeclineInvite(menu.roomSummary, blockUser = true),
) )
eventsRecorder.assertList(expectedEvents) eventsRecorder.assertList(expectedEvents)
} }
@Test @Test
fun `clicking on cancel emits the expected Event`() { fun `clicking on cancel emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary()) val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setSafeContent { rule.setSafeContent {
RoomListDeclineInviteMenu( RoomListDeclineInviteMenu(
@ -97,6 +97,6 @@ class RoomListDeclineInviteMenuTest {
) )
} }
rule.clickOn(CommonStrings.action_cancel) rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertList(listOf(RoomListEvents.HideDeclineInviteMenu)) eventsRecorder.assertList(listOf(RoomListEvent.HideDeclineInviteMenu))
} }
} }

View file

@ -8,9 +8,6 @@
package io.element.android.features.home.impl.roomlist 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 com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Interaction import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.announcement.api.Announcement 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.RoomListFiltersState
import io.element.android.features.home.impl.filters.aRoomListFiltersState 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.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.RoomListSearchState
import io.element.android.features.home.impl.search.aRoomListSearchState 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.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState 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.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember 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.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.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService 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.test
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceTimeBy
@ -94,7 +95,10 @@ class RoomListPresenterTest {
@Test @Test
fun `present - load 1 room with success`() = runTest { fun `present - load 1 room with success`() = runTest {
val roomListService = FakeRoomListService() val roomList = FakeDynamicRoomList()
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
roomListService = roomListService roomListService = roomListService
) )
@ -105,8 +109,8 @@ class RoomListPresenterTest {
presenter.test { presenter.test {
val initialState = consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Skeleton }.last() val initialState = consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Skeleton }.last()
assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Skeleton::class.java) assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Skeleton::class.java)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) roomList.loadingState.emit(RoomList.LoadingState.Loaded(1))
roomListService.postAllRooms( roomList.summaries.emit(
listOf( listOf(
aRoomSummary( aRoomSummary(
numUnreadMentions = 1, numUnreadMentions = 1,
@ -131,9 +135,12 @@ class RoomListPresenterTest {
@Test @Test
fun `present - handle DismissRequestVerificationPrompt`() = runTest { fun `present - handle DismissRequestVerificationPrompt`() = runTest {
val roomListService = FakeRoomListService().apply { val roomList = FakeDynamicRoomList(
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
} )
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val encryptionService = FakeEncryptionService().apply { val encryptionService = FakeEncryptionService().apply {
emitRecoveryState(RecoveryState.INCOMPLETE) emitRecoveryState(RecoveryState.INCOMPLETE)
} }
@ -141,15 +148,13 @@ class RoomListPresenterTest {
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
client = FakeMatrixClient(roomListService = roomListService, encryptionService = encryptionService, syncService = syncService), client = FakeMatrixClient(roomListService = roomListService, encryptionService = encryptionService, syncService = syncService),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val eventWithContentAsRooms = consumeItemsUntilPredicate { val eventWithContentAsRooms = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms it.contentState is RoomListContentState.Rooms
}.last() }.last()
val eventSink = eventWithContentAsRooms.eventSink val eventSink = eventWithContentAsRooms.eventSink
assertThat(eventWithContentAsRooms.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation) assertThat(eventWithContentAsRooms.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation)
eventSink(RoomListEvents.DismissRequestVerificationPrompt) eventSink(RoomListEvent.DismissRequestVerificationPrompt)
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None) assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
} }
} }
@ -159,9 +164,12 @@ class RoomListPresenterTest {
val encryptionService = FakeEncryptionService().apply { val encryptionService = FakeEncryptionService().apply {
recoveryStateStateFlow.emit(RecoveryState.DISABLED) recoveryStateStateFlow.emit(RecoveryState.DISABLED)
} }
val roomListService = FakeRoomListService().apply { val roomList = FakeDynamicRoomList(
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
} )
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
roomListService = roomListService, roomListService = roomListService,
encryptionService = encryptionService, encryptionService = encryptionService,
@ -173,9 +181,7 @@ class RoomListPresenterTest {
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
client = matrixClient, client = matrixClient,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = consumeItemsUntilPredicate { val initialState = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms it.contentState is RoomListContentState.Rooms
}.last() }.last()
@ -194,7 +200,7 @@ class RoomListPresenterTest {
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None) assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
encryptionService.emitRecoveryState(RecoveryState.DISABLED) encryptionService.emitRecoveryState(RecoveryState.DISABLED)
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.SetUpRecovery) assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.SetUpRecovery)
nextState.eventSink(RoomListEvents.DismissBanner) nextState.eventSink(RoomListEvent.DismissBanner)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None) assertThat(finalState.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
} }
@ -207,12 +213,10 @@ class RoomListPresenterTest {
givenGetRoomResult(A_ROOM_ID, room) givenGetRoomResult(A_ROOM_ID, room)
} }
val presenter = createRoomListPresenter(client = client) val presenter = createRoomListPresenter(client = client)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
val summary = createRoomListRoomSummary() val summary = createRoomListRoomSummary()
initialState.eventSink(RoomListEvents.ShowContextMenu(summary)) initialState.eventSink(RoomListEvent.ShowContextMenu(summary))
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.contextMenu) assertThat(state.contextMenu)
@ -257,7 +261,7 @@ class RoomListPresenterTest {
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
val summary = createRoomListRoomSummary() val summary = createRoomListRoomSummary()
initialState.eventSink(RoomListEvents.ShowContextMenu(summary)) initialState.eventSink(RoomListEvent.ShowContextMenu(summary))
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.contextMenu) assertThat(state.contextMenu)
.isEqualTo( .isEqualTo(
@ -282,12 +286,10 @@ class RoomListPresenterTest {
givenGetRoomResult(A_ROOM_ID, room) givenGetRoomResult(A_ROOM_ID, room)
} }
val presenter = createRoomListPresenter(client = client) val presenter = createRoomListPresenter(client = client)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
val summary = createRoomListRoomSummary() val summary = createRoomListRoomSummary()
initialState.eventSink(RoomListEvents.ShowContextMenu(summary)) initialState.eventSink(RoomListEvent.ShowContextMenu(summary))
val shownState = awaitItem() val shownState = awaitItem()
assertThat(shownState.contextMenu) assertThat(shownState.contextMenu)
@ -302,7 +304,7 @@ class RoomListPresenterTest {
) )
) )
shownState.eventSink(RoomListEvents.HideContextMenu) shownState.eventSink(RoomListEvent.HideContextMenu)
val hiddenState = awaitItem() val hiddenState = awaitItem()
assertThat(hiddenState.contextMenu).isEqualTo(RoomListState.ContextMenu.Hidden) assertThat(hiddenState.contextMenu).isEqualTo(RoomListState.ContextMenu.Hidden)
@ -315,11 +317,9 @@ class RoomListPresenterTest {
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
leaveRoomState = aLeaveRoomState(eventSink = leaveRoomEventsRecorder), leaveRoomState = aLeaveRoomState(eventSink = leaveRoomEventsRecorder),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() 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)) leaveRoomEventsRecorder.assertSingle(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
@ -327,7 +327,7 @@ class RoomListPresenterTest {
@Test @Test
fun `present - toggle search menu`() = runTest { fun `present - toggle search menu`() = runTest {
val eventRecorder = EventsRecorder<RoomListSearchEvents>() val eventRecorder = EventsRecorder<RoomListSearchEvent>()
val searchPresenter: Presenter<RoomListSearchState> = Presenter { val searchPresenter: Presenter<RoomListSearchState> = Presenter {
aRoomListSearchState( aRoomListSearchState(
eventSink = eventRecorder eventSink = eventRecorder
@ -336,20 +336,18 @@ class RoomListPresenterTest {
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
searchPresenter = searchPresenter, searchPresenter = searchPresenter,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
eventRecorder.assertEmpty() eventRecorder.assertEmpty()
initialState.eventSink(RoomListEvents.ToggleSearchResults) initialState.eventSink(RoomListEvent.ToggleSearchResults)
eventRecorder.assertSingle( eventRecorder.assertSingle(
RoomListSearchEvents.ToggleSearchVisibility RoomListSearchEvent.ToggleSearchVisibility
) )
initialState.eventSink(RoomListEvents.ToggleSearchResults) initialState.eventSink(RoomListEvent.ToggleSearchResults)
eventRecorder.assertList( eventRecorder.assertList(
listOf( listOf(
RoomListSearchEvents.ToggleSearchVisibility, RoomListSearchEvent.ToggleSearchVisibility,
RoomListSearchEvents.ToggleSearchVisibility RoomListSearchEvent.ToggleSearchVisibility
) )
) )
} }
@ -359,17 +357,19 @@ class RoomListPresenterTest {
fun `present - change in notification settings updates the summary for decorations`() = runTest { fun `present - change in notification settings updates the summary for decorations`() = runTest {
val userDefinedMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY val userDefinedMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
val notificationSettingsService = FakeNotificationSettingsService() val notificationSettingsService = FakeNotificationSettingsService()
val roomListService = FakeRoomListService() val roomList = FakeDynamicRoomList(
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) summaries = MutableStateFlow(listOf(aRoomSummary(userDefinedNotificationMode = userDefinedMode))),
roomListService.postAllRooms(listOf(aRoomSummary(userDefinedNotificationMode = userDefinedMode))) loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
roomListService = roomListService, roomListService = roomListService,
notificationSettingsService = notificationSettingsService notificationSettingsService = notificationSettingsService
) )
val presenter = createRoomListPresenter(client = matrixClient) val presenter = createRoomListPresenter(client = matrixClient)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
notificationSettingsService.setRoomNotificationMode(A_ROOM_ID, userDefinedMode) notificationSettingsService.setRoomNotificationMode(A_ROOM_ID, userDefinedMode)
val updatedState = consumeItemsUntilPredicate { state -> val updatedState = consumeItemsUntilPredicate { state ->
(state.contentState as? RoomListContentState.Rooms)?.summaries.orEmpty().any { summary -> (state.contentState as? RoomListContentState.Rooms)?.summaries.orEmpty().any { summary ->
@ -394,13 +394,11 @@ class RoomListPresenterTest {
givenGetRoomResult(A_ROOM_ID, room) givenGetRoomResult(A_ROOM_ID, room)
} }
val presenter = createRoomListPresenter(client = client, analyticsService = analyticsService) val presenter = createRoomListPresenter(client = client, analyticsService = analyticsService)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() 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)) 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) setIsFavoriteResult.assertions().isCalledExactly(2)
.withSequence( .withSequence(
listOf(value(true)), listOf(value(true)),
@ -416,17 +414,19 @@ class RoomListPresenterTest {
@Test @Test
fun `present - when room service returns no room, then contentState is Empty`() = runTest { fun `present - when room service returns no room, then contentState is Empty`() = runTest {
val roomListService = FakeRoomListService() val roomList = FakeDynamicRoomList(
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(0)) loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(0))
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
roomListService = roomListService, roomListService = roomListService,
) )
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
client = matrixClient, client = matrixClient,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
assertThat(awaitItem().contentState).isInstanceOf(RoomListContentState.Empty::class.java) assertThat(awaitItem().contentState).isInstanceOf(RoomListContentState.Empty::class.java)
} }
} }
@ -463,23 +463,21 @@ class RoomListPresenterTest {
analyticsService = analyticsService, analyticsService = analyticsService,
notificationCleaner = notificationCleaner, notificationCleaner = notificationCleaner,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
allRooms.forEach { allRooms.forEach {
assertThat(it.setUnreadFlagCalls).isEmpty() 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)) markAsReadResult.assertions().isCalledOnce().with(value(ReceiptType.READ))
assertThat(room.setUnreadFlagCalls).isEqualTo(listOf(false)) assertThat(room.setUnreadFlagCalls).isEqualTo(listOf(false))
clearMessagesForRoomLambda.assertions().isCalledOnce() clearMessagesForRoomLambda.assertions().isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID)) .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)) assertThat(room2.setUnreadFlagCalls).isEqualTo(listOf(true))
// Test again with private read receipts // Test again with private read receipts
sessionPreferencesStore.setSendPublicReadReceipts(false) 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)) markAsReadResult3.assertions().isCalledOnce().with(value(ReceiptType.READ_PRIVATE))
assertThat(room3.setUnreadFlagCalls).isEqualTo(listOf(false)) assertThat(room3.setUnreadFlagCalls).isEqualTo(listOf(false))
clearMessagesForRoomLambda.assertions().isCalledExactly(2) clearMessagesForRoomLambda.assertions().isCalledExactly(2)
@ -502,16 +500,21 @@ class RoomListPresenterTest {
val acceptDeclinePresenter = Presenter { val acceptDeclinePresenter = Presenter {
anAcceptDeclineInviteState(eventSink = eventSinkRecorder) anAcceptDeclineInviteState(eventSink = eventSinkRecorder)
} }
val roomListService = FakeRoomListService()
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val roomSummary = aRoomSummary( val roomSummary = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED, currentUserMembership = CurrentUserMembership.INVITED,
inviter = aRoomMember(), inviter = aRoomMember(),
) )
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) val roomList = FakeDynamicRoomList(
roomListService.postAllRooms(listOf(roomSummary)) summaries = MutableStateFlow(listOf(roomSummary)),
loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
client = matrixClient, client = matrixClient,
acceptDeclineInvitePresenter = acceptDeclinePresenter acceptDeclineInvitePresenter = acceptDeclinePresenter
@ -525,8 +528,8 @@ class RoomListPresenterTest {
it.id == roomSummary.roomId.value it.id == roomSummary.roomId.value
} }
state.eventSink(RoomListEvents.AcceptInvite(roomListRoomSummary)) state.eventSink(RoomListEvent.AcceptInvite(roomListRoomSummary))
state.eventSink(RoomListEvents.DeclineInvite(roomListRoomSummary, blockUser = false)) state.eventSink(RoomListEvent.DeclineInvite(roomListRoomSummary, blockUser = false))
val inviteData = roomListRoomSummary.toInviteData() val inviteData = roomListRoomSummary.toInviteData()
assert(eventSinkRecorder) assert(eventSinkRecorder)
@ -542,15 +545,20 @@ class RoomListPresenterTest {
@Test @Test
fun `present - UpdateVisibleRange will cancel the previous subscription if called too soon`() = runTest { fun `present - UpdateVisibleRange will cancel the previous subscription if called too soon`() = runTest {
val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List<RoomId> -> } val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List<RoomId> -> }
val roomListService = FakeRoomListService(subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda)
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val roomSummary = aRoomSummary( val roomSummary = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED currentUserMembership = CurrentUserMembership.INVITED
) )
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) val roomList = FakeDynamicRoomList(
roomListService.postAllRooms(listOf(roomSummary)) 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( val presenter = createRoomListPresenter(
client = matrixClient, client = matrixClient,
) )
@ -559,9 +567,9 @@ class RoomListPresenterTest {
it.contentState is RoomListContentState.Rooms it.contentState is RoomListContentState.Rooms
}.last() }.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 // 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) advanceTimeBy(1.seconds)
subscribeToVisibleRoomsLambda.assertions().isCalledOnce() subscribeToVisibleRoomsLambda.assertions().isCalledOnce()
} }
@ -571,15 +579,20 @@ class RoomListPresenterTest {
@Test @Test
fun `present - UpdateVisibleRange subscribes to rooms in visible range`() = runTest { fun `present - UpdateVisibleRange subscribes to rooms in visible range`() = runTest {
val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List<RoomId> -> } val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List<RoomId> -> }
val roomListService = FakeRoomListService(subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda)
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val roomSummary = aRoomSummary( val roomSummary = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED currentUserMembership = CurrentUserMembership.INVITED
) )
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) val roomList = FakeDynamicRoomList(
roomListService.postAllRooms(listOf(roomSummary)) 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( val presenter = createRoomListPresenter(
client = matrixClient, client = matrixClient,
) )
@ -588,12 +601,12 @@ class RoomListPresenterTest {
it.contentState is RoomListContentState.Rooms it.contentState is RoomListContentState.Rooms
}.last() }.last()
state.eventSink(RoomListEvents.UpdateVisibleRange(IntRange(0, 10))) state.eventSink(RoomListEvent.UpdateVisibleRange(IntRange(0, 10)))
advanceTimeBy(1.seconds) advanceTimeBy(1.seconds)
subscribeToVisibleRoomsLambda.assertions().isCalledOnce() subscribeToVisibleRoomsLambda.assertions().isCalledOnce()
// If called again, it will subscribe to the next items // 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) advanceTimeBy(1.seconds)
subscribeToVisibleRoomsLambda.assertions().isCalledExactly(2) subscribeToVisibleRoomsLambda.assertions().isCalledExactly(2)
} }
@ -602,15 +615,20 @@ class RoomListPresenterTest {
@Test @Test
fun `present - notification sound banner`() = runTest { fun `present - notification sound banner`() = runTest {
val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List<RoomId> -> } val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List<RoomId> -> }
val roomListService = FakeRoomListService(subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda)
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val roomSummary = aRoomSummary( val roomSummary = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED currentUserMembership = CurrentUserMembership.INVITED
) )
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) val roomList = FakeDynamicRoomList(
roomListService.postAllRooms(listOf(roomSummary)) 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<Announcement, Unit> { } val onAnnouncementDismissedResult = lambdaRecorder<Announcement, Unit> { }
val announcementService = FakeAnnouncementService( val announcementService = FakeAnnouncementService(
onAnnouncementDismissedResult = onAnnouncementDismissedResult, onAnnouncementDismissedResult = onAnnouncementDismissedResult,
@ -626,7 +644,7 @@ class RoomListPresenterTest {
assertThat(state.contentAsRooms().showNewNotificationSoundBanner).isFalse() assertThat(state.contentAsRooms().showNewNotificationSoundBanner).isFalse()
announcementService.emitAnnouncementsToShow(listOf(Announcement.NewNotificationSound)) announcementService.emitAnnouncementsToShow(listOf(Announcement.NewNotificationSound))
assertThat(awaitItem().contentAsRooms().showNewNotificationSoundBanner).isTrue() assertThat(awaitItem().contentAsRooms().showNewNotificationSoundBanner).isTrue()
state.eventSink(RoomListEvents.DismissNewNotificationSoundBanner) state.eventSink(RoomListEvent.DismissNewNotificationSoundBanner)
onAnnouncementDismissedResult.assertions().isCalledOnce() onAnnouncementDismissedResult.assertions().isCalledOnce()
.with(value(Announcement.NewNotificationSound)) .with(value(Announcement.NewNotificationSound))
// Simulate service updating the value // Simulate service updating the value
@ -644,6 +662,7 @@ class RoomListPresenterTest {
analyticsService: AnalyticsService = FakeAnalyticsService(), analyticsService: AnalyticsService = FakeAnalyticsService(),
filtersPresenter: Presenter<RoomListFiltersState> = Presenter { aRoomListFiltersState() }, filtersPresenter: Presenter<RoomListFiltersState> = Presenter { aRoomListFiltersState() },
searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() }, searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() },
spaceFiltersPresenter: Presenter<SpaceFiltersState> = Presenter { aDisabledSpaceFiltersState() },
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() }, acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(), notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
@ -667,6 +686,7 @@ class RoomListPresenterTest {
searchPresenter = searchPresenter, searchPresenter = searchPresenter,
sessionPreferencesStore = sessionPreferencesStore, sessionPreferencesStore = sessionPreferencesStore,
filtersPresenter = filtersPresenter, filtersPresenter = filtersPresenter,
spaceFiltersPresenter = spaceFiltersPresenter,
analyticsService = analyticsService, analyticsService = analyticsService,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() }, fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() },

View file

@ -46,7 +46,7 @@ class RoomListViewTest {
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `displaying the view automatically sends a couple of UpdateVisibleRangeEvents`() { fun `displaying the view automatically sends a couple of UpdateVisibleRangeEvents`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvent>()
rule.setRoomListView( rule.setRoomListView(
state = aRoomListState( state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation), contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
@ -56,15 +56,15 @@ class RoomListViewTest {
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
RoomListEvents.UpdateVisibleRange(IntRange.EMPTY), RoomListEvent.UpdateVisibleRange(IntRange.EMPTY),
RoomListEvents.UpdateVisibleRange(0..5), RoomListEvent.UpdateVisibleRange(0..5),
) )
) )
} }
@Test @Test
fun `clicking on close recovery key banner emits the expected Event`() { fun `clicking on close recovery key banner emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvent>()
rule.setRoomListView( rule.setRoomListView(
state = aRoomListState( state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation), contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
@ -77,12 +77,12 @@ class RoomListViewTest {
val close = rule.activity.getString(CommonStrings.action_close) val close = rule.activity.getString(CommonStrings.action_close)
rule.onNodeWithContentDescription(close).performClick() rule.onNodeWithContentDescription(close).performClick()
eventsRecorder.assertSingle(RoomListEvents.DismissBanner) eventsRecorder.assertSingle(RoomListEvent.DismissBanner)
} }
@Test @Test
fun `clicking on close setup key banner emits the expected Event`() { fun `clicking on close setup key banner emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvent>()
rule.setRoomListView( rule.setRoomListView(
state = aRoomListState( state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery), contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery),
@ -95,12 +95,12 @@ class RoomListViewTest {
val close = rule.activity.getString(CommonStrings.action_close) val close = rule.activity.getString(CommonStrings.action_close)
rule.onNodeWithContentDescription(close).performClick() rule.onNodeWithContentDescription(close).performClick()
eventsRecorder.assertSingle(RoomListEvents.DismissBanner) eventsRecorder.assertSingle(RoomListEvent.DismissBanner)
} }
@Test @Test
fun `clicking on continue recovery key banner invokes the expected callback`() { fun `clicking on continue recovery key banner invokes the expected callback`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvent>()
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setRoomListView( rule.setRoomListView(
state = aRoomListState( state = aRoomListState(
@ -121,7 +121,7 @@ class RoomListViewTest {
@Test @Test
fun `clicking on continue setup key banner invokes the expected callback`() { fun `clicking on continue setup key banner invokes the expected callback`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvent>()
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setRoomListView( rule.setRoomListView(
state = aRoomListState( state = aRoomListState(
@ -139,7 +139,7 @@ class RoomListViewTest {
@Test @Test
fun `clicking on start chat when the session has no room invokes the expected callback`() { fun `clicking on start chat when the session has no room invokes the expected callback`() {
val eventsRecorder = EventsRecorder<RoomListEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<RoomListEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setRoomListView( rule.setRoomListView(
state = aRoomListState( state = aRoomListState(
@ -154,7 +154,7 @@ class RoomListViewTest {
@Test @Test
fun `clicking on a room invokes the expected callback`() { fun `clicking on a room invokes the expected callback`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val state = aRoomListState( val state = aRoomListState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
@ -178,7 +178,7 @@ class RoomListViewTest {
@Test @Test
fun `clicking on a room twice invokes the expected callback only once`() { fun `clicking on a room twice invokes the expected callback only once`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val state = aRoomListState( val state = aRoomListState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
@ -201,7 +201,7 @@ class RoomListViewTest {
@Test @Test
fun `long clicking on a room emits the expected Event`() { fun `long clicking on a room emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val state = aRoomListState( val state = aRoomListState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
@ -215,12 +215,12 @@ class RoomListViewTest {
eventsRecorder.clear() eventsRecorder.clear()
rule.onNodeWithText(room0.latestEvent.content().toString()).performTouchInput { longClick() } rule.onNodeWithText(room0.latestEvent.content().toString()).performTouchInput { longClick() }
eventsRecorder.assertSingle(RoomListEvents.ShowContextMenu(room0)) eventsRecorder.assertSingle(RoomListEvent.ShowContextMenu(room0))
} }
@Test @Test
fun `clicking on a room setting invokes the expected callback and emits expected Event`() { fun `clicking on a room setting invokes the expected callback and emits expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val state = aRoomListState( val state = aRoomListState(
contextMenu = aContextMenuShown(), contextMenu = aContextMenuShown(),
eventSink = eventsRecorder, eventSink = eventsRecorder,
@ -238,12 +238,12 @@ class RoomListViewTest {
rule.clickOn(CommonStrings.common_settings) rule.clickOn(CommonStrings.common_settings)
} }
eventsRecorder.assertSingle(RoomListEvents.HideContextMenu) eventsRecorder.assertSingle(RoomListEvent.HideContextMenu)
} }
@Test @Test
fun `clicking on accept and decline invite emits the expected Events`() { fun `clicking on accept and decline invite emits the expected Events`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val state = aRoomListState( val state = aRoomListState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
@ -259,8 +259,8 @@ class RoomListViewTest {
rule.clickOn(CommonStrings.action_decline) rule.clickOn(CommonStrings.action_decline)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
RoomListEvents.AcceptInvite(invitedRoom), RoomListEvent.AcceptInvite(invitedRoom),
RoomListEvents.ShowDeclineInviteMenu(invitedRoom), RoomListEvent.ShowDeclineInviteMenu(invitedRoom),
) )
) )
} }

View file

@ -8,9 +8,6 @@
package io.element.android.features.home.impl.search 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 com.google.common.truth.Truth.assertThat
import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory
import io.element.android.libraries.dateformatter.test.FakeDateFormatter 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.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService 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.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.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 io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -29,9 +30,7 @@ class RoomListSearchPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createRoomListSearchPresenter() val presenter = createRoomListSearchPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse() assertThat(state.isSearchActive).isFalse()
assertThat(state.query.text.toString()).isEmpty() assertThat(state.query.text.toString()).isEmpty()
@ -43,16 +42,14 @@ class RoomListSearchPresenterTest {
@Test @Test
fun `present - toggle search visibility`() = runTest { fun `present - toggle search visibility`() = runTest {
val presenter = createRoomListSearchPresenter() val presenter = createRoomListSearchPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse() assertThat(state.isSearchActive).isFalse()
state.eventSink(RoomListSearchEvents.ToggleSearchVisibility) state.eventSink(RoomListSearchEvent.ToggleSearchVisibility)
} }
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.isSearchActive).isTrue() assertThat(state.isSearchActive).isTrue()
state.eventSink(RoomListSearchEvents.ToggleSearchVisibility) state.eventSink(RoomListSearchEvent.ToggleSearchVisibility)
} }
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse() assertThat(state.isSearchActive).isFalse()
@ -62,14 +59,15 @@ class RoomListSearchPresenterTest {
@Test @Test
fun `present - query search changes`() = runTest { fun `present - query search changes`() = runTest {
val roomListService = FakeRoomListService() val roomList = FakeDynamicRoomList()
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createRoomListSearchPresenter(roomListService) val presenter = createRoomListSearchPresenter(roomListService)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
awaitItem().let { state -> awaitItem().let { state ->
assertThat( assertThat(
roomListService.allRooms.currentFilter.value roomList.currentFilter.value
).isEqualTo( ).isEqualTo(
RoomListFilter.None RoomListFilter.None
) )
@ -78,16 +76,16 @@ class RoomListSearchPresenterTest {
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.query.text).isEqualTo("Search") assertThat(state.query.text).isEqualTo("Search")
assertThat( assertThat(
roomListService.allRooms.currentFilter.value roomList.currentFilter.value
).isEqualTo( ).isEqualTo(
RoomListFilter.NormalizedMatchRoomName("Search") RoomListFilter.NormalizedMatchRoomName("Search")
) )
state.eventSink(RoomListSearchEvents.ClearQuery) state.eventSink(RoomListSearchEvent.ClearQuery)
} }
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.query.text.toString()).isEmpty() assertThat(state.query.text.toString()).isEmpty()
assertThat( assertThat(
roomListService.allRooms.currentFilter.value roomList.currentFilter.value
).isEqualTo( ).isEqualTo(
RoomListFilter.None RoomListFilter.None
) )
@ -97,26 +95,51 @@ class RoomListSearchPresenterTest {
@Test @Test
fun `present - room list changes`() = runTest { fun `present - room list changes`() = runTest {
val roomListService = FakeRoomListService() val roomList = FakeDynamicRoomList()
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createRoomListSearchPresenter(roomListService) val presenter = createRoomListSearchPresenter(roomListService)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.results).isEmpty() assertThat(state.results).isEmpty()
} }
roomListService.postAllRooms( roomList.summaries.emit(
listOf(aRoomSummary()) listOf(aRoomSummary())
) )
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.results).hasSize(1) assertThat(state.results).hasSize(1)
} }
roomListService.postAllRooms(emptyList()) roomList.summaries.emit(emptyList())
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.results).isEmpty() assertThat(state.results).isEmpty()
} }
} }
} }
@Test
fun `present - UpdateVisibleRange triggers pagination when near end`() = runTest {
val loadMoreLambda = lambdaRecorder<Unit> { }
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( fun TestScope.createRoomListSearchPresenter(

View file

@ -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,
)
}
}

View file

@ -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<ComponentActivity>()
@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<SpaceFiltersEvent.Selecting>()
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<SpaceFiltersEvent.Selecting>()
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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSpaceFiltersView(
state: SpaceFiltersState,
) {
setContent {
SpaceFiltersView(state = state)
}
}

View file

@ -376,10 +376,9 @@ private fun JoinRule?.toJoinAuthorisationStatus(): JoinAuthorisationStatus {
return when (this) { return when (this) {
JoinRule.Knock, JoinRule.Knock,
is JoinRule.KnockRestricted -> JoinAuthorisationStatus.CanKnock is JoinRule.KnockRestricted -> JoinAuthorisationStatus.CanKnock
JoinRule.Invite, JoinRule.Invite -> JoinAuthorisationStatus.NeedInvite
JoinRule.Private -> JoinAuthorisationStatus.NeedInvite
is JoinRule.Restricted -> JoinAuthorisationStatus.Restricted is JoinRule.Restricted -> JoinAuthorisationStatus.Restricted
JoinRule.Public -> JoinAuthorisationStatus.CanJoin JoinRule.Public -> JoinAuthorisationStatus.CanJoin
else -> JoinAuthorisationStatus.Unknown is JoinRule.Custom, null -> JoinAuthorisationStatus.Unknown
} }
} }

View file

@ -41,8 +41,8 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.invite.api.InviteData 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.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.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.atoms.RoomPreviewTitleAtom
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
@ -514,7 +514,7 @@ private fun IncompleteContent(
title = { title = {
when (roomIdOrAlias) { when (roomIdOrAlias) {
is RoomIdOrAlias.Alias -> { is RoomIdOrAlias.Alias -> {
RoomPreviewSubtitleAtom(roomIdOrAlias.identifier) RoomPreviewAliasAtom(roomIdOrAlias.identifier)
} }
is RoomIdOrAlias.Id -> { is RoomIdOrAlias.Id -> {
PlaceholderAtom(width = 200.dp, height = 22.dp) PlaceholderAtom(width = 200.dp, height = 22.dp)
@ -566,13 +566,12 @@ private fun DefaultLoadedContent(
} }
}, },
subtitle = { subtitle = {
when { if (contentState.alias != null) {
contentState.details is LoadedDetails.Space -> { RoomPreviewAliasAtom(contentState.alias.value)
SpaceInfoRow(visibility = SpaceRoomVisibility.fromJoinRule(contentState.joinRule)) }
} if (contentState.details is LoadedDetails.Space) {
contentState.alias != null -> { Spacer(Modifier.height(8.dp))
RoomPreviewSubtitleAtom(contentState.alias.value) SpaceInfoRow(visibility = SpaceRoomVisibility.fromJoinRule(contentState.joinRule))
}
} }
}, },
description = { description = {

View file

@ -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 @Test
fun `present - when room is not known RoomPreview is loaded as Custom`() = runTest { fun `present - when room is not known RoomPreview is loaded as Custom`() = runTest {
val client = FakeMatrixClient( val client = FakeMatrixClient(

View file

@ -193,7 +193,8 @@ class LinkNewDeviceFlowNode(
is ErrorType.Unknown -> ErrorScreenType.UnknownError is ErrorType.Unknown -> ErrorScreenType.UnknownError
is ErrorType.UnsupportedProtocol -> 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)) backstack.push(NavTarget.Error(error))
} }
@ -263,6 +264,12 @@ class LinkNewDeviceFlowNode(
linkNewDesktopHandler.reset() linkNewDesktopHandler.reset()
backstack.newRoot(NavTarget.Root) backstack.newRoot(NavTarget.Root)
} }
override fun onCancel() {
linkNewMobileHandler.reset()
linkNewDesktopHandler.reset()
callback.onDone()
}
} }
createNode<ErrorNode>(buildContext, listOf(callback, navTarget.errorScreenType)) createNode<ErrorNode>(buildContext, listOf(callback, navTarget.errorScreenType))
} }

View file

@ -27,6 +27,7 @@ class ErrorNode(
) : Node(buildContext = buildContext, plugins = plugins) { ) : Node(buildContext = buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
fun onRetry() fun onRetry()
fun onCancel()
} }
private val callback: Callback = callback() private val callback: Callback = callback()
@ -38,6 +39,7 @@ class ErrorNode(
modifier = modifier, modifier = modifier,
errorScreenType = errorScreenType, errorScreenType = errorScreenType,
onRetry = callback::onRetry, onRetry = callback::onRetry,
onCancel = callback::onCancel,
) )
} }
} }

View file

@ -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.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.LocalBuildMeta 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.Button
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -41,17 +42,23 @@ import kotlinx.collections.immutable.persistentListOf
fun ErrorView( fun ErrorView(
errorScreenType: ErrorScreenType, errorScreenType: ErrorScreenType,
onRetry: () -> Unit, onRetry: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val appName = LocalBuildMeta.current.applicationName val appName = LocalBuildMeta.current.applicationName
BackHandler(onBack = onRetry) BackHandler(onBack = onCancel)
FlowStepPage( FlowStepPage(
modifier = modifier, modifier = modifier,
iconStyle = BigIcon.Style.AlertSolid, iconStyle = BigIcon.Style.AlertSolid,
title = titleText(errorScreenType, appName), title = titleText(errorScreenType, appName),
subTitle = subtitleText(errorScreenType, appName), subTitle = subtitleText(errorScreenType, appName),
content = { Content(errorScreenType) }, content = { Content(errorScreenType) },
buttons = { Buttons(onRetry) }, buttons = {
Buttons(
onRetry = onRetry,
onCancel = onCancel,
)
},
) )
} }
@ -118,11 +125,19 @@ private fun Content(errorScreenType: ErrorScreenType) {
} }
@Composable @Composable
private fun Buttons(onRetry: () -> Unit) { private fun Buttons(
onRetry: () -> Unit,
onCancel: () -> Unit,
) {
Button( Button(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_start_over), text = stringResource(CommonStrings.action_try_again),
onClick = onRetry onClick = onRetry,
)
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_cancel),
onClick = onCancel,
) )
} }
@ -133,6 +148,7 @@ internal fun ErrorViewPreview(@PreviewParameter(ErrorScreenTypeProvider::class)
ErrorView( ErrorView(
errorScreenType = errorScreenType, errorScreenType = errorScreenType,
onRetry = {}, onRetry = {},
onCancel = {},
) )
} }
} }

View file

@ -1,16 +1,33 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_link_new_device_desktop_scanning_title">"Skann QR-koden"</string> <string name="screen_link_new_device_desktop_scanning_title">"Skann QR-koden"</string>
<string name="screen_link_new_device_desktop_step1">"Åpne %1$s på en bærbar eller stasjonær datamaskin"</string>
<string name="screen_link_new_device_desktop_step3">"Skann QR-koden med denne enheten"</string> <string name="screen_link_new_device_desktop_step3">"Skann QR-koden med denne enheten"</string>
<string name="screen_link_new_device_desktop_submit">"Klar til å skanne"</string> <string name="screen_link_new_device_desktop_submit">"Klar til å skanne"</string>
<string name="screen_link_new_device_desktop_title">"Åpne %1$s på en datamaskin for å få QR-koden"</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Tallene stemmer ikke overens"</string>
<string name="screen_link_new_device_enter_number_notice">"Skriv inn 2-sifret kode"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Dette vil bekrefte at forbindelsen til den andre enheten din er sikker."</string>
<string name="screen_link_new_device_enter_number_title">"Skriv inn nummeret som vises på den andre enheten din"</string>
<string name="screen_link_new_device_error_app_not_supported_subtitle">"Kontotilbyderen din støtter ikke %1$s."</string> <string name="screen_link_new_device_error_app_not_supported_subtitle">"Kontotilbyderen din støtter ikke %1$s."</string>
<string name="screen_link_new_device_error_app_not_supported_title">"%1$s støttes ikke"</string> <string name="screen_link_new_device_error_app_not_supported_title">"%1$s støttes ikke"</string>
<string name="screen_link_new_device_error_not_supported_subtitle">"Din kontoleverandør støtter ikke pålogging på en ny enhet med QR-kode."</string>
<string name="screen_link_new_device_error_not_supported_title">"QR-kode støttes ikke"</string> <string name="screen_link_new_device_error_not_supported_title">"QR-kode støttes ikke"</string>
<string name="screen_link_new_device_error_request_cancelled_subtitle">"Påloggingen ble kansellert på den andre enheten."</string> <string name="screen_link_new_device_error_request_cancelled_subtitle">"Påloggingen ble kansellert på den andre enheten."</string>
<string name="screen_link_new_device_error_request_cancelled_title">"Påloggingsforespørsel kansellert"</string> <string name="screen_link_new_device_error_request_cancelled_title">"Påloggingsforespørsel kansellert"</string>
<string name="screen_link_new_device_error_request_timeout_subtitle">"Påloggingen er utløpt. Vennligst prøv igjen."</string> <string name="screen_link_new_device_error_request_timeout_subtitle">"Påloggingen er utløpt. Vennligst prøv igjen."</string>
<string name="screen_link_new_device_error_request_timeout_title">"Påloggingen ble ikke fullført i tide"</string> <string name="screen_link_new_device_error_request_timeout_title">"Påloggingen ble ikke fullført i tide"</string>
<string name="screen_link_new_device_mobile_step1">"Åpne %1$s på den andre enheten"</string>
<string name="screen_link_new_device_mobile_step2">"Velg %1$s"</string> <string name="screen_link_new_device_mobile_step2">"Velg %1$s"</string>
<string name="screen_link_new_device_mobile_step2_action">"Logg inn med QR-kode"</string>
<string name="screen_link_new_device_mobile_step3">"Skann QR-koden som vises her med den andre enheten"</string>
<string name="screen_link_new_device_mobile_title">"Åpne %1$s på den andre enheten"</string>
<string name="screen_link_new_device_root_desktop_computer">"Datamaskin"</string>
<string name="screen_link_new_device_root_loading_qr_code">"Laster QR-kode…"</string>
<string name="screen_link_new_device_root_mobile_device">"Mobil enhet"</string>
<string name="screen_link_new_device_root_title">"Hvilken type enhet ønsker du å koble til?"</string>
<string name="screen_link_new_device_wrong_number_subtitle">"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."</string>
<string name="screen_link_new_device_wrong_number_title">"Tallene stemmer ikke overens"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"En sikker tilkobling kunne ikke opprettes til den nye enheten. Dine eksisterende enheter er fortsatt trygge, og du trenger ikke å bekymre deg for dem."</string> <string name="screen_qr_code_login_connection_note_secure_state_description">"En sikker tilkobling kunne ikke opprettes til den nye enheten. Dine eksisterende enheter er fortsatt trygge, og du trenger ikke å bekymre deg for dem."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Hva nå?"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_header">"Hva nå?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Prøv å logge på igjen med en QR-kode i tilfelle dette var et nettverksproblem"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Prøv å logge på igjen med en QR-kode i tilfelle dette var et nettverksproblem"</string>
@ -21,6 +38,8 @@
<string name="screen_qr_code_login_error_cancelled_title">"Påloggingsforespørsel kansellert"</string> <string name="screen_qr_code_login_error_cancelled_title">"Påloggingsforespørsel kansellert"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Påloggingen ble avvist på den andre enheten."</string> <string name="screen_qr_code_login_error_declined_subtitle">"Påloggingen ble avvist på den andre enheten."</string>
<string name="screen_qr_code_login_error_declined_title">"Pålogging avslått"</string> <string name="screen_qr_code_login_error_declined_title">"Pålogging avslått"</string>
<string name="screen_qr_code_login_error_device_already_signed_in_subtitle">"Du trenger ikke å gjøre noe annet."</string>
<string name="screen_qr_code_login_error_device_already_signed_in_title">"Din andre enhet er allerede logget inn"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Påloggingen er utløpt. Vennligst prøv igjen."</string> <string name="screen_qr_code_login_error_expired_subtitle">"Påloggingen er utløpt. Vennligst prøv igjen."</string>
<string name="screen_qr_code_login_error_expired_title">"Påloggingen ble ikke fullført i tide"</string> <string name="screen_qr_code_login_error_expired_title">"Påloggingen ble ikke fullført i tide"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Den andre enheten din støtter ikke pålogging på %s med en QR-kode. <string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Den andre enheten din støtter ikke pålogging på %s med en QR-kode.

View file

@ -12,6 +12,7 @@ import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings 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.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
@ -26,33 +27,45 @@ class ErrorViewTest {
val rule = createAndroidComposeRule<ComponentActivity>() val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `on back pressed - calls the onRetry callback`() { fun `on back pressed - calls the onCancel callback`() {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setErrorView( rule.setErrorView(
onRetry = callback onCancel = callback,
) )
rule.pressBackKey() rule.pressBackKey()
} }
} }
@Test @Test
fun `on start over button clicked - calls the expected callback`() { fun `on try again button clicked - calls the expected callback`() {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setErrorView( rule.setErrorView(
onRetry = callback 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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setErrorView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setErrorView(
onRetry: () -> Unit, onRetry: () -> Unit = EnsureNeverCalled(),
onCancel: () -> Unit = EnsureNeverCalled(),
errorScreenType: ErrorScreenType = ErrorScreenType.UnknownError, errorScreenType: ErrorScreenType = ErrorScreenType.UnknownError,
) { ) {
setContent { setContent {
ErrorView( ErrorView(
errorScreenType = errorScreenType, errorScreenType = errorScreenType,
onRetry = onRetry, onRetry = onRetry,
onCancel = onCancel,
) )
} }
} }

View file

@ -20,6 +20,7 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack 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.push
import com.bumble.appyx.navmodel.backstack.operation.singleTop import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
@ -196,7 +197,12 @@ class LoginFlowNode(
createNode<ChooseAccountProviderNode>(buildContext, listOf(callback)) createNode<ChooseAccountProviderNode>(buildContext, listOf(callback))
} }
NavTarget.QrCode -> { NavTarget.QrCode -> {
createNode<QrCodeLoginFlowNode>(buildContext) val callback = object : QrCodeLoginFlowNode.Callback {
override fun navigateBack() {
backstack.pop()
}
}
createNode<QrCodeLoginFlowNode>(buildContext, listOf(callback))
} }
is NavTarget.ConfirmAccountProvider -> { is NavTarget.ConfirmAccountProvider -> {
val inputs = ConfirmAccountProviderNode.Inputs( val inputs = ConfirmAccountProviderNode.Inputs(

View file

@ -37,6 +37,7 @@ import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.bindings 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.architecture.createNode
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.di.DependencyInjectionGraphOwner
@ -64,6 +65,12 @@ class QrCodeLoginFlowNode(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
), DependencyInjectionGraphOwner { ), DependencyInjectionGraphOwner {
interface Callback : Plugin {
fun navigateBack()
}
private val callback: Callback = callback()
private var authenticationJob: Job? = null private var authenticationJob: Job? = null
override val graph = qrCodeLoginGraphFactory.create() override val graph = qrCodeLoginGraphFactory.create()
@ -85,7 +92,6 @@ class QrCodeLoginFlowNode(
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
observeLoginStep() observeLoginStep()
} }
@ -113,7 +119,8 @@ class QrCodeLoginFlowNode(
is QrLoginException.Cancelled -> { is QrLoginException.Cancelled -> {
backstack.replace(NavTarget.Error(QrCodeErrorScreenType.Cancelled)) backstack.replace(NavTarget.Error(QrCodeErrorScreenType.Cancelled))
} }
is QrLoginException.Expired -> { is QrLoginException.Expired,
is QrLoginException.NotFound -> {
backstack.replace(NavTarget.Error(QrCodeErrorScreenType.Expired)) backstack.replace(NavTarget.Error(QrCodeErrorScreenType.Expired))
} }
is QrLoginException.Declined -> { is QrLoginException.Declined -> {
@ -132,7 +139,9 @@ class QrCodeLoginFlowNode(
Timber.e(error, "OIDC metadata is invalid") Timber.e(error, "OIDC metadata is invalid")
backstack.replace(NavTarget.Error(QrCodeErrorScreenType.UnknownError)) backstack.replace(NavTarget.Error(QrCodeErrorScreenType.UnknownError))
} }
else -> { QrLoginException.CheckCodeAlreadySent,
QrLoginException.CheckCodeCannotBeSent,
QrLoginException.Unknown -> {
Timber.e(error, "Unknown error found") Timber.e(error, "Unknown error found")
backstack.replace(NavTarget.Error(QrCodeErrorScreenType.UnknownError)) backstack.replace(NavTarget.Error(QrCodeErrorScreenType.UnknownError))
} }
@ -178,7 +187,13 @@ class QrCodeLoginFlowNode(
} }
is NavTarget.Error -> { is NavTarget.Error -> {
val callback = object : QrCodeErrorNode.Callback { val callback = object : QrCodeErrorNode.Callback {
override fun onRetry() = reset() override fun onRetry() {
reset()
}
override fun onCancel() {
callback.navigateBack()
}
} }
createNode<QrCodeErrorNode>(buildContext, plugins = listOf(navTarget.errorType, callback)) createNode<QrCodeErrorNode>(buildContext, plugins = listOf(navTarget.errorType, callback))
} }

View file

@ -31,6 +31,7 @@ class QrCodeErrorNode(
) : Node(buildContext = buildContext, plugins = plugins) { ) : Node(buildContext = buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
fun onRetry() fun onRetry()
fun onCancel()
} }
private val callback: Callback = callback() private val callback: Callback = callback()
@ -43,6 +44,7 @@ class QrCodeErrorNode(
errorScreenType = qrCodeErrorScreenType, errorScreenType = qrCodeErrorScreenType,
appName = buildMeta.productionApplicationName, appName = buildMeta.productionApplicationName,
onRetry = callback::onRetry, onRetry = callback::onRetry,
onCancel = callback::onCancel,
) )
} }
} }

View file

@ -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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button 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.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -44,16 +45,22 @@ fun QrCodeErrorView(
errorScreenType: QrCodeErrorScreenType, errorScreenType: QrCodeErrorScreenType,
appName: String, appName: String,
onRetry: () -> Unit, onRetry: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
BackHandler(onBack = onRetry) BackHandler(onBack = onCancel)
FlowStepPage( FlowStepPage(
modifier = modifier, modifier = modifier,
iconStyle = BigIcon.Style.AlertSolid, iconStyle = BigIcon.Style.AlertSolid,
title = titleText(errorScreenType, appName), title = titleText(errorScreenType, appName),
subTitle = subtitleText(errorScreenType, appName), subTitle = subtitleText(errorScreenType, appName),
content = { Content(errorScreenType) }, content = { Content(errorScreenType) },
buttons = { Buttons(onRetry) }, buttons = {
Buttons(
onRetry = onRetry,
onCancel = onCancel,
)
},
) )
} }
@ -118,11 +125,19 @@ private fun Content(errorScreenType: QrCodeErrorScreenType) {
} }
@Composable @Composable
private fun Buttons(onRetry: () -> Unit) { private fun Buttons(
onRetry: () -> Unit,
onCancel: () -> Unit,
) {
Button( Button(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_qr_code_login_start_over_button), text = stringResource(CommonStrings.action_try_again),
onClick = onRetry onClick = onRetry,
)
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_cancel),
onClick = onCancel,
) )
} }
@ -133,7 +148,8 @@ internal fun QrCodeErrorViewPreview(@PreviewParameter(QrCodeErrorScreenTypeProvi
QrCodeErrorView( QrCodeErrorView(
errorScreenType = errorScreenType, errorScreenType = errorScreenType,
appName = "Element X", appName = "Element X",
onRetry = {} onRetry = {},
onCancel = {},
) )
} }
} }

View file

@ -60,6 +60,8 @@
<string name="screen_qr_code_login_error_cancelled_title">"Påloggingsforespørsel kansellert"</string> <string name="screen_qr_code_login_error_cancelled_title">"Påloggingsforespørsel kansellert"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Påloggingen ble avvist på den andre enheten."</string> <string name="screen_qr_code_login_error_declined_subtitle">"Påloggingen ble avvist på den andre enheten."</string>
<string name="screen_qr_code_login_error_declined_title">"Pålogging avslått"</string> <string name="screen_qr_code_login_error_declined_title">"Pålogging avslått"</string>
<string name="screen_qr_code_login_error_device_already_signed_in_subtitle">"Du trenger ikke å gjøre noe annet."</string>
<string name="screen_qr_code_login_error_device_already_signed_in_title">"Din andre enhet er allerede logget inn"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Påloggingen er utløpt. Vennligst prøv igjen."</string> <string name="screen_qr_code_login_error_expired_subtitle">"Påloggingen er utløpt. Vennligst prøv igjen."</string>
<string name="screen_qr_code_login_error_expired_title">"Påloggingen ble ikke fullført i tide"</string> <string name="screen_qr_code_login_error_expired_title">"Påloggingen ble ikke fullført i tide"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Den andre enheten din støtter ikke pålogging på %s med en QR-kode. <string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Den andre enheten din støtter ikke pålogging på %s med en QR-kode.

View file

@ -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.A_SESSION_ID
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData 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 io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -60,6 +61,9 @@ class QrCodeLoginFlowNodeTest {
qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.Expired) qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.Expired)
assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.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) qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.Declined)
assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.Declined)) assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.Declined))
@ -183,7 +187,11 @@ class QrCodeLoginFlowNodeTest {
) )
return QrCodeLoginFlowNode( return QrCodeLoginFlowNode(
buildContext = buildContext, buildContext = buildContext,
plugins = emptyList(), plugins = listOf(
object : QrCodeLoginFlowNode.Callback {
override fun navigateBack() = lambdaError()
}
),
qrCodeLoginGraphFactory = FakeQrCodeLoginGraph.Builder(qrCodeLoginManager), qrCodeLoginGraphFactory = FakeQrCodeLoginGraph.Builder(qrCodeLoginManager),
coroutineDispatchers = coroutineDispatchers, coroutineDispatchers = coroutineDispatchers,
) )

View file

@ -12,8 +12,9 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4 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.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.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
@ -28,10 +29,10 @@ class QrCodeErrorViewTest {
val rule = createAndroidComposeRule<ComponentActivity>() val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `on back pressed - calls the onRetry callback`() { fun `on back pressed - calls the onCancel callback`() {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setQrCodeErrorView( rule.setQrCodeErrorView(
onRetry = callback onCancel = callback,
) )
rule.pressBackKey() rule.pressBackKey()
} }
@ -41,14 +42,25 @@ class QrCodeErrorViewTest {
fun `on try again button clicked - calls the expected callback`() { fun `on try again button clicked - calls the expected callback`() {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setQrCodeErrorView( 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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setQrCodeErrorView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setQrCodeErrorView(
onRetry: () -> Unit, onRetry: () -> Unit = EnsureNeverCalled(),
onCancel: () -> Unit = EnsureNeverCalled(),
errorScreenType: QrCodeErrorScreenType = QrCodeErrorScreenType.UnknownError, errorScreenType: QrCodeErrorScreenType = QrCodeErrorScreenType.UnknownError,
appName: String = "Element X", appName: String = "Element X",
) { ) {
@ -56,7 +68,8 @@ class QrCodeErrorViewTest {
QrCodeErrorView( QrCodeErrorView(
errorScreenType = errorScreenType, errorScreenType = errorScreenType,
appName = appName, appName = appName,
onRetry = onRetry onRetry = onRetry,
onCancel = onCancel,
) )
} }
} }

View file

@ -210,10 +210,7 @@ class MessagesPresenter(
// * History sharing is enabled, // * History sharing is enabled,
// * The room is encrypted, and: // * The room is encrypted, and:
// * The room's history_visibility allows future users to see content. // * The room's history_visibility allows future users to see content.
val showSharedHistoryIcon = isKeyShareOnInviteEnabled && val topBarSharedHistoryIcon = if (isKeyShareOnInviteEnabled) roomInfo.sharedHistoryIcon() else SharedHistoryIcon.NONE
roomInfo.isEncrypted == true &&
(roomInfo.historyVisibility == RoomHistoryVisibility.Shared ||
roomInfo.historyVisibility == RoomHistoryVisibility.WorldReadable)
LifecycleResumeEffect(dmRoomMember, roomInfo.isEncrypted) { LifecycleResumeEffect(dmRoomMember, roomInfo.isEncrypted) {
if (roomInfo.isEncrypted == true) { if (roomInfo.isEncrypted == true) {
@ -297,12 +294,24 @@ class MessagesPresenter(
pinnedMessagesBannerState = pinnedMessagesBannerState, pinnedMessagesBannerState = pinnedMessagesBannerState,
dmUserVerificationState = dmUserVerificationState, dmUserVerificationState = dmUserVerificationState,
roomMemberModerationState = roomMemberModerationState, roomMemberModerationState = roomMemberModerationState,
showSharedHistoryIcon = showSharedHistoryIcon, topBarSharedHistoryIcon = topBarSharedHistoryIcon,
successorRoom = roomInfo.successorRoom, successorRoom = roomInfo.successorRoom,
eventSink = ::handleEvent, 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 { private fun RoomInfo.avatarData(): AvatarData {
return AvatarData( return AvatarData(
id = id.value, id = id.value,

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