Merge branch 'release/26.02.0'

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

View file

@ -22,7 +22,8 @@
{
"versioning": "semver",
"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"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.3.0" />
<option name="version" value="2.3.10" />
</component>
</project>
</project>

View file

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

View file

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

View file

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

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
=============================

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

View file

@ -24,6 +24,7 @@
* [Logging](#logging)
* [Translations](#translations)
* [Rageshake](#rageshake)
* [Developer options](#developer-options)
* [Tips](#tips)
* [Happy coding!](#happy-coding)
@ -409,14 +410,31 @@ The data will be sent to an internal server, which is not publicly accessible. A
Rageshake can be very useful to get logs from a release version of the application.
#### Developer options
> [!WARNING]
> Developer options can result in unexpected application behavior or destructive
> actions. Use with caution and only if you are instructed by someone at Element or are
> already familiar.
These options provide advanced controls for testing and debugging. They are visible by
default in debug and nightly builds but are hidden in release versions.
**Enabling in release builds:** Navigate to application settings and tap the version
number at the bottom 7 times. After tapping, a new "Developer options" entry will appear
at the bottom of the list.
The developer options include feature flags, notification/push history, Element call
customization, Rust SDK log levels, per-feature tracing toggles, Showkase to debug UI
components, rageshake controls, app crash controls, cache details/controls, persistent
storage maintenance tasks.
Keywords: Developer settings, developer mode
### Tips
- Element Android has a `developer mode` in the `Settings/Advanced settings`. Other useful options are available here; (TODO Not supported yet!)
- Show hidden Events can also help to debug feature. When developer mode is enabled, it is possible to view the source (= the Json content) of any Events; (TODO
Not supported yet!)
- Type `/devtools` in a Room composer to access a developer menu. There are some other entry points. Developer mode has to be enabled; (TODO Not supported yet!)
- Hidden debug menu: when developer mode is enabled and on debug build, there are some extra screens that can be accessible using the green wheel. In those
screens, it will be possible to toggle some feature flags; (TODO Not supported yet!)
- Using logcat, filtering with `Compositions` can help you to understand what screen are currently displayed on your device. Searching for string displayed on
the screen can also help to find the running code in the codebase.
- When this is possible, prefer using `sealed interface` instead of `sealed class`;

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

View file

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

View file

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

View file

@ -82,8 +82,8 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
roomAddressValidity = RoomAddressValidity.Valid,
),
aConfigureRoomState(
isSpace = true,
config = CreateRoomConfig(
isSpace = true,
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",
visibilityState = RoomVisibilityState.Public(
@ -95,13 +95,11 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
),
aConfigureRoomState(
config = CreateRoomConfig(
isSpace = false,
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",
parentSpace = null,
visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Space-101"),
joinRuleItem = JoinRuleItem.PublicVisibility.Restricted(aSpaceRoom().roomId),
visibilityState = RoomVisibilityState.Private(
joinRuleItem = JoinRuleItem.PrivateVisibility.Restricted(aSpaceRoom().roomId),
),
),
spaces = listOf(aSpaceRoom()),
@ -109,13 +107,11 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
),
aConfigureRoomState(
config = CreateRoomConfig(
isSpace = false,
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",
parentSpace = aSpaceRoom(canonicalAlias = RoomAlias("#a-space-room:example.org")),
visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Space-101"),
joinRuleItem = JoinRuleItem.PublicVisibility.Restricted(aSpaceRoom().roomId),
visibilityState = RoomVisibilityState.Private(
joinRuleItem = JoinRuleItem.PrivateVisibility.Restricted(aSpaceRoom().roomId),
),
),
spaces = listOf(aSpaceRoom()),
@ -126,6 +122,7 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
fun aConfigureRoomState(
config: CreateRoomConfig = CreateRoomConfig(),
isSpace: Boolean = false,
isKnockFeatureEnabled: Boolean = true,
avatarActions: List<AvatarAction> = emptyList(),
createRoomAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
@ -134,21 +131,22 @@ fun aConfigureRoomState(
roomAddressValidity: RoomAddressValidity = RoomAddressValidity.Valid,
availableVisibilityOptions: List<JoinRuleItem> = if (config.parentSpace != null) {
listOfNotNull(
JoinRuleItem.PublicVisibility.Restricted(config.parentSpace.roomId),
JoinRuleItem.PublicVisibility.AskToJoinRestricted(config.parentSpace.roomId).takeIf { isKnockFeatureEnabled },
JoinRuleItem.Private,
JoinRuleItem.PrivateVisibility.Restricted(config.parentSpace.roomId),
JoinRuleItem.PrivateVisibility.AskToJoinRestricted(config.parentSpace.roomId).takeIf { isKnockFeatureEnabled },
JoinRuleItem.PrivateVisibility.Private,
)
} else {
listOfNotNull(
JoinRuleItem.PublicVisibility.Public,
JoinRuleItem.PublicVisibility.AskToJoin.takeIf { isKnockFeatureEnabled },
JoinRuleItem.Private,
JoinRuleItem.PrivateVisibility.Private,
)
},
spaces: List<SpaceRoom> = emptyList(),
eventSink: (ConfigureRoomEvents) -> Unit = { },
) = ConfigureRoomState(
config = config,
isSpace = isSpace,
avatarActions = avatarActions.toImmutableList(),
createRoomAction = createRoomAction,
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@ -76,7 +78,7 @@ fun ConfigureRoomView(
onCreateRoomSuccess: (RoomId) -> Unit,
modifier: Modifier = Modifier,
) {
val isSpace = state.config.isSpace
val isSpace = state.isSpace
val focusManager = LocalFocusManager.current
val isAvatarActionsSheetVisible = remember { mutableStateOf(false) }
@ -105,7 +107,6 @@ fun ConfigureRoomView(
.imePadding()
.verticalScroll(rememberScrollState())
.consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
RoomNameWithAvatar(
isSpace = isSpace,
@ -115,20 +116,20 @@ fun ConfigureRoomView(
onAvatarClick = ::onAvatarClick,
onChangeRoomName = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) },
)
Spacer(modifier = Modifier.height(16.dp))
RoomTopic(
modifier = Modifier.padding(horizontal = 16.dp),
topic = state.config.topic.orEmpty(),
onTopicChange = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
)
if (!state.config.isSpace && state.spaces.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp))
if (!state.isSpace && state.spaces.isNotEmpty()) {
SelectParentSpaceOptions(
spaces = state.spaces,
selectedSpace = state.config.parentSpace,
onSelectSpace = { state.eventSink(ConfigureRoomEvents.SetParentSpace(it)) },
)
}
RoomJoinRuleOptions(
options = state.availableJoinRules,
selected = state.config.visibilityState.joinRuleItem,
@ -138,20 +139,17 @@ fun ConfigureRoomView(
state.eventSink(ConfigureRoomEvents.JoinRuleChanged(it))
},
)
if (state.config.visibilityState !is RoomVisibilityState.Private) {
Column {
ListSectionHeader(title = stringResource(R.string.screen_create_room_room_address_section_title))
RoomAddressField(
modifier = Modifier.padding(horizontal = 16.dp),
address = state.config.visibilityState.roomAddress().getOrNull().orEmpty(),
homeserverName = state.homeserverName,
addressValidity = state.roomAddressValidity,
onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) },
label = null,
supportingText = stringResource(R.string.screen_create_room_room_address_section_footer),
)
}
ListSectionHeader(title = stringResource(R.string.screen_create_room_room_address_section_title))
RoomAddressField(
modifier = Modifier.padding(horizontal = 16.dp),
address = state.config.visibilityState.roomAddress().getOrNull().orEmpty(),
homeserverName = state.homeserverName,
addressValidity = state.roomAddressValidity,
onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) },
label = null,
supportingText = stringResource(R.string.screen_create_room_room_address_section_footer),
)
}
}
}
@ -217,7 +215,9 @@ private fun RoomNameWithAvatar(
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.padding(end = 8.dp).size(AvatarSize.EditRoomDetails.dp),
modifier = Modifier
.padding(end = 8.dp)
.size(AvatarSize.EditRoomDetails.dp),
contentAlignment = Alignment.Center,
) {
val avatarState = remember(avatarUri) {
@ -272,12 +272,13 @@ private fun RoomTopic(
internal fun ConfigureRoomOptions(
title: String,
modifier: Modifier = Modifier,
hasDivider: Boolean = true,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier.selectableGroup()
) {
ListSectionHeader(title = title)
ListSectionHeader(title = title, hasDivider = hasDivider)
content()
}
}
@ -302,10 +303,10 @@ private fun RoomJoinRuleOptions(
size = RoundedIconAtomSize.Big,
imageVector = when (item) {
JoinRuleItem.PublicVisibility.Public -> CompoundIcons.Public()
is JoinRuleItem.PublicVisibility.Restricted -> CompoundIcons.Space()
is JoinRuleItem.PrivateVisibility.Restricted -> CompoundIcons.Space()
JoinRuleItem.PublicVisibility.AskToJoin,
is JoinRuleItem.PublicVisibility.AskToJoinRestricted -> CompoundIcons.UserAdd()
JoinRuleItem.Private -> CompoundIcons.Lock()
is JoinRuleItem.PrivateVisibility.AskToJoinRestricted -> CompoundIcons.UserAdd()
JoinRuleItem.PrivateVisibility.Private -> CompoundIcons.Lock()
},
tint = if (isSelected) ElementTheme.colors.iconPrimary else ElementTheme.colors.iconSecondary,
backgroundTint = Color.Transparent,
@ -314,28 +315,28 @@ private fun RoomJoinRuleOptions(
headlineContent = {
val title = when (item) {
JoinRuleItem.PublicVisibility.Public -> stringResource(R.string.screen_create_room_room_access_section_public_option_title)
is JoinRuleItem.PublicVisibility.Restricted -> stringResource(R.string.screen_create_room_room_access_section_restricted_option_title)
is JoinRuleItem.PrivateVisibility.Restricted -> stringResource(R.string.screen_create_room_room_access_section_restricted_option_title)
JoinRuleItem.PublicVisibility.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_title)
is JoinRuleItem.PublicVisibility.AskToJoinRestricted -> stringResource(
is JoinRuleItem.PrivateVisibility.AskToJoinRestricted -> stringResource(
R.string.screen_create_room_room_access_section_knocking_restricted_option_title
)
JoinRuleItem.Private -> stringResource(R.string.screen_create_room_room_access_section_private_option_title)
JoinRuleItem.PrivateVisibility.Private -> stringResource(R.string.screen_create_room_room_access_section_private_option_title)
}
Text(text = title)
},
supportingContent = {
val description = when (item) {
JoinRuleItem.PublicVisibility.Public -> stringResource(R.string.screen_create_room_room_access_section_public_option_description)
is JoinRuleItem.PublicVisibility.Restricted -> stringResource(
is JoinRuleItem.PrivateVisibility.Restricted -> stringResource(
R.string.screen_create_room_room_access_section_restricted_option_description,
parentSpace?.displayName.orEmpty()
)
JoinRuleItem.PublicVisibility.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_description)
is JoinRuleItem.PublicVisibility.AskToJoinRestricted -> stringResource(
is JoinRuleItem.PrivateVisibility.AskToJoinRestricted -> stringResource(
R.string.screen_create_room_room_access_section_knocking_restricted_option_description,
parentSpace?.displayName.orEmpty()
)
JoinRuleItem.Private -> stringResource(R.string.screen_create_room_room_access_section_private_option_description)
JoinRuleItem.PrivateVisibility.Private -> stringResource(R.string.screen_create_room_room_access_section_private_option_description)
}
Text(text = description)
},

View file

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

View file

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

View file

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

View file

@ -12,7 +12,7 @@ import java.util.Optional
sealed interface RoomVisibilityState {
val joinRuleItem: JoinRuleItem
data class Private(override val joinRuleItem: JoinRuleItem.Private = JoinRuleItem.Private) : RoomVisibilityState
data class Private(override val joinRuleItem: JoinRuleItem.PrivateVisibility) : RoomVisibilityState
data class Public(
val roomAddress: RoomAddress,

View file

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

View file

@ -3,16 +3,33 @@
<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_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.
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_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_public_option_description">"Do této místnosti může vstoupit kdokoli"</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">"Povolit žádost o vstup"</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_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_address_section_title">"Adresa 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_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_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_placeholder">"Přidat popis…"</string>
</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_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_title">"Home"</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_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_add_people_title">"Kutsu osalejaid"</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.
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_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_public_option_description">"Kõik võivad selle jututoaga liituda"</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">"Luba küsida liitumisvõimalust"</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_address_section_footer">"Selleks, et see jututuba oleks nähtav jututubade avalikus kataloogis, sa vajad jututoa aadressi."</string>
<string name="screen_create_room_room_address_section_title">"Jututoa aadress"</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_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_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_placeholder">"Lisa kirjeldus…"</string>
</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_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_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_title">"Standard"</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_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_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_topic_label">"Sujet (facultatif)"</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_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_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_title">"Privat"</string>
<string name="screen_create_room_public_option_description">"Alle kan finne dette rommet.
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_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_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_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_title">"Adresse"</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_placeholder">"Legg til beskrivelse…"</string>
</resources>

View file

@ -3,16 +3,16 @@
<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_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.
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_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_title">"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_title">"Vem som helst"</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_title">"Rumsadress"</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">"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."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Offentligt"</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">"Adress"</string>
<string name="screen_create_room_room_visibility_section_title">"Rumssynlighet"</string>
<string name="screen_create_room_topic_label">"Ämne (valfritt)"</string>
</resources>

View file

@ -7,8 +7,6 @@
<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_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_title">"Private"</string>
<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_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_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_topic_label">"Topic (optional)"</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.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
@ -89,7 +90,7 @@ class ConfigureRoomPresenterTest {
assertThat(initialState.config.topic).isNull()
assertThat(initialState.config.invites).isEmpty()
assertThat(initialState.config.avatarUri).isNull()
assertThat(initialState.config.visibilityState).isEqualTo(RoomVisibilityState.Private())
assertThat(initialState.config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private))
assertThat(initialState.createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(initialState.homeserverName).isEqualTo("matrix.org")
}
@ -218,6 +219,7 @@ class ConfigureRoomPresenterTest {
fun `present - when creating a room in a space if the room doesn't receive the power levels value it can't be added to the space`() = runTest {
val addChildToSpaceResult = lambdaRecorder<RoomId, RoomId, Result<Unit>> { _, _ -> Result.success(Unit) }
val spaceService = FakeSpaceService(
editableSpacesResult = { Result.success(emptyList()) },
addChildToSpaceResult = addChildToSpaceResult,
)
val roomInfoFlow = MutableStateFlow<Optional<RoomInfo>>(Optional.empty())
@ -234,7 +236,8 @@ class ConfigureRoomPresenterTest {
matrixClient.givenCreateRoomResult(createRoomResult)
val parentSpace = aSpaceRoom()
// Use a public parent space so AskToJoin is a valid option
val parentSpace = aSpaceRoom(joinRule = JoinRule.Public)
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(parentSpace))
assertThat(awaitItem().config.parentSpace).isEqualTo(parentSpace)
@ -259,6 +262,7 @@ class ConfigureRoomPresenterTest {
fun `present - creating a room and adding it into a parent space works when all the data is available`() = runTest {
val addChildToSpaceResult = lambdaRecorder<RoomId, RoomId, Result<Unit>> { _, _ -> Result.success(Unit) }
val spaceService = FakeSpaceService(
editableSpacesResult = { Result.success(emptyList()) },
addChildToSpaceResult = addChildToSpaceResult,
)
val roomInfoFlow = MutableStateFlow<Optional<RoomInfo>>(Optional.empty())
@ -275,7 +279,8 @@ class ConfigureRoomPresenterTest {
matrixClient.givenCreateRoomResult(createRoomResult)
val parentSpace = aSpaceRoom()
// Use a public parent space so AskToJoin is a valid option
val parentSpace = aSpaceRoom(joinRule = JoinRule.Public)
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(parentSpace))
assertThat(awaitItem().config.parentSpace).isEqualTo(parentSpace)
@ -484,16 +489,19 @@ class ConfigureRoomPresenterTest {
assertThat(awaitItem().config.visibilityState).isInstanceOf(RoomVisibilityState.Public::class.java)
// Then check changing the parent space resets it to private
// (via LaunchedEffect fallback since Public is not in availableJoinRules for non-public parent)
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(aSpaceRoom()))
assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private())
skipItems(1) // Skip intermediate state
assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private))
// If we change the join rule back to public
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
assertThat(awaitItem().config.visibilityState).isInstanceOf(RoomVisibilityState.Public::class.java)
skipItems(1) // Skip intermediate state (Public is still invalid)
assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private))
// Then remove the parent space, it'll be private again
// Then remove the parent space, the join rule stays private
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(null))
assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private())
assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private))
}
}
@ -516,7 +524,9 @@ class ConfigureRoomPresenterTest {
private fun createMatrixClient(
isAliasAvailable: Boolean = true,
spaceService: FakeSpaceService = FakeSpaceService(),
spaceService: FakeSpaceService = FakeSpaceService(
editableSpacesResult = { Result.success(emptyList()) }
),
) = FakeMatrixClient(
userIdServerNameLambda = { "matrix.org" },
resolveRoomAliasResult = {

View file

@ -18,12 +18,12 @@ import org.junit.Test
class JoinRuleItemTest {
@Test
fun `toJoinRule works as expected`() {
assertThat(JoinRuleItem.Private.toJoinRule()).isEqualTo(JoinRule.Private)
assertThat(JoinRuleItem.PrivateVisibility.Private.toJoinRule()).isEqualTo(JoinRule.Invite)
assertThat(JoinRuleItem.PublicVisibility.Public.toJoinRule()).isEqualTo(JoinRule.Public)
assertThat(JoinRuleItem.PublicVisibility.AskToJoin.toJoinRule()).isEqualTo(JoinRule.Knock)
assertThat(JoinRuleItem.PublicVisibility.Restricted(A_ROOM_ID).toJoinRule())
assertThat(JoinRuleItem.PrivateVisibility.Restricted(A_ROOM_ID).toJoinRule())
.isEqualTo(JoinRule.Restricted(persistentListOf(AllowRule.RoomMembership(A_ROOM_ID))))
assertThat(JoinRuleItem.PublicVisibility.AskToJoinRestricted(A_ROOM_ID).toJoinRule())
assertThat(JoinRuleItem.PrivateVisibility.AskToJoinRestricted(A_ROOM_ID).toJoinRule())
.isEqualTo(JoinRule.KnockRestricted(persistentListOf(AllowRule.RoomMembership(A_ROOM_ID))))
}
}

View file

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

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.impl.components.RoomListMenuAction
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.roomlist.RoomListEvents
import io.element.android.features.home.impl.roomlist.RoomListEvent
import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView
import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint
@ -159,7 +159,7 @@ class HomeFlowNode(
}
private fun onNewOwnersSelected(roomId: RoomId) {
stateFlow.value.roomListState.eventSink(RoomListEvents.LeaveRoom(roomId, needsConfirmation = false))
stateFlow.value.roomListState.eventSink(RoomListEvent.LeaveRoom(roomId, needsConfirmation = false))
}
private fun rootNode(buildContext: BuildContext): Node {

View file

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

View file

@ -9,6 +9,7 @@
package io.element.android.features.home.impl
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.home.impl.spaces.HomeSpacesState
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@ -29,8 +30,9 @@ data class HomeState(
val snackbarMessage: SnackbarMessage?,
val canReportBug: Boolean,
val directLogoutState: DirectLogoutState,
val eventSink: (HomeEvents) -> Unit,
val eventSink: (HomeEvent) -> Unit,
) {
val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected
val displayActions = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats
val displayRoomListFilters = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats && roomListState.displayFilters
val showNavigationBar = homeSpacesState.canCreateSpaces || homeSpacesState.spaceRooms.isNotEmpty()

View file

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

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

View file

@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberTopAppBarState
@ -44,6 +45,10 @@ import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.filters.RoomListFiltersView
import io.element.android.features.home.impl.filters.aRoomListFiltersState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersEvent
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.home.impl.spacefilters.aSelectedSpaceFiltersState
import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState
import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom
import io.element.android.libraries.designsystem.components.TopAppBarScrollBehaviorLayout
import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -75,7 +80,6 @@ import kotlinx.collections.immutable.toImmutableList
@Composable
fun HomeTopBar(
selectedNavigationItem: HomeNavigationBarItem,
title: String,
currentUserAndNeighbors: ImmutableList<MatrixUser>,
showAvatarIndicator: Boolean,
areSearchResultsDisplayed: Boolean,
@ -89,6 +93,7 @@ fun HomeTopBar(
canReportBug: Boolean,
displayFilters: Boolean,
filtersState: RoomListFiltersState,
spaceFiltersState: SpaceFiltersState,
modifier: Modifier = Modifier,
) {
Column(modifier) {
@ -103,12 +108,21 @@ fun HomeTopBar(
scrolledContainerColor = Color.Transparent,
),
title = {
val displayTitle = when (selectedNavigationItem) {
HomeNavigationBarItem.Chats -> {
when (spaceFiltersState) {
is SpaceFiltersState.Selected -> spaceFiltersState.selectedFilter.spaceRoom.displayName
else -> stringResource(selectedNavigationItem.labelRes)
}
}
HomeNavigationBarItem.Spaces -> stringResource(selectedNavigationItem.labelRes)
}
Text(
modifier = Modifier.semantics {
heading()
},
style = ElementTheme.typography.aliasScreenTitle,
text = title,
text = displayTitle,
)
},
navigationIcon = {
@ -124,7 +138,8 @@ fun HomeTopBar(
HomeNavigationBarItem.Chats -> RoomListMenuItems(
onToggleSearch = onToggleSearch,
onMenuActionClick = onMenuActionClick,
canReportBug = canReportBug
canReportBug = canReportBug,
spaceFiltersState = spaceFiltersState,
)
HomeNavigationBarItem.Spaces -> SpacesMenuItems(
canCreateSpaces = canCreateSpaces,
@ -154,6 +169,7 @@ private fun RoomListMenuItems(
onToggleSearch: () -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
canReportBug: Boolean,
spaceFiltersState: SpaceFiltersState,
) {
IconButton(
onClick = onToggleSearch,
@ -163,6 +179,7 @@ private fun RoomListMenuItems(
contentDescription = stringResource(CommonStrings.action_search),
)
}
SpaceFilterButton(spaceFiltersState = spaceFiltersState)
if (RoomListConfig.HAS_DROP_DOWN_MENU) {
var showMenu by remember { mutableStateOf(false) }
IconButton(
@ -228,6 +245,38 @@ private fun SpacesMenuItems(
}
}
@Composable
private fun SpaceFilterButton(
spaceFiltersState: SpaceFiltersState,
) {
if (spaceFiltersState == SpaceFiltersState.Disabled) return
fun onClick() {
when (spaceFiltersState) {
is SpaceFiltersState.Unselected -> spaceFiltersState.eventSink(SpaceFiltersEvent.Unselected.ShowFilters)
is SpaceFiltersState.Selected -> spaceFiltersState.eventSink(SpaceFiltersEvent.Selected.ClearSelection)
else -> Unit
}
}
val isSelected = spaceFiltersState is SpaceFiltersState.Selected
IconButton(
onClick = ::onClick,
colors = if (isSelected) {
IconButtonDefaults.iconButtonColors(
containerColor = ElementTheme.colors.bgActionPrimaryRest,
contentColor = ElementTheme.colors.iconOnSolidPrimary,
)
} else {
IconButtonDefaults.iconButtonColors()
},
) {
Icon(
imageVector = CompoundIcons.Filter(),
contentDescription = stringResource(R.string.screen_roomlist_your_spaces),
)
}
}
@Composable
private fun NavigationIcon(
currentUserAndNeighbors: ImmutableList<MatrixUser>,
@ -309,7 +358,6 @@ private fun AccountIcon(
internal fun HomeTopBarPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats,
title = stringResource(R.string.screen_roomlist_main_space_title),
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
@ -322,6 +370,30 @@ internal fun HomeTopBarPreview() = ElementPreview {
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
spaceFiltersState = anUnselectedSpaceFiltersState(),
onMenuActionClick = {},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
internal fun HomeTopBarSpaceFiltersSelectedPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats,
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
onOpenSettings = {},
onAccountSwitch = {},
onToggleSearch = {},
onCreateSpace = {},
canCreateSpaces = true,
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
spaceFiltersState = aSelectedSpaceFiltersState(),
onMenuActionClick = {},
)
}
@ -332,7 +404,6 @@ internal fun HomeTopBarPreview() = ElementPreview {
internal fun HomeTopBarSpacesPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Spaces,
title = stringResource(R.string.screen_home_tab_spaces),
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
@ -345,6 +416,7 @@ internal fun HomeTopBarSpacesPreview() = ElementPreview {
canReportBug = true,
displayFilters = false,
filtersState = aRoomListFiltersState(),
spaceFiltersState = anUnselectedSpaceFiltersState(),
onMenuActionClick = {},
)
}
@ -355,7 +427,6 @@ internal fun HomeTopBarSpacesPreview() = ElementPreview {
internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats,
title = stringResource(R.string.screen_roomlist_main_space_title),
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
showAvatarIndicator = true,
areSearchResultsDisplayed = false,
@ -368,6 +439,7 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
spaceFiltersState = anUnselectedSpaceFiltersState(),
onMenuActionClick = {},
)
}
@ -378,7 +450,6 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
internal fun HomeTopBarMultiAccountPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats,
title = stringResource(R.string.screen_roomlist_main_space_title),
currentUserAndNeighbors = aMatrixUserList().take(3).toImmutableList(),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
@ -391,6 +462,7 @@ internal fun HomeTopBarMultiAccountPreview() = ElementPreview {
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
spaceFiltersState = anUnselectedSpaceFiltersState(),
onMenuActionClick = {},
)
}

View file

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

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

View file

@ -9,33 +9,48 @@
package io.element.android.features.home.impl.datasource
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.libraries.androidutils.diff.DiffCacheUpdater
import io.element.android.libraries.androidutils.diff.MutableListDiffCache
import io.element.android.libraries.androidutils.system.DateTimeObserver
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.roomlist.updateVisibleRange
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.lang.IllegalStateException
import kotlin.time.Duration.Companion.seconds
private const val PAGE_SIZE = 20
private const val EXTENDED_VISIBILITY_RANGE_SIZE = 40
private const val SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS = 300L
private const val PAGINATION_THRESHOLD = 3 * PAGE_SIZE
@Inject
@SingleIn(SessionScope::class)
class RoomListDataSource(
private val roomListService: RoomListService,
private val roomListRoomSummaryFactory: RoomListRoomSummaryFactory,
@ -51,7 +66,12 @@ class RoomListDataSource(
observeDateTimeChanges()
}
private val _allRooms = MutableSharedFlow<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 diffCache = MutableListDiffCache<RoomListRoomSummary>()
@ -59,22 +79,49 @@ class RoomListDataSource(
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) {
roomListService
.allRooms
.filteredSummaries
roomList
.summaries
.onEach { roomSummaries ->
replaceWith(roomSummaries)
}
.launchIn(coroutineScope)
}
suspend fun subscribeToVisibleRooms(roomIds: List<RoomId>) {
roomListService.subscribeToVisibleRooms(roomIds)
suspend fun updateFilter(filter: RoomListFilter) {
roomList.updateFilter(filter)
}
suspend fun updateVisibleRange(visibleRange: IntRange) = coroutineScope {
launch {
roomList.updateVisibleRange(visibleRange, PAGINATION_THRESHOLD)
}
launch {
subscribeToVisibleRoomsIfNeeded(visibleRange)
}
}
private var currentSubscribeToVisibleRoomsJob: Job? = null
private fun CoroutineScope.subscribeToVisibleRoomsIfNeeded(range: IntRange) {
currentSubscribeToVisibleRoomsJob?.cancel()
currentSubscribeToVisibleRoomsJob = launch {
// Debounce the subscription to avoid subscribing to too many rooms
delay(SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS)
if (range.isEmpty()) return@launch
val currentRoomList = roomSummariesFlow.first()
// Use extended range to 'prefetch' the next rooms info
val midExtendedRangeSize = EXTENDED_VISIBILITY_RANGE_SIZE / 2
val extendedRange = range.first until range.last + midExtendedRangeSize
val roomIds = extendedRange.mapNotNull { index ->
currentRoomList.getOrNull(index)?.roomId
}
roomListService.subscribeToVisibleRooms(roomIds)
}
}
@OptIn(FlowPreview::class)
@ -82,7 +129,7 @@ class RoomListDataSource(
notificationSettingsService.notificationSettingsChangeFlow
.debounce(0.5.seconds)
.onEach {
roomListService.allRooms.rebuildSummaries()
roomList.rebuildSummaries()
}
.launchIn(sessionCoroutineScope)
}
@ -108,6 +155,7 @@ class RoomListDataSource(
private suspend fun buildAndEmitAllRooms(roomSummaries: List<RoomSummary>, useCache: Boolean = true) {
// Used to detect duplicates in the room list summaries - see comment below
data class CacheResult(val index: Int, val fromCache: Boolean)
val cachingResults = mutableMapOf<RoomId, MutableList<CacheResult>>()
val roomListRoomSummaries = diffCache.indices().mapNotNull { index ->
@ -144,14 +192,14 @@ class RoomListDataSource(
analyticsService.trackError(
IllegalStateException(
"Found duplicates in room summaries after a local UI update: $duplicates. " +
"This could be a race condition/caching issue of some kind"
"This could be a race condition/caching issue of some kind"
)
)
// Remove duplicates before emitting the new values
_allRooms.emit(roomListRoomSummaries.distinctBy { it.roomId }.toImmutableList())
_roomSummariesFlow.emit(roomListRoomSummaries.distinctBy { it.roomId }.toImmutableList())
} else {
_allRooms.emit(roomListRoomSummaries.toImmutableList())
_roomSummariesFlow.emit(roomListRoomSummaries.toImmutableList())
}
}
@ -163,7 +211,7 @@ class RoomListDataSource(
private suspend fun rebuildAllRoomSummaries() {
lock.withLock {
roomListService.allRooms.filteredSummaries.replayCache.firstOrNull()?.let { roomSummaries ->
roomList.summaries.replayCache.firstOrNull()?.let { roomSummaries ->
buildAndEmitAllRooms(roomSummaries, useCache = false)
}
}

View file

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

View file

@ -9,6 +9,7 @@
package io.element.android.features.home.impl.filters
import io.element.android.features.home.impl.R
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter
/**
* Enum class representing the different filters that can be applied to the room list.
@ -30,3 +31,13 @@ enum class RoomListFilter(val stringResource: Int) {
Invites -> setOf(Rooms, People, Unread, Favourites)
}
}
fun RoomListFilter.into(): MatrixRoomListFilter {
return when (this) {
RoomListFilter.Rooms -> MatrixRoomListFilter.Category.Group
RoomListFilter.People -> MatrixRoomListFilter.Category.People
RoomListFilter.Unread -> MatrixRoomListFilter.Unread
RoomListFilter.Favourites -> MatrixRoomListFilter.Favorite
RoomListFilter.Invites -> MatrixRoomListFilter.Invite
}
}

View file

@ -24,8 +24,12 @@ data class RoomListFiltersEmptyStateResources(
/**
* 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 {
isSpaceFilterSelected -> RoomListFiltersEmptyStateResources(
title = R.string.screen_roomlist_filter_mixed_empty_state_title,
subtitle = R.string.screen_roomlist_filter_mixed_empty_state_subtitle
)
selectedFilters.isEmpty() -> null
selectedFilters.size == 1 -> {
when (selectedFilters.first()) {

View file

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

View file

@ -9,61 +9,33 @@
package io.element.android.features.home.impl.filters
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import dev.zacsweers.metro.Inject
import io.element.android.features.home.impl.filters.selection.FilterSelectionStrategy
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.map
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter
@Inject
class RoomListFiltersPresenter(
private val roomListService: RoomListService,
private val filterSelectionStrategy: FilterSelectionStrategy,
) : Presenter<RoomListFiltersState> {
private val initialFilters = filterSelectionStrategy.filterSelectionStates.value.toImmutableList()
@Composable
override fun present(): RoomListFiltersState {
fun handleEvent(event: RoomListFiltersEvents) {
fun handleEvent(event: RoomListFiltersEvent) {
when (event) {
RoomListFiltersEvents.ClearSelectedFilters -> {
RoomListFiltersEvent.ClearSelectedFilters -> {
filterSelectionStrategy.clear()
}
is RoomListFiltersEvents.ToggleFilter -> {
is RoomListFiltersEvent.ToggleFilter -> {
filterSelectionStrategy.toggle(event.filter)
}
}
}
val filters by produceState(initialValue = initialFilters) {
filterSelectionStrategy.filterSelectionStates
.map { filters ->
value = filters.toImmutableList()
filters.mapNotNull { filterState ->
if (!filterState.isSelected) {
return@mapNotNull null
}
when (filterState.filter) {
RoomListFilter.Rooms -> MatrixRoomListFilter.Category.Group
RoomListFilter.People -> MatrixRoomListFilter.Category.People
RoomListFilter.Unread -> MatrixRoomListFilter.Unread
RoomListFilter.Favourites -> MatrixRoomListFilter.Favorite
RoomListFilter.Invites -> MatrixRoomListFilter.Invite
}
}
}
.collect { filters ->
val result = MatrixRoomListFilter.All(filters)
roomListService.allRooms.updateFilter(result)
}
}
val filters by filterSelectionStrategy.filterSelectionStates.collectAsState()
return RoomListFiltersState(
filterSelectionStates = filters,
filterSelectionStates = filters.toImmutableList(),
eventSink = ::handleEvent,
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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.AnnouncementService
import io.element.android.features.home.impl.datasource.RoomListDataSource
import io.element.android.features.home.impl.filters.RoomListFilter.Rooms
import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.search.RoomListSearchEvents
import io.element.android.features.home.impl.filters.into
import io.element.android.features.home.impl.search.RoomListSearchEvent
import io.element.android.features.home.impl.search.RoomListSearchState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.home.impl.spacefilters.into
import io.element.android.features.home.impl.spacefilters.selectedFilter
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.AcceptInvite
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.DeclineInvite
@ -44,6 +49,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
@ -57,8 +63,6 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
@ -68,9 +72,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
private const val EXTENDED_RANGE_SIZE = 40
private const val SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS = 300L
@Inject
class RoomListPresenter(
private val client: MatrixClient,
@ -88,6 +89,7 @@ class RoomListPresenter(
private val seenInvitesStore: SeenInvitesStore,
private val announcementService: AnnouncementService,
private val coldStartWatcher: AnalyticsColdStartWatcher,
private val spaceFiltersPresenter: Presenter<SpaceFiltersState>,
) : Presenter<RoomListState> {
private val encryptionService = client.encryptionService
@ -97,6 +99,7 @@ class RoomListPresenter(
val leaveRoomState = leaveRoomPresenter.present()
val filtersState = filtersPresenter.present()
val searchState = searchPresenter.present()
val spaceFiltersState = spaceFiltersPresenter.present()
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
LaunchedEffect(Unit) {
@ -116,45 +119,52 @@ class RoomListPresenter(
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(RoomListState.DeclineInviteMenu.Hidden) }
fun handleEvent(event: RoomListEvents) {
fun handleEvent(event: RoomListEvent) {
when (event) {
is RoomListEvents.UpdateVisibleRange -> coroutineScope.launch {
updateVisibleRange(event.range)
is RoomListEvent.UpdateVisibleRange -> coroutineScope.launch {
roomListDataSource.updateVisibleRange(event.range)
}
RoomListEvents.DismissRequestVerificationPrompt -> securityBannerDismissed = true
RoomListEvents.DismissBanner -> securityBannerDismissed = true
RoomListEvents.DismissNewNotificationSoundBanner -> coroutineScope.launch {
RoomListEvent.DismissRequestVerificationPrompt -> securityBannerDismissed = true
RoomListEvent.DismissBanner -> securityBannerDismissed = true
RoomListEvent.DismissNewNotificationSoundBanner -> coroutineScope.launch {
announcementService.onAnnouncementDismissed(Announcement.NewNotificationSound)
}
RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
is RoomListEvents.ShowContextMenu -> {
RoomListEvent.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvent.ToggleSearchVisibility)
is RoomListEvent.ShowContextMenu -> {
coroutineScope.showContextMenu(event, contextMenu)
}
is RoomListEvents.HideContextMenu -> {
is RoomListEvent.HideContextMenu -> {
contextMenu.value = RoomListState.ContextMenu.Hidden
}
is RoomListEvents.LeaveRoom -> {
is RoomListEvent.LeaveRoom -> {
leaveRoomState.eventSink(LeaveRoomEvent.LeaveRoom(event.roomId, needsConfirmation = event.needsConfirmation))
}
is RoomListEvents.SetRoomIsFavorite -> coroutineScope.setRoomIsFavorite(event.roomId, event.isFavorite)
is RoomListEvents.MarkAsRead -> coroutineScope.markAsRead(event.roomId)
is RoomListEvents.MarkAsUnread -> coroutineScope.markAsUnread(event.roomId)
is RoomListEvents.AcceptInvite -> {
is RoomListEvent.SetRoomIsFavorite -> coroutineScope.setRoomIsFavorite(event.roomId, event.isFavorite)
is RoomListEvent.MarkAsRead -> coroutineScope.markAsRead(event.roomId)
is RoomListEvent.MarkAsUnread -> coroutineScope.markAsUnread(event.roomId)
is RoomListEvent.AcceptInvite -> {
acceptDeclineInviteState.eventSink(
AcceptInvite(event.roomSummary.toInviteData())
)
}
is RoomListEvents.DeclineInvite -> {
is RoomListEvent.DeclineInvite -> {
acceptDeclineInviteState.eventSink(
DeclineInvite(event.roomSummary.toInviteData(), blockUser = event.blockUser, shouldConfirm = false)
)
}
is RoomListEvents.ShowDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Shown(event.roomSummary)
RoomListEvents.HideDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Hidden
is RoomListEvents.ClearCacheOfRoom -> coroutineScope.clearCacheOfRoom(event.roomId)
is RoomListEvent.ShowDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Shown(event.roomSummary)
RoomListEvent.HideDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Hidden
is RoomListEvent.ClearCacheOfRoom -> coroutineScope.clearCacheOfRoom(event.roomId)
}
}
LaunchedEffect(filtersState.filterSelectionStates, spaceFiltersState.selectedFilter()) {
val selectedFilters = filtersState.selectedFilters().map { filter -> filter.into() }
val selectedSpaceFilter = spaceFiltersState.selectedFilter().into()
val allFilters = RoomListFilter.All(selectedFilters + listOfNotNull(selectedSpaceFilter))
roomListDataSource.updateFilter(allFilters)
}
val contentState = roomListContentState(
securityBannerDismissed,
showNewNotificationSoundBanner,
@ -168,6 +178,7 @@ class RoomListPresenter(
leaveRoomState = leaveRoomState,
filtersState = filtersState,
searchState = searchState,
spaceFiltersState = spaceFiltersState,
contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
hideInvitesAvatars = hideInvitesAvatar,
@ -217,7 +228,7 @@ class RoomListPresenter(
showNewNotificationSoundBanner: Boolean,
): RoomListContentState {
val roomSummaries by produceState(initialValue = AsyncData.Loading()) {
roomListDataSource.allRooms.collect { value = AsyncData.Success(it) }
roomListDataSource.roomSummariesFlow.collect { value = AsyncData.Success(it) }
}
val loadingState by roomListDataSource.loadingState.collectAsState()
val showEmpty by remember {
@ -253,7 +264,7 @@ class RoomListPresenter(
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun CoroutineScope.showContextMenu(event: RoomListEvents.ShowContextMenu, contextMenuState: MutableState<RoomListState.ContextMenu>) = launch {
private fun CoroutineScope.showContextMenu(event: RoomListEvent.ShowContextMenu, contextMenuState: MutableState<RoomListState.ContextMenu>) = launch {
val initialState = RoomListState.ContextMenu.Shown(
roomId = event.roomSummary.roomId,
roomName = event.roomSummary.name,
@ -322,23 +333,4 @@ class RoomListPresenter(
room.clearEventCacheStorage()
}
}
private var currentUpdateVisibleRangeJob: Job? = null
private fun CoroutineScope.updateVisibleRange(range: IntRange) {
currentUpdateVisibleRangeJob?.cancel()
currentUpdateVisibleRangeJob = launch {
// Debounce the subscription to avoid subscribing to too many rooms
delay(SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS)
if (range.isEmpty()) return@launch
val currentRoomList = roomListDataSource.allRooms.first()
// Use extended range to 'prefetch' the next rooms info
val midExtendedRangeSize = EXTENDED_RANGE_SIZE / 2
val extendedRange = range.first until range.last + midExtendedRangeSize
val roomIds = extendedRange.mapNotNull { index ->
currentRoomList.getOrNull(index)?.roomId
}
roomListDataSource.subscribeToVisibleRooms(roomIds)
}
}
}

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

View file

@ -15,7 +15,7 @@ open class RoomListStateContextMenuShownProvider : PreviewParameterProvider<Room
override val values: Sequence<RoomListState.ContextMenu.Shown>
get() = sequenceOf(
aContextMenuShown(hasNewContent = true),
aContextMenuShown(isDm = true),
aContextMenuShown(isDm = true, isFavorite = true),
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.search.RoomListSearchState
import io.element.android.features.home.impl.search.aRoomListSearchState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomEvent
@ -52,17 +54,19 @@ internal fun aRoomListState(
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
searchState: RoomListSearchState = aRoomListSearchState(),
filtersState: RoomListFiltersState = aRoomListFiltersState(),
spaceFiltersState: SpaceFiltersState = anUnselectedSpaceFiltersState(),
contentState: RoomListContentState = aRoomsContentState(),
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
hideInvitesAvatars: Boolean = false,
canReportRoom: Boolean = true,
eventSink: (RoomListEvents) -> Unit = {}
eventSink: (RoomListEvent) -> Unit = {}
) = RoomListState(
contextMenu = contextMenu,
declineInviteMenu = declineInviteMenu,
leaveRoomState = leaveRoomState,
filtersState = filtersState,
searchState = searchState,
spaceFiltersState = spaceFiltersState,
contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
hideInvitesAvatars = hideInvitesAvatars,

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

View file

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

View file

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

View file

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

View file

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

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 hideInvitesAvatar by client.rememberHideInvitesAvatar()
val spaceRooms by remember {
client.spaceService.spaceRoomsFlow.map { it.toImmutableList() }
client.spaceService.topLevelSpacesFlow.map { it.toImmutableList() }
}.collectAsState(persistentListOf())
val seenSpaceInvites by remember {

View file

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

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_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_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_title">"Ověřte, že jste to vy"</string>
</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_unread">"Märgi mitteloetuks"</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_title">"Verifitseeri, et see oled sina"</string>
</resources>

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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) {
JoinRule.Knock,
is JoinRule.KnockRestricted -> JoinAuthorisationStatus.CanKnock
JoinRule.Invite,
JoinRule.Private -> JoinAuthorisationStatus.NeedInvite
JoinRule.Invite -> JoinAuthorisationStatus.NeedInvite
is JoinRule.Restricted -> JoinAuthorisationStatus.Restricted
JoinRule.Public -> JoinAuthorisationStatus.CanJoin
else -> JoinAuthorisationStatus.Unknown
is JoinRule.Custom, null -> JoinAuthorisationStatus.Unknown
}
}

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

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

View file

@ -193,7 +193,8 @@ class LinkNewDeviceFlowNode(
is ErrorType.Unknown -> ErrorScreenType.UnknownError
is ErrorType.UnsupportedProtocol -> ErrorScreenType.UnknownError
}
// It is OK to push on backstack, since when user leaves the error screen, a new root will be set
// It is OK to push on backstack, since when user leaves the error screen, a new root will be set,
// or the whole flow will be popped.
backstack.push(NavTarget.Error(error))
}
@ -263,6 +264,12 @@ class LinkNewDeviceFlowNode(
linkNewDesktopHandler.reset()
backstack.newRoot(NavTarget.Root)
}
override fun onCancel() {
linkNewMobileHandler.reset()
linkNewDesktopHandler.reset()
callback.onDone()
}
}
createNode<ErrorNode>(buildContext, listOf(callback, navTarget.errorScreenType))
}

View file

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

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

View file

@ -1,16 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<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_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_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_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_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_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_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_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_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>
@ -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_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_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_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.

View file

@ -12,6 +12,7 @@ import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBackKey
@ -26,33 +27,45 @@ class ErrorViewTest {
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `on back pressed - calls the onRetry callback`() {
fun `on back pressed - calls the onCancel callback`() {
ensureCalledOnce { callback ->
rule.setErrorView(
onRetry = callback
onCancel = callback,
)
rule.pressBackKey()
}
}
@Test
fun `on start over button clicked - calls the expected callback`() {
fun `on try again button clicked - calls the expected callback`() {
ensureCalledOnce { callback ->
rule.setErrorView(
onRetry = callback
)
rule.clickOn(CommonStrings.action_start_over)
rule.clickOn(CommonStrings.action_try_again)
}
}
@Test
fun `on cancel button clicked - calls the expected callback`() {
ensureCalledOnce { callback ->
rule.setErrorView(
onCancel = callback
)
rule.clickOn(CommonStrings.action_cancel)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setErrorView(
onRetry: () -> Unit,
onRetry: () -> Unit = EnsureNeverCalled(),
onCancel: () -> Unit = EnsureNeverCalled(),
errorScreenType: ErrorScreenType = ErrorScreenType.UnknownError,
) {
setContent {
ErrorView(
errorScreenType = errorScreenType,
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.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dev.zacsweers.metro.AppScope
@ -196,7 +197,12 @@ class LoginFlowNode(
createNode<ChooseAccountProviderNode>(buildContext, listOf(callback))
}
NavTarget.QrCode -> {
createNode<QrCodeLoginFlowNode>(buildContext)
val callback = object : QrCodeLoginFlowNode.Callback {
override fun navigateBack() {
backstack.pop()
}
}
createNode<QrCodeLoginFlowNode>(buildContext, listOf(callback))
}
is NavTarget.ConfirmAccountProvider -> {
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.NodeInputs
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.DependencyInjectionGraphOwner
@ -64,6 +65,12 @@ class QrCodeLoginFlowNode(
buildContext = buildContext,
plugins = plugins,
), DependencyInjectionGraphOwner {
interface Callback : Plugin {
fun navigateBack()
}
private val callback: Callback = callback()
private var authenticationJob: Job? = null
override val graph = qrCodeLoginGraphFactory.create()
@ -85,7 +92,6 @@ class QrCodeLoginFlowNode(
override fun onBuilt() {
super.onBuilt()
observeLoginStep()
}
@ -113,7 +119,8 @@ class QrCodeLoginFlowNode(
is QrLoginException.Cancelled -> {
backstack.replace(NavTarget.Error(QrCodeErrorScreenType.Cancelled))
}
is QrLoginException.Expired -> {
is QrLoginException.Expired,
is QrLoginException.NotFound -> {
backstack.replace(NavTarget.Error(QrCodeErrorScreenType.Expired))
}
is QrLoginException.Declined -> {
@ -132,7 +139,9 @@ class QrCodeLoginFlowNode(
Timber.e(error, "OIDC metadata is invalid")
backstack.replace(NavTarget.Error(QrCodeErrorScreenType.UnknownError))
}
else -> {
QrLoginException.CheckCodeAlreadySent,
QrLoginException.CheckCodeCannotBeSent,
QrLoginException.Unknown -> {
Timber.e(error, "Unknown error found")
backstack.replace(NavTarget.Error(QrCodeErrorScreenType.UnknownError))
}
@ -178,7 +187,13 @@ class QrCodeLoginFlowNode(
}
is NavTarget.Error -> {
val callback = object : QrCodeErrorNode.Callback {
override fun onRetry() = reset()
override fun onRetry() {
reset()
}
override fun onCancel() {
callback.navigateBack()
}
}
createNode<QrCodeErrorNode>(buildContext, plugins = listOf(navTarget.errorType, callback))
}

View file

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

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

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_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_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_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.

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

View file

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

View file

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

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