Merge branch 'develop' into fix/start-voice-recording-when-permission-is-granted

This commit is contained in:
Karsten Knappe 2026-02-03 11:23:26 +01:00 committed by GitHub
commit 10bc15d647
215 changed files with 2436 additions and 2080 deletions

View file

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

View file

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

View file

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

View file

@ -3,16 +3,22 @@
<string name="screen_create_room_action_create_room">"Uus jututuba"</string> <string name="screen_create_room_action_create_room">"Uus jututuba"</string>
<string name="screen_create_room_add_people_title">"Kutsu osalejaid"</string> <string name="screen_create_room_add_people_title">"Kutsu osalejaid"</string>
<string name="screen_create_room_error_creating_room">"Jututoa loomisel tekkis viga"</string> <string name="screen_create_room_error_creating_room">"Jututoa loomisel tekkis viga"</string>
<string name="screen_create_room_private_option_description">"Ligipääs siia jututuppa on vaid kutse alusel. Kõik sõnumid siin jututoas on läbivalt krüptitud."</string> <string name="screen_create_room_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_public_option_description">"Kõik saavad seda jututuba leida. <string name="screen_create_room_public_option_description">"Kõik saavad seda jututuba leida.
Sa võid seda jututoa seadistustest alati muuta."</string> Sa võid seda jututoa seadistustest alati muuta."</string>
<string name="screen_create_room_public_option_title">"Avalik jututuba"</string> <string name="screen_create_room_public_option_title">"Avalik jututuba"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Kõik võivad paluda selle jututoaga liitumist, kuid peakasutaja või moderaator peavad selle kinnitama"</string> <string name="screen_create_room_room_access_section_knocking_option_description">"Kõik võivad paluda selle jututoaga liitumist, kuid peakasutaja või moderaator peavad selle kinnitama."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Küsi võimalust liitumiseks"</string> <string name="screen_create_room_room_access_section_knocking_option_title">"Luba küsida liitumisvõimalust"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Kõik võivad selle jututoaga liituda"</string> <string name="screen_create_room_room_access_section_public_option_description">"Kõik võivad selle jututoaga liituda."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Kõik kasutajad"</string> <string name="screen_create_room_room_access_section_public_option_title">"Kõik kasutajad"</string>
<string name="screen_create_room_room_address_section_footer">"Selleks, et see jututuba oleks nähtav jututubade avalikus kataloogis, sa vajad jututoa aadressi."</string> <string name="screen_create_room_room_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">"Jututoa aadress"</string> <string name="screen_create_room_room_address_section_title">"Aadress"</string>
<string name="screen_create_room_room_visibility_section_title">"Jututoa nähtavus"</string> <string name="screen_create_room_room_visibility_section_title">"Jututoa nähtavus"</string>
<string name="screen_create_room_space_selection_no_space_title">"Avaleht"</string>
<string name="screen_create_room_space_selection_sheet_title">"Lisa kogukonda"</string>
<string name="screen_create_room_topic_label">"Teema (kui soovid lisada)"</string> <string name="screen_create_room_topic_label">"Teema (kui soovid lisada)"</string>
<string name="screen_create_room_topic_placeholder">"Lisa kirjeldus…"</string>
</resources> </resources>

View file

@ -3,14 +3,14 @@
<string name="screen_create_room_action_create_room">"Nytt rum"</string> <string name="screen_create_room_action_create_room">"Nytt rum"</string>
<string name="screen_create_room_add_people_title">"Bjud in personer"</string> <string name="screen_create_room_add_people_title">"Bjud in personer"</string>
<string name="screen_create_room_error_creating_room">"Ett fel uppstod när rummet skapades"</string> <string name="screen_create_room_error_creating_room">"Ett fel uppstod när rummet skapades"</string>
<string name="screen_create_room_private_option_description">"Endast inbjudna personer har tillgång till detta rum. Alla meddelanden är totalsträckskrypterade."</string> <string name="screen_create_room_private_option_description">"Endast inbjudna personer kan gå med."</string>
<string name="screen_create_room_public_option_description">"Vem som helst kan hitta det här rummet. <string name="screen_create_room_public_option_description">"Vem som helst kan hitta det här rummet.
Du kan ändra detta när som helst i rumsinställningarna."</string> Du kan ändra detta när som helst i rumsinställningarna."</string>
<string name="screen_create_room_public_option_title">"Offentligt rum"</string> <string name="screen_create_room_public_option_title">"Offentligt rum"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Vem som helst kan be om att gå med i rummet men en administratör eller en moderator måste acceptera begäran"</string> <string name="screen_create_room_room_access_section_knocking_option_description">"Vem som helst kan be om att gå med 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_knocking_option_title">"Tillåt att be om att gå med"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Vem som helst kan gå med i det här rummet"</string> <string name="screen_create_room_room_access_section_public_option_description">"Vem som helst kan gå med 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_access_section_public_option_title">"Offentligt"</string>
<string name="screen_create_room_room_address_section_footer">"För att detta rum ska vara synligt i den allmänna rumskatalogen behöver du en rumsadress."</string> <string name="screen_create_room_room_address_section_footer">"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_address_section_title">"Rumsadress"</string>
<string name="screen_create_room_room_visibility_section_title">"Rumssynlighet"</string> <string name="screen_create_room_room_visibility_section_title">"Rumssynlighet"</string>

View file

@ -7,8 +7,6 @@
<string name="screen_create_room_name_placeholder">"Add name…"</string> <string name="screen_create_room_name_placeholder">"Add name…"</string>
<string name="screen_create_room_new_room_title">"New room"</string> <string name="screen_create_room_new_room_title">"New room"</string>
<string name="screen_create_room_new_space_title">"New space"</string> <string name="screen_create_room_new_space_title">"New space"</string>
<string name="screen_create_room_parent_space_home_description">"(no space)"</string>
<string name="screen_create_room_parent_space_home_title">"Home"</string>
<string name="screen_create_room_private_option_description">"Only people invited can join."</string> <string name="screen_create_room_private_option_description">"Only people invited can join."</string>
<string name="screen_create_room_private_option_title">"Private"</string> <string name="screen_create_room_private_option_title">"Private"</string>
<string name="screen_create_room_public_option_description">"Anyone can find this room. <string name="screen_create_room_public_option_description">"Anyone can find this room.

View file

@ -23,11 +23,7 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -55,6 +51,7 @@ import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -215,17 +212,8 @@ private fun RoomsViewList(
lazyListState: LazyListState, lazyListState: LazyListState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val visibleRange by remember { OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
derivedStateOf { eventSink(RoomListEvent.UpdateVisibleRange(visibleRange))
val layoutInfo = lazyListState.layoutInfo
val firstItemIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: 0
val size = layoutInfo.visibleItemsInfo.size
firstItemIndex until firstItemIndex + size
}
}
val updatedEventSink by rememberUpdatedState(newValue = eventSink)
LaunchedEffect(visibleRange) {
updatedEventSink(RoomListEvent.UpdateVisibleRange(visibleRange))
} }
LazyColumn( LazyColumn(
state = lazyListState, state = lazyListState,
@ -237,7 +225,7 @@ private fun RoomsViewList(
item { item {
SetUpRecoveryKeyBanner( SetUpRecoveryKeyBanner(
onContinueClick = onSetUpRecoveryClick, onContinueClick = onSetUpRecoveryClick,
onDismissClick = { updatedEventSink(RoomListEvent.DismissBanner) }, onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
) )
} }
} }
@ -245,7 +233,7 @@ private fun RoomsViewList(
item { item {
ConfirmRecoveryKeyBanner( ConfirmRecoveryKeyBanner(
onContinueClick = onConfirmRecoveryKeyClick, onContinueClick = onConfirmRecoveryKeyClick,
onDismissClick = { updatedEventSink(RoomListEvent.DismissBanner) }, onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
) )
} }
} }
@ -260,7 +248,7 @@ private fun RoomsViewList(
} else if (state.showNewNotificationSoundBanner) { } else if (state.showNewNotificationSoundBanner) {
item { item {
NewNotificationSoundBanner( NewNotificationSoundBanner(
onDismissClick = { updatedEventSink(RoomListEvent.DismissNewNotificationSoundBanner) }, onDismissClick = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) },
) )
} }
} }

View file

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

View file

@ -12,16 +12,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.home.impl.datasource.RoomListDataSource
import io.element.android.features.home.impl.filters.selection.FilterSelectionStrategy import io.element.android.features.home.impl.filters.selection.FilterSelectionStrategy
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter
@Inject @Inject
class RoomListFiltersPresenter( class RoomListFiltersPresenter(
private val roomListService: RoomListService, private val roomListDataSource: RoomListDataSource,
private val filterSelectionStrategy: FilterSelectionStrategy, private val filterSelectionStrategy: FilterSelectionStrategy,
) : Presenter<RoomListFiltersState> { ) : Presenter<RoomListFiltersState> {
private val initialFilters = filterSelectionStrategy.filterSelectionStates.value.toImmutableList() private val initialFilters = filterSelectionStrategy.filterSelectionStates.value.toImmutableList()
@ -56,9 +57,9 @@ class RoomListFiltersPresenter(
} }
} }
} }
.collect { filters -> .collectLatest { filters ->
val result = MatrixRoomListFilter.All(filters) val result = MatrixRoomListFilter.All(filters)
roomListService.allRooms.updateFilter(result) roomListDataSource.updateFilter(result)
} }
} }

View file

@ -57,8 +57,6 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -68,9 +66,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private const val EXTENDED_RANGE_SIZE = 40
private const val SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS = 300L
@Inject @Inject
class RoomListPresenter( class RoomListPresenter(
private val client: MatrixClient, private val client: MatrixClient,
@ -119,7 +114,7 @@ class RoomListPresenter(
fun handleEvent(event: RoomListEvent) { fun handleEvent(event: RoomListEvent) {
when (event) { when (event) {
is RoomListEvent.UpdateVisibleRange -> coroutineScope.launch { is RoomListEvent.UpdateVisibleRange -> coroutineScope.launch {
updateVisibleRange(event.range) roomListDataSource.updateVisibleRange(event.range)
} }
RoomListEvent.DismissRequestVerificationPrompt -> securityBannerDismissed = true RoomListEvent.DismissRequestVerificationPrompt -> securityBannerDismissed = true
RoomListEvent.DismissBanner -> securityBannerDismissed = true RoomListEvent.DismissBanner -> securityBannerDismissed = true
@ -217,7 +212,7 @@ class RoomListPresenter(
showNewNotificationSoundBanner: Boolean, showNewNotificationSoundBanner: Boolean,
): RoomListContentState { ): RoomListContentState {
val roomSummaries by produceState(initialValue = AsyncData.Loading()) { val roomSummaries by produceState(initialValue = AsyncData.Loading()) {
roomListDataSource.allRooms.collect { value = AsyncData.Success(it) } roomListDataSource.roomSummariesFlow.collect { value = AsyncData.Success(it) }
} }
val loadingState by roomListDataSource.loadingState.collectAsState() val loadingState by roomListDataSource.loadingState.collectAsState()
val showEmpty by remember { val showEmpty by remember {
@ -322,23 +317,4 @@ class RoomListPresenter(
room.clearEventCacheStorage() room.clearEventCacheStorage()
} }
} }
private var currentUpdateVisibleRangeJob: Job? = null
private fun CoroutineScope.updateVisibleRange(range: IntRange) {
currentUpdateVisibleRangeJob?.cancel()
currentUpdateVisibleRangeJob = launch {
// Debounce the subscription to avoid subscribing to too many rooms
delay(SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS)
if (range.isEmpty()) return@launch
val currentRoomList = roomListDataSource.allRooms.first()
// Use extended range to 'prefetch' the next rooms info
val midExtendedRangeSize = EXTENDED_RANGE_SIZE / 2
val extendedRange = range.first until range.last + midExtendedRangeSize
val roomIds = extendedRange.mapNotNull { index ->
currentRoomList.getOrNull(index)?.roomId
}
roomListDataSource.subscribeToVisibleRooms(roomIds)
}
}
} }

View file

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

View file

@ -11,4 +11,5 @@ package io.element.android.features.home.impl.search
sealed interface RoomListSearchEvent { sealed interface RoomListSearchEvent {
data object ToggleSearchVisibility : RoomListSearchEvent data object ToggleSearchVisibility : RoomListSearchEvent
data object ClearQuery : 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 dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
@Inject @Inject
class RoomListSearchPresenter( class RoomListSearchPresenter(
@ -37,10 +38,6 @@ class RoomListSearchPresenter(
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val dataSource = remember { dataSourceFactory.create(coroutineScope) } val dataSource = remember { dataSourceFactory.create(coroutineScope) }
LaunchedEffect(isSearchActive) {
dataSource.setIsActive(isSearchActive)
}
LaunchedEffect(searchQuery.text) { LaunchedEffect(searchQuery.text) {
dataSource.setSearchQuery(searchQuery.text.toString()) dataSource.setSearchQuery(searchQuery.text.toString())
} }
@ -54,6 +51,9 @@ class RoomListSearchPresenter(
isSearchActive = !isSearchActive isSearchActive = !isSearchActive
searchQuery.clearText() searchQuery.clearText()
} }
is RoomListSearchEvent.UpdateVisibleRange -> coroutineScope.launch {
dataSource.updateVisibleRange(visibleRange = event.range)
}
} }
} }

View file

@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -47,6 +48,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -154,7 +156,12 @@ private fun RoomListSearchContent(
.padding(padding) .padding(padding)
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
) { ) {
val lazyListState = rememberLazyListState()
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
state.eventSink(RoomListSearchEvent.UpdateVisibleRange(visibleRange))
}
LazyColumn( LazyColumn(
state = lazyListState,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { ) {
items( items(

View file

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

View file

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

View file

@ -9,15 +9,28 @@
package io.element.android.features.home.impl.filters package io.element.android.features.home.impl.filters
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.impl.FakeDateTimeObserver
import io.element.android.features.home.impl.datasource.RoomListDataSource
import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory
import io.element.android.features.home.impl.filters.selection.DefaultFilterSelectionStrategy import io.element.android.features.home.impl.filters.selection.DefaultFilterSelectionStrategy
import io.element.android.features.home.impl.filters.selection.FilterSelectionState import io.element.android.features.home.impl.filters.selection.FilterSelectionState
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter
class RoomListFiltersPresenterTest { class RoomListFiltersPresenterTest {
@Test @Test
@ -39,13 +52,13 @@ class RoomListFiltersPresenterTest {
} }
@Test @Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `present - toggle rooms filter`() = runTest { fun `present - toggle rooms filter`() = runTest {
val roomListService = FakeRoomListService() val roomListService = FakeRoomListService()
val presenter = createRoomListFiltersPresenter(roomListService) val presenter = createRoomListFiltersPresenter(roomListService)
presenter.test { presenter.test {
awaitItem().eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms)) awaitItem().eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms))
awaitLastSequentialItem().let { state -> awaitLastSequentialItem().let { state ->
assertThat(state.hasAnyFilterSelected).isTrue() assertThat(state.hasAnyFilterSelected).isTrue()
assertThat(state.filterSelectionStates).containsExactly( assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.Rooms, true), filterSelectionState(RoomListFilter.Rooms, true),
@ -56,12 +69,9 @@ class RoomListFiltersPresenterTest {
assertThat(state.selectedFilters()).containsExactly( assertThat(state.selectedFilters()).containsExactly(
RoomListFilter.Rooms, RoomListFilter.Rooms,
) )
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
assertThat(roomListCurrentFilter.filters).containsExactly(
MatrixRoomListFilter.Category.Group,
)
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms)) state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms))
} }
advanceUntilIdle()
awaitLastSequentialItem().let { state -> awaitLastSequentialItem().let { state ->
assertThat(state.hasAnyFilterSelected).isFalse() assertThat(state.hasAnyFilterSelected).isFalse()
assertThat(state.filterSelectionStates).containsExactly( assertThat(state.filterSelectionStates).containsExactly(
@ -72,13 +82,12 @@ class RoomListFiltersPresenterTest {
filterSelectionState(RoomListFilter.Invites, false), filterSelectionState(RoomListFilter.Invites, false),
).inOrder() ).inOrder()
assertThat(state.selectedFilters()).isEmpty() assertThat(state.selectedFilters()).isEmpty()
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
assertThat(roomListCurrentFilter.filters).isEmpty()
} }
} }
} }
@Test @Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `present - clear filters event`() = runTest { fun `present - clear filters event`() = runTest {
val roomListService = FakeRoomListService() val roomListService = FakeRoomListService()
val presenter = createRoomListFiltersPresenter(roomListService) val presenter = createRoomListFiltersPresenter(roomListService)
@ -88,6 +97,7 @@ class RoomListFiltersPresenterTest {
assertThat(state.hasAnyFilterSelected).isTrue() assertThat(state.hasAnyFilterSelected).isTrue()
state.eventSink.invoke(RoomListFiltersEvent.ClearSelectedFilters) state.eventSink.invoke(RoomListFiltersEvent.ClearSelectedFilters)
} }
advanceUntilIdle()
awaitLastSequentialItem().let { state -> awaitLastSequentialItem().let { state ->
assertThat(state.hasAnyFilterSelected).isFalse() assertThat(state.hasAnyFilterSelected).isFalse()
} }
@ -100,11 +110,25 @@ private fun filterSelectionState(filter: RoomListFilter, selected: Boolean) = Fi
isSelected = selected, isSelected = selected,
) )
private fun createRoomListFiltersPresenter( private fun TestScope.createRoomListFiltersPresenter(
roomListService: RoomListService = FakeRoomListService(), roomListService: RoomListService = FakeRoomListService(),
notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
dateFormatter: DateFormatter = FakeDateFormatter(),
roomLatestEventFormatter: RoomLatestEventFormatter = FakeRoomLatestEventFormatter(),
): RoomListFiltersPresenter { ): RoomListFiltersPresenter {
return RoomListFiltersPresenter( return RoomListFiltersPresenter(
roomListService = roomListService, roomListDataSource = RoomListDataSource(
roomListService = roomListService,
roomListRoomSummaryFactory = aRoomListRoomSummaryFactory(
dateFormatter = dateFormatter,
roomLatestEventFormatter = roomLatestEventFormatter,
),
coroutineDispatchers = testCoroutineDispatchers(),
notificationSettingsService = notificationSettingsService,
sessionCoroutineScope = backgroundScope,
dateTimeObserver = FakeDateTimeObserver(),
analyticsService = FakeAnalyticsService(),
),
filterSelectionStrategy = DefaultFilterSelectionStrategy(), filterSelectionStrategy = DefaultFilterSelectionStrategy(),
) )
} }

View file

@ -55,6 +55,7 @@ import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.aRoomSummary import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
@ -77,6 +78,7 @@ import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceTimeBy
@ -91,7 +93,10 @@ class RoomListPresenterTest {
@Test @Test
fun `present - load 1 room with success`() = runTest { fun `present - load 1 room with success`() = runTest {
val roomListService = FakeRoomListService() val roomList = FakeDynamicRoomList()
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
roomListService = roomListService roomListService = roomListService
) )
@ -102,8 +107,8 @@ class RoomListPresenterTest {
presenter.test { presenter.test {
val initialState = consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Skeleton }.last() val initialState = consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Skeleton }.last()
assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Skeleton::class.java) assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Skeleton::class.java)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) roomList.loadingState.emit(RoomList.LoadingState.Loaded(1))
roomListService.postAllRooms( roomList.summaries.emit(
listOf( listOf(
aRoomSummary( aRoomSummary(
numUnreadMentions = 1, numUnreadMentions = 1,
@ -128,9 +133,12 @@ class RoomListPresenterTest {
@Test @Test
fun `present - handle DismissRequestVerificationPrompt`() = runTest { fun `present - handle DismissRequestVerificationPrompt`() = runTest {
val roomListService = FakeRoomListService().apply { val roomList = FakeDynamicRoomList(
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
} )
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val encryptionService = FakeEncryptionService().apply { val encryptionService = FakeEncryptionService().apply {
emitRecoveryState(RecoveryState.INCOMPLETE) emitRecoveryState(RecoveryState.INCOMPLETE)
} }
@ -154,9 +162,12 @@ class RoomListPresenterTest {
val encryptionService = FakeEncryptionService().apply { val encryptionService = FakeEncryptionService().apply {
recoveryStateStateFlow.emit(RecoveryState.DISABLED) recoveryStateStateFlow.emit(RecoveryState.DISABLED)
} }
val roomListService = FakeRoomListService().apply { val roomList = FakeDynamicRoomList(
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
} )
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
roomListService = roomListService, roomListService = roomListService,
encryptionService = encryptionService, encryptionService = encryptionService,
@ -344,9 +355,13 @@ class RoomListPresenterTest {
fun `present - change in notification settings updates the summary for decorations`() = runTest { fun `present - change in notification settings updates the summary for decorations`() = runTest {
val userDefinedMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY val userDefinedMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
val notificationSettingsService = FakeNotificationSettingsService() val notificationSettingsService = FakeNotificationSettingsService()
val roomListService = FakeRoomListService() val roomList = FakeDynamicRoomList(
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) summaries = MutableStateFlow(listOf(aRoomSummary(userDefinedNotificationMode = userDefinedMode))),
roomListService.postAllRooms(listOf(aRoomSummary(userDefinedNotificationMode = userDefinedMode))) loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
roomListService = roomListService, roomListService = roomListService,
notificationSettingsService = notificationSettingsService notificationSettingsService = notificationSettingsService
@ -397,8 +412,12 @@ class RoomListPresenterTest {
@Test @Test
fun `present - when room service returns no room, then contentState is Empty`() = runTest { fun `present - when room service returns no room, then contentState is Empty`() = runTest {
val roomListService = FakeRoomListService() val roomList = FakeDynamicRoomList(
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(0)) loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(0))
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
roomListService = roomListService, roomListService = roomListService,
) )
@ -479,16 +498,21 @@ class RoomListPresenterTest {
val acceptDeclinePresenter = Presenter { val acceptDeclinePresenter = Presenter {
anAcceptDeclineInviteState(eventSink = eventSinkRecorder) anAcceptDeclineInviteState(eventSink = eventSinkRecorder)
} }
val roomListService = FakeRoomListService()
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val roomSummary = aRoomSummary( val roomSummary = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED, currentUserMembership = CurrentUserMembership.INVITED,
inviter = aRoomMember(), inviter = aRoomMember(),
) )
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) val roomList = FakeDynamicRoomList(
roomListService.postAllRooms(listOf(roomSummary)) summaries = MutableStateFlow(listOf(roomSummary)),
loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
client = matrixClient, client = matrixClient,
acceptDeclineInvitePresenter = acceptDeclinePresenter acceptDeclineInvitePresenter = acceptDeclinePresenter
@ -519,15 +543,20 @@ class RoomListPresenterTest {
@Test @Test
fun `present - UpdateVisibleRange will cancel the previous subscription if called too soon`() = runTest { fun `present - UpdateVisibleRange will cancel the previous subscription if called too soon`() = runTest {
val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List<RoomId> -> } val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List<RoomId> -> }
val roomListService = FakeRoomListService(subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda)
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val roomSummary = aRoomSummary( val roomSummary = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED currentUserMembership = CurrentUserMembership.INVITED
) )
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) val roomList = FakeDynamicRoomList(
roomListService.postAllRooms(listOf(roomSummary)) summaries = MutableStateFlow(listOf(roomSummary)),
loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList },
subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda,
)
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
client = matrixClient, client = matrixClient,
) )
@ -548,15 +577,20 @@ class RoomListPresenterTest {
@Test @Test
fun `present - UpdateVisibleRange subscribes to rooms in visible range`() = runTest { fun `present - UpdateVisibleRange subscribes to rooms in visible range`() = runTest {
val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List<RoomId> -> } val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List<RoomId> -> }
val roomListService = FakeRoomListService(subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda)
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val roomSummary = aRoomSummary( val roomSummary = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED currentUserMembership = CurrentUserMembership.INVITED
) )
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) val roomList = FakeDynamicRoomList(
roomListService.postAllRooms(listOf(roomSummary)) summaries = MutableStateFlow(listOf(roomSummary)),
loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList },
subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda,
)
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
client = matrixClient, client = matrixClient,
) )
@ -579,15 +613,20 @@ class RoomListPresenterTest {
@Test @Test
fun `present - notification sound banner`() = runTest { fun `present - notification sound banner`() = runTest {
val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List<RoomId> -> } val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List<RoomId> -> }
val roomListService = FakeRoomListService(subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda)
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val roomSummary = aRoomSummary( val roomSummary = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED currentUserMembership = CurrentUserMembership.INVITED
) )
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) val roomList = FakeDynamicRoomList(
roomListService.postAllRooms(listOf(roomSummary)) summaries = MutableStateFlow(listOf(roomSummary)),
loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList },
subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda,
)
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val onAnnouncementDismissedResult = lambdaRecorder<Announcement, Unit> { } val onAnnouncementDismissedResult = lambdaRecorder<Announcement, Unit> { }
val announcementService = FakeAnnouncementService( val announcementService = FakeAnnouncementService(
onAnnouncementDismissedResult = onAnnouncementDismissedResult, onAnnouncementDismissedResult = onAnnouncementDismissedResult,

View file

@ -15,7 +15,10 @@ import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventForma
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.room.aRoomSummary import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -56,12 +59,15 @@ class RoomListSearchPresenterTest {
@Test @Test
fun `present - query search changes`() = runTest { fun `present - query search changes`() = runTest {
val roomListService = FakeRoomListService() val roomList = FakeDynamicRoomList()
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createRoomListSearchPresenter(roomListService) val presenter = createRoomListSearchPresenter(roomListService)
presenter.test { presenter.test {
awaitItem().let { state -> awaitItem().let { state ->
assertThat( assertThat(
roomListService.allRooms.currentFilter.value roomList.currentFilter.value
).isEqualTo( ).isEqualTo(
RoomListFilter.None RoomListFilter.None
) )
@ -70,7 +76,7 @@ class RoomListSearchPresenterTest {
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.query.text).isEqualTo("Search") assertThat(state.query.text).isEqualTo("Search")
assertThat( assertThat(
roomListService.allRooms.currentFilter.value roomList.currentFilter.value
).isEqualTo( ).isEqualTo(
RoomListFilter.NormalizedMatchRoomName("Search") RoomListFilter.NormalizedMatchRoomName("Search")
) )
@ -79,7 +85,7 @@ class RoomListSearchPresenterTest {
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.query.text.toString()).isEmpty() assertThat(state.query.text.toString()).isEmpty()
assertThat( assertThat(
roomListService.allRooms.currentFilter.value roomList.currentFilter.value
).isEqualTo( ).isEqualTo(
RoomListFilter.None RoomListFilter.None
) )
@ -89,24 +95,51 @@ class RoomListSearchPresenterTest {
@Test @Test
fun `present - room list changes`() = runTest { fun `present - room list changes`() = runTest {
val roomListService = FakeRoomListService() val roomList = FakeDynamicRoomList()
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createRoomListSearchPresenter(roomListService) val presenter = createRoomListSearchPresenter(roomListService)
presenter.test { presenter.test {
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.results).isEmpty() assertThat(state.results).isEmpty()
} }
roomListService.postAllRooms( roomList.summaries.emit(
listOf(aRoomSummary()) listOf(aRoomSummary())
) )
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.results).hasSize(1) assertThat(state.results).hasSize(1)
} }
roomListService.postAllRooms(emptyList()) roomList.summaries.emit(emptyList())
awaitItem().let { state -> awaitItem().let { state ->
assertThat(state.results).isEmpty() assertThat(state.results).isEmpty()
} }
} }
} }
@Test
fun `present - UpdateVisibleRange triggers pagination when near end`() = runTest {
val loadMoreLambda = lambdaRecorder<Unit> { }
val roomList = FakeDynamicRoomList(loadMoreLambda = loadMoreLambda)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createRoomListSearchPresenter(roomListService)
presenter.test {
val initialState = awaitItem()
// Post some rooms to simulate loaded content
val rooms = (1..10).map { aRoomSummary() }
roomList.summaries.emit(rooms)
skipItems(1)
// UpdateVisibleRange near end should trigger loadMore
initialState.eventSink(RoomListSearchEvent.UpdateVisibleRange(IntRange(0, 9)))
// Give time for the coroutine to complete
testScheduler.advanceUntilIdle()
assert(loadMoreLambda).isCalledOnce()
}
}
} }
fun TestScope.createRoomListSearchPresenter( fun TestScope.createRoomListSearchPresenter(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -37,6 +37,7 @@ import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.di.DependencyInjectionGraphOwner
@ -64,6 +65,12 @@ class QrCodeLoginFlowNode(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
), DependencyInjectionGraphOwner { ), DependencyInjectionGraphOwner {
interface Callback : Plugin {
fun navigateBack()
}
private val callback: Callback = callback()
private var authenticationJob: Job? = null private var authenticationJob: Job? = null
override val graph = qrCodeLoginGraphFactory.create() override val graph = qrCodeLoginGraphFactory.create()
@ -85,7 +92,6 @@ class QrCodeLoginFlowNode(
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
observeLoginStep() observeLoginStep()
} }
@ -178,7 +184,13 @@ class QrCodeLoginFlowNode(
} }
is NavTarget.Error -> { is NavTarget.Error -> {
val callback = object : QrCodeErrorNode.Callback { val callback = object : QrCodeErrorNode.Callback {
override fun onRetry() = reset() override fun onRetry() {
reset()
}
override fun onCancel() {
callback.navigateBack()
}
} }
createNode<QrCodeErrorNode>(buildContext, plugins = listOf(navTarget.errorType, callback)) createNode<QrCodeErrorNode>(buildContext, plugins = listOf(navTarget.errorType, callback))
} }

View file

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

View file

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

View file

@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -183,7 +184,11 @@ class QrCodeLoginFlowNodeTest {
) )
return QrCodeLoginFlowNode( return QrCodeLoginFlowNode(
buildContext = buildContext, buildContext = buildContext,
plugins = emptyList(), plugins = listOf(
object : QrCodeLoginFlowNode.Callback {
override fun navigateBack() = lambdaError()
}
),
qrCodeLoginGraphFactory = FakeQrCodeLoginGraph.Builder(qrCodeLoginManager), qrCodeLoginGraphFactory = FakeQrCodeLoginGraph.Builder(qrCodeLoginManager),
coroutineDispatchers = coroutineDispatchers, coroutineDispatchers = coroutineDispatchers,
) )

View file

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

View file

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

View file

@ -54,10 +54,22 @@ data class MessagesState(
val pinnedMessagesBannerState: PinnedMessagesBannerState, val pinnedMessagesBannerState: PinnedMessagesBannerState,
val dmUserVerificationState: IdentityState?, val dmUserVerificationState: IdentityState?,
val roomMemberModerationState: RoomMemberModerationState, val roomMemberModerationState: RoomMemberModerationState,
/** Should the top bar include the "history" icon? */ /** Type of "shared history" icon to show in the top bar. */
val showSharedHistoryIcon: Boolean, val topBarSharedHistoryIcon: SharedHistoryIcon,
val successorRoom: SuccessorRoom?, val successorRoom: SuccessorRoom?,
val eventSink: (MessagesEvent) -> Unit val eventSink: (MessagesEvent) -> Unit
) { ) {
val isTombstoned = successorRoom != null val isTombstoned = successorRoom != null
} }
/** Type of "shared history" icon to show in the top bar. */
enum class SharedHistoryIcon {
/** Show no icon at all. */
NONE,
/** history_visibility: shared. */
SHARED,
/** history_visibility: world_readable. */
WORLD_READABLE
}

View file

@ -120,7 +120,7 @@ fun aMessagesState(
pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(), pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(),
dmUserVerificationState: IdentityState? = null, dmUserVerificationState: IdentityState? = null,
roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(), roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(),
showSharedHistoryIcon: Boolean = false, topBarSharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE,
successorRoom: SuccessorRoom? = null, successorRoom: SuccessorRoom? = null,
eventSink: (MessagesEvent) -> Unit = {}, eventSink: (MessagesEvent) -> Unit = {},
) = MessagesState( ) = MessagesState(
@ -148,7 +148,7 @@ fun aMessagesState(
pinnedMessagesBannerState = pinnedMessagesBannerState, pinnedMessagesBannerState = pinnedMessagesBannerState,
dmUserVerificationState = dmUserVerificationState, dmUserVerificationState = dmUserVerificationState,
roomMemberModerationState = roomMemberModerationState, roomMemberModerationState = roomMemberModerationState,
showSharedHistoryIcon = showSharedHistoryIcon, topBarSharedHistoryIcon = topBarSharedHistoryIcon,
successorRoom = successorRoom, successorRoom = successorRoom,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -225,7 +225,7 @@ fun MessagesView(
heroes = state.heroes, heroes = state.heroes,
roomCallState = state.roomCallState, roomCallState = state.roomCallState,
dmUserIdentityState = state.dmUserVerificationState, dmUserIdentityState = state.dmUserVerificationState,
showSharedHistoryIcon = state.showSharedHistoryIcon, sharedHistoryIcon = state.topBarSharedHistoryIcon,
onBackClick = { hidingKeyboard { onBackClick() } }, onBackClick = { hidingKeyboard { onBackClick() } },
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } }, onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
onJoinCallClick = onJoinCallClick, onJoinCallClick = onJoinCallClick,

View file

@ -19,6 +19,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
@ -29,6 +30,7 @@ import io.element.android.annotations.ContributesNode
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.MessagesNavigator import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.MessagesPresenter import io.element.android.features.messages.impl.MessagesPresenter
import io.element.android.features.messages.impl.MessagesState
import io.element.android.features.messages.impl.MessagesView import io.element.android.features.messages.impl.MessagesView
import io.element.android.features.messages.impl.actionlist.ActionListPresenter import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
@ -44,11 +46,11 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.androidutils.system.openUrlInExternalApp import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -67,22 +69,19 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@AssistedInject @AssistedInject
class ThreadedMessagesNode( class ThreadedMessagesNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val room: JoinedRoom, private val room: JoinedRoom,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
messageComposerPresenterFactory: MessageComposerPresenter.Factory, private val messageComposerPresenterFactory: MessageComposerPresenter.Factory,
timelinePresenterFactory: TimelinePresenter.Factory, private val timelinePresenterFactory: TimelinePresenter.Factory,
presenterFactory: MessagesPresenter.Factory, private val presenterFactory: MessagesPresenter.Factory,
actionListPresenterFactory: ActionListPresenter.Factory, private val actionListPresenterFactory: ActionListPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories, private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer, private val mediaPlayer: MediaPlayer,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
@ -96,20 +95,29 @@ class ThreadedMessagesNode(
private val inputs = inputs<Inputs>() private val inputs = inputs<Inputs>()
private val callback: Callback = callback() private val callback: Callback = callback()
// TODO use a loading state node to preload this instead of using `runBlocking` private var timelineController: TimelineController? by mutableStateOf(null)
private val threadedTimeline = runBlocking { room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = inputs.threadRootEventId)).getOrThrow() } private var presenter: Presenter<MessagesState>? by mutableStateOf(null)
private val timelineController = TimelineController(room, threadedTimeline)
private val presenter = presenterFactory.create( /**
navigator = this, * This should be fast to load, but not faster than several UI frames, which will cause ANRs.
composerPresenter = messageComposerPresenterFactory.create(timelineController, this), * We'll load the [presenter] in an async way to prevent this.
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this), */
// TODO add special processor for threaded timeline private suspend fun createPresenter(): Presenter<MessagesState> {
actionListPresenter = actionListPresenterFactory.create( val threadedTimeline = room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = inputs.threadRootEventId)).getOrThrow()
postProcessor = TimelineItemActionPostProcessor.Default, val timelineController = TimelineController(room, threadedTimeline)
timelineMode = timelineController.mainTimelineMode(), this.timelineController = timelineController
), return presenterFactory.create(
timelineController = timelineController, navigator = this,
) composerPresenter = messageComposerPresenterFactory.create(timelineController, this),
timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this),
// TODO add special processor for threaded timeline
actionListPresenter = actionListPresenterFactory.create(
postProcessor = TimelineItemActionPostProcessor.Default,
timelineMode = timelineController.mainTimelineMode(),
),
timelineController = timelineController,
)
}
interface Callback : Plugin { interface Callback : Plugin {
fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean
@ -130,7 +138,10 @@ class ThreadedMessagesNode(
super.onBuilt() super.onBuilt()
lifecycle.subscribe( lifecycle.subscribe(
onCreate = { onCreate = {
sessionCoroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) } analyticsService.capture(room.toAnalyticsViewRoom())
lifecycleScope.launch {
presenter = createPresenter()
}
}, },
onStart = { onStart = {
appNavigationStateService.onNavigateToThread(id, inputs.threadRootEventId) appNavigationStateService.onNavigateToThread(id, inputs.threadRootEventId)
@ -231,56 +242,61 @@ class ThreadedMessagesNode(
CompositionLocalProvider( CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) { ) {
val state = presenter.present() // Only display the actual UI and lifecycle logic if the presenter is loaded
OnLifecycleEvent { _, event -> presenter?.present()?.let { state ->
when (event) { OnLifecycleEvent { _, event ->
Lifecycle.Event.ON_PAUSE -> state.composerState.eventSink(MessageComposerEvent.SaveDraft) when (event) {
else -> Unit Lifecycle.Event.ON_PAUSE -> state.composerState.eventSink(MessageComposerEvent.SaveDraft)
} else -> Unit
}
MessagesView(
state = state,
onBackClick = this::navigateUp,
onRoomDetailsClick = {},
onEventContentClick = { isLive, event ->
if (isLive) {
callback.handleEventClick(timelineController.mainTimelineMode(), event)
} else {
val detachedTimelineMode = timelineController.detachedTimelineMode()
if (detachedTimelineMode != null) {
callback.handleEventClick(detachedTimelineMode, event)
} else {
false
}
} }
},
onUserDataClick = callback::navigateToRoomMemberDetails,
onLinkClick = { url, customTab ->
onLinkClick(
activity = activity,
darkTheme = isDark,
url = url,
eventSink = state.timelineState.eventSink,
customTab = customTab,
)
},
onSendLocationClick = callback::navigateToSendLocation,
onCreatePollClick = callback::navigateToCreatePoll,
onJoinCallClick = { callback.navigateToRoomCall(room.roomId) },
onViewAllPinnedMessagesClick = {},
modifier = modifier,
knockRequestsBannerView = {},
)
var focusedEventId by rememberSaveable {
mutableStateOf(inputs.focusedEventId)
}
LaunchedEffect(Unit) {
focusedEventId?.also { eventId ->
state.timelineState.eventSink(TimelineEvent.FocusOnEvent(eventId))
} }
// Reset the focused event id to null to avoid refocusing when restoring node.
focusedEventId = null MessagesView(
state = state,
onBackClick = this::navigateUp,
onRoomDetailsClick = {},
onEventContentClick = { isLive, event ->
timelineController?.let { controller ->
if (isLive) {
callback.handleEventClick(controller.mainTimelineMode(), event)
} else {
val detachedTimelineMode = controller.detachedTimelineMode()
if (detachedTimelineMode != null) {
callback.handleEventClick(detachedTimelineMode, event)
} else {
false
}
}
} == true
},
onUserDataClick = callback::navigateToRoomMemberDetails,
onLinkClick = { url, customTab ->
onLinkClick(
activity = activity,
darkTheme = isDark,
url = url,
eventSink = state.timelineState.eventSink,
customTab = customTab,
)
},
onSendLocationClick = callback::navigateToSendLocation,
onCreatePollClick = callback::navigateToCreatePoll,
onJoinCallClick = { callback.navigateToRoomCall(room.roomId) },
onViewAllPinnedMessagesClick = {},
modifier = modifier,
knockRequestsBannerView = {},
)
var focusedEventId by rememberSaveable {
mutableStateOf(inputs.focusedEventId)
}
LaunchedEffect(Unit) {
focusedEventId?.also { eventId ->
state.timelineState.eventSink(TimelineEvent.FocusOnEvent(eventId))
}
// Reset the focused event id to null to avoid refocusing when restoring node.
focusedEventId = null
}
} }
} }
} }

View file

@ -30,6 +30,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.SharedHistoryIcon
import io.element.android.features.messages.impl.timeline.components.CallMenuItem import io.element.android.features.messages.impl.timeline.components.CallMenuItem
import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roomcall.api.aStandByCallState
@ -63,7 +64,7 @@ internal fun MessagesViewTopBar(
heroes: ImmutableList<AvatarData>, heroes: ImmutableList<AvatarData>,
roomCallState: RoomCallState, roomCallState: RoomCallState,
dmUserIdentityState: IdentityState?, dmUserIdentityState: IdentityState?,
showSharedHistoryIcon: Boolean, sharedHistoryIcon: SharedHistoryIcon,
onRoomDetailsClick: () -> Unit, onRoomDetailsClick: () -> Unit,
onJoinCallClick: () -> Unit, onJoinCallClick: () -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
@ -110,12 +111,18 @@ internal fun MessagesViewTopBar(
else -> Unit else -> Unit
} }
if (showSharedHistoryIcon) { when (sharedHistoryIcon) {
Icon( SharedHistoryIcon.NONE -> Unit
SharedHistoryIcon.SHARED -> Icon(
imageVector = CompoundIcons.History(), imageVector = CompoundIcons.History(),
tint = ElementTheme.colors.iconInfoPrimary, tint = ElementTheme.colors.iconInfoPrimary,
contentDescription = stringResource(CommonStrings.common_shared_history), contentDescription = stringResource(CommonStrings.common_shared_history),
) )
SharedHistoryIcon.WORLD_READABLE -> Icon(
imageVector = CompoundIcons.UserProfileSolid(),
tint = ElementTheme.colors.iconInfoPrimary,
contentDescription = stringResource(CommonStrings.common_world_readable_history),
)
} }
} }
}, },
@ -178,7 +185,7 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
heroes: ImmutableList<AvatarData> = persistentListOf(), heroes: ImmutableList<AvatarData> = persistentListOf(),
roomCallState: RoomCallState = RoomCallState.Unavailable, roomCallState: RoomCallState = RoomCallState.Unavailable,
dmUserIdentityState: IdentityState? = null, dmUserIdentityState: IdentityState? = null,
showSharedHistoryIcon: Boolean = false, sharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE,
) = MessagesViewTopBar( ) = MessagesViewTopBar(
roomName = roomName, roomName = roomName,
roomAvatar = roomAvatar, roomAvatar = roomAvatar,
@ -186,7 +193,7 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
heroes = heroes, heroes = heroes,
roomCallState = roomCallState, roomCallState = roomCallState,
dmUserIdentityState = dmUserIdentityState, dmUserIdentityState = dmUserIdentityState,
showSharedHistoryIcon = showSharedHistoryIcon, sharedHistoryIcon = sharedHistoryIcon,
onRoomDetailsClick = {}, onRoomDetailsClick = {},
onJoinCallClick = {}, onJoinCallClick = {},
onBackClick = {}, onBackClick = {},
@ -223,7 +230,12 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
AMessagesViewTopBar( AMessagesViewTopBar(
roomName = "A DM with shared history", roomName = "A DM with shared history",
dmUserIdentityState = IdentityState.Verified, dmUserIdentityState = IdentityState.Verified,
showSharedHistoryIcon = true, sharedHistoryIcon = SharedHistoryIcon.SHARED,
)
HorizontalDivider()
AMessagesViewTopBar(
roomName = "A room with world_readable history",
sharedHistoryIcon = SharedHistoryIcon.WORLD_READABLE,
) )
} }
} }

View file

@ -31,7 +31,7 @@
<string name="screen_report_content_explanation">"Teade selle sõnumi kohta edastatakse sinu koduserveri haldajale. Haldajal ei ole võimalik lugeda krüptitud sõnumite sisu."</string> <string name="screen_report_content_explanation">"Teade selle sõnumi kohta edastatakse sinu koduserveri haldajale. Haldajal ei ole võimalik lugeda krüptitud sõnumite sisu."</string>
<string name="screen_report_content_hint">"Sellest sisust teatamise põhjus"</string> <string name="screen_report_content_hint">"Sellest sisust teatamise põhjus"</string>
<string name="screen_room_attachment_source_camera">"Kaamera"</string> <string name="screen_room_attachment_source_camera">"Kaamera"</string>
<string name="screen_room_attachment_source_camera_photo">"Tee pilt"</string> <string name="screen_room_attachment_source_camera_photo">"Pildista"</string>
<string name="screen_room_attachment_source_camera_video">"Salvesta video"</string> <string name="screen_room_attachment_source_camera_video">"Salvesta video"</string>
<string name="screen_room_attachment_source_files">"Manus"</string> <string name="screen_room_attachment_source_files">"Manus"</string>
<string name="screen_room_attachment_source_gallery">"Fotode ja videote galerii"</string> <string name="screen_room_attachment_source_gallery">"Fotode ja videote galerii"</string>

View file

@ -1217,7 +1217,7 @@ class MessagesPresenterTest {
} }
@Test @Test
fun `present - shows a "history" icon if the room is encrypted and history is shared`() = runTest { fun `present - shows a history icon if the room is encrypted and history is shared`() = runTest {
val presenter = createMessagesPresenter( val presenter = createMessagesPresenter(
joinedRoom = FakeJoinedRoom( joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
@ -1233,7 +1233,28 @@ class MessagesPresenterTest {
awaitItem() awaitItem()
runCurrent() runCurrent()
val state = awaitItem() val state = awaitItem()
assertThat(state.showSharedHistoryIcon).isTrue() assertThat(state.topBarSharedHistoryIcon).isEqualTo(SharedHistoryIcon.SHARED)
}
}
@Test
fun `present - shows a "world_readable" icon if the room is encrypted and history is world_readable`() = runTest {
val presenter = createMessagesPresenter(
joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(isEncrypted = true, historyVisibility = RoomHistoryVisibility.WorldReadable),
),
),
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.EnableKeyShareOnInvite.key to true)
)
)
presenter.testWithLifecycleOwner {
awaitItem()
runCurrent()
val state = awaitItem()
assertThat(state.topBarSharedHistoryIcon).isEqualTo(SharedHistoryIcon.WORLD_READABLE)
} }
} }

View file

@ -15,6 +15,7 @@ import io.element.android.features.messages.impl.messagecomposer.suggestions.Roo
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.room.aRoomSummary import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -22,7 +23,10 @@ import org.junit.Test
class DefaultRoomAliasSuggestionsDataSourceTest { class DefaultRoomAliasSuggestionsDataSourceTest {
@Test @Test
fun `getAllRoomAliasSuggestions must emit a list of room alias suggestions`() = runTest { fun `getAllRoomAliasSuggestions must emit a list of room alias suggestions`() = runTest {
val roomListService = FakeRoomListService() val roomList = FakeDynamicRoomList()
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val sut = DefaultRoomAliasSuggestionsDataSource( val sut = DefaultRoomAliasSuggestionsDataSource(
roomListService roomListService
) )
@ -31,7 +35,7 @@ class DefaultRoomAliasSuggestionsDataSourceTest {
) )
sut.getAllRoomAliasSuggestions().test { sut.getAllRoomAliasSuggestions().test {
assertThat(awaitItem()).isEmpty() assertThat(awaitItem()).isEmpty()
roomListService.postAllRooms( roomList.summaries.emit(
listOf( listOf(
aRoomSummary(roomId = A_ROOM_ID_2, canonicalAlias = null), aRoomSummary(roomId = A_ROOM_ID_2, canonicalAlias = null),
aRoomSummaryWithAnAlias, aRoomSummaryWithAnAlias,

View file

@ -17,10 +17,12 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.aRoomSummary import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -53,10 +55,14 @@ class EditDefaultNotificationSettingsPresenterTest {
initialRoomModeIsDefault = false, initialRoomModeIsDefault = false,
getRoomsWithUserDefinedRulesResult = { Result.success(listOf(A_ROOM_ID)) }, getRoomsWithUserDefinedRulesResult = { Result.success(listOf(A_ROOM_ID)) },
) )
val roomListService = FakeRoomListService() val roomList = FakeDynamicRoomList(
summaries = MutableStateFlow(listOf(aRoomSummary(userDefinedNotificationMode = RoomNotificationMode.ALL_MESSAGES)))
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService) val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService)
presenter.test { presenter.test {
roomListService.postAllRooms(listOf(aRoomSummary(userDefinedNotificationMode = RoomNotificationMode.ALL_MESSAGES)))
val loadedState = consumeItemsUntilPredicate { state -> val loadedState = consumeItemsUntilPredicate { state ->
state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.ALL_MESSAGES } state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.ALL_MESSAGES }
}.last() }.last()
@ -71,10 +77,8 @@ class EditDefaultNotificationSettingsPresenterTest {
initialRoomModeIsDefault = false, initialRoomModeIsDefault = false,
getRoomsWithUserDefinedRulesResult = { Result.success(listOf(A_ROOM_ID, A_ROOM_ID_2)) }, getRoomsWithUserDefinedRulesResult = { Result.success(listOf(A_ROOM_ID, A_ROOM_ID_2)) },
) )
val roomListService = FakeRoomListService() val roomList = FakeDynamicRoomList(
val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService) summaries = MutableStateFlow(
presenter.test {
roomListService.postAllRooms(
listOf( listOf(
aRoomSummary( aRoomSummary(
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@ -86,8 +90,14 @@ class EditDefaultNotificationSettingsPresenterTest {
name = "A", name = "A",
userDefinedNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, userDefinedNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
), ),
), )
) )
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService)
presenter.test {
val loadedState = consumeItemsUntilPredicate { state -> val loadedState = consumeItemsUntilPredicate { state ->
state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY } state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY }
}.last() }.last()
@ -103,10 +113,8 @@ class EditDefaultNotificationSettingsPresenterTest {
initialRoomModeIsDefault = false, initialRoomModeIsDefault = false,
getRoomsWithUserDefinedRulesResult = { Result.success(listOf(A_ROOM_ID, A_ROOM_ID_2)) }, getRoomsWithUserDefinedRulesResult = { Result.success(listOf(A_ROOM_ID, A_ROOM_ID_2)) },
) )
val roomListService = FakeRoomListService() val roomList = FakeDynamicRoomList(
val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService) summaries = MutableStateFlow(
presenter.test {
roomListService.postAllRooms(
listOf( listOf(
aRoomSummary( aRoomSummary(
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@ -118,8 +126,14 @@ class EditDefaultNotificationSettingsPresenterTest {
name = null, name = null,
userDefinedNotificationMode = RoomNotificationMode.MUTE, userDefinedNotificationMode = RoomNotificationMode.MUTE,
), ),
), )
) )
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService)
presenter.test {
val loadedState = consumeItemsUntilPredicate { state -> val loadedState = consumeItemsUntilPredicate { state ->
state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.MUTE } state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.MUTE }
}.last() }.last()

View file

@ -1,14 +1,16 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_change_permissions_administrators">"Endast administratörer"</string> <string name="screen_room_change_permissions_administrators">"Admin"</string>
<string name="screen_room_change_permissions_ban_people">"Banna personer"</string> <string name="screen_room_change_permissions_ban_people">"Banna personer"</string>
<string name="screen_room_change_permissions_delete_messages">"Ta bort meddelanden"</string> <string name="screen_room_change_permissions_delete_messages">"Ta bort meddelanden"</string>
<string name="screen_room_change_permissions_invite_people">"Bjuda in personer och acceptera förfrågningar om att gå med"</string> <string name="screen_room_change_permissions_everyone">"Medlem"</string>
<string name="screen_room_change_permissions_invite_people">"Bjud in personer"</string>
<string name="screen_room_change_permissions_member_moderation">"Hantera medlemmar"</string>
<string name="screen_room_change_permissions_messages_and_content">"Meddelanden och innehåll"</string> <string name="screen_room_change_permissions_messages_and_content">"Meddelanden och innehåll"</string>
<string name="screen_room_change_permissions_moderators">"Administratörer och moderatorer"</string> <string name="screen_room_change_permissions_moderators">"Moderator"</string>
<string name="screen_room_change_permissions_remove_people">"Ta bort personer och avslå förfrågningar om att gå med"</string> <string name="screen_room_change_permissions_remove_people">"Ta bort personer"</string>
<string name="screen_room_change_permissions_room_avatar">"Byt rumsavatar"</string> <string name="screen_room_change_permissions_room_avatar">"Byt rumsavatar"</string>
<string name="screen_room_change_permissions_room_details">"Redigera rummet"</string> <string name="screen_room_change_permissions_room_details">"Redigera detaljer"</string>
<string name="screen_room_change_permissions_room_name">"Byt rumsnamn"</string> <string name="screen_room_change_permissions_room_name">"Byt rumsnamn"</string>
<string name="screen_room_change_permissions_room_topic">"Byt rumsämne"</string> <string name="screen_room_change_permissions_room_topic">"Byt rumsämne"</string>
<string name="screen_room_change_permissions_send_messages">"Skicka meddelanden"</string> <string name="screen_room_change_permissions_send_messages">"Skicka meddelanden"</string>
@ -31,10 +33,10 @@
<string name="screen_room_change_role_section_users">"Medlemmar"</string> <string name="screen_room_change_role_section_users">"Medlemmar"</string>
<string name="screen_room_change_role_unsaved_changes_description">"Du har osparade ändringar."</string> <string name="screen_room_change_role_unsaved_changes_description">"Du har osparade ändringar."</string>
<string name="screen_room_change_role_unsaved_changes_title">"Spara ändringar?"</string> <string name="screen_room_change_role_unsaved_changes_title">"Spara ändringar?"</string>
<string name="screen_room_member_list_banned_empty">"Det finns inga bannade användare i det här rummet."</string> <string name="screen_room_member_list_banned_empty">"Det finns inga bannade användare."</string>
<plurals name="screen_room_member_list_header_title"> <plurals name="screen_room_member_list_header_title">
<item quantity="one">"%1$d person"</item> <item quantity="one">"%1$d Person"</item>
<item quantity="other">"%1$d personer"</item> <item quantity="other">"%1$d Personer"</item>
</plurals> </plurals>
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Ta bort och banna medlem"</string> <string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Ta bort och banna medlem"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_kick">"Ta bara bort medlem"</string> <string name="screen_room_member_list_manage_member_remove_confirmation_kick">"Ta bara bort medlem"</string>
@ -43,8 +45,8 @@
<string name="screen_room_member_list_manage_member_unban_title">"Avbanna från rummet"</string> <string name="screen_room_member_list_manage_member_unban_title">"Avbanna från rummet"</string>
<string name="screen_room_member_list_mode_banned">"Bannade"</string> <string name="screen_room_member_list_mode_banned">"Bannade"</string>
<string name="screen_room_member_list_mode_members">"Medlemmar"</string> <string name="screen_room_member_list_mode_members">"Medlemmar"</string>
<string name="screen_room_member_list_role_administrator">"Endast administratörer"</string> <string name="screen_room_member_list_role_administrator">"Admin"</string>
<string name="screen_room_member_list_role_moderator">"Administratörer och moderatorer"</string> <string name="screen_room_member_list_role_moderator">"Moderator"</string>
<string name="screen_room_member_list_role_owner">"Ägare"</string> <string name="screen_room_member_list_role_owner">"Ägare"</string>
<string name="screen_room_member_list_room_members_header_title">"Rumsmedlemmar"</string> <string name="screen_room_member_list_room_members_header_title">"Rumsmedlemmar"</string>
<string name="screen_room_member_list_unbanning_user">"Avbannar %1$s"</string> <string name="screen_room_member_list_unbanning_user">"Avbannar %1$s"</string>

View file

@ -142,7 +142,7 @@ Me ei soovita krüptimise kasutamist selliste avalike jututubade puhul, millega
<string name="screen_security_and_privacy_encryption_section_header">"Krüptimine"</string> <string name="screen_security_and_privacy_encryption_section_header">"Krüptimine"</string>
<string name="screen_security_and_privacy_encryption_toggle_title">"Võta läbiv krüptimine kasutusele"</string> <string name="screen_security_and_privacy_encryption_toggle_title">"Võta läbiv krüptimine kasutusele"</string>
<string name="screen_security_and_privacy_room_access_anyone_option_description">"Kõik võivad jututoaga liituda"</string> <string name="screen_security_and_privacy_room_access_anyone_option_description">"Kõik võivad jututoaga liituda"</string>
<string name="screen_security_and_privacy_room_access_anyone_option_title">"Avalik"</string> <string name="screen_security_and_privacy_room_access_anyone_option_title">"ik"</string>
<string name="screen_security_and_privacy_room_access_footer">"Vali kogukonnad, mille liikmed saavad selle jututoaga liituda ilma kutseta. %1$s"</string> <string name="screen_security_and_privacy_room_access_footer">"Vali kogukonnad, mille liikmed saavad selle jututoaga liituda ilma kutseta. %1$s"</string>
<string name="screen_security_and_privacy_room_access_footer_manage_spaces_action">"Halda kogukondi"</string> <string name="screen_security_and_privacy_room_access_footer_manage_spaces_action">"Halda kogukondi"</string>
<string name="screen_security_and_privacy_room_access_invite_only_option_description">"Liituda saab vaid kutse olemasolul"</string> <string name="screen_security_and_privacy_room_access_invite_only_option_description">"Liituda saab vaid kutse olemasolul"</string>

View file

@ -5,15 +5,17 @@
<string name="screen_notification_settings_edit_failed_updating_default_mode">"Ett fel uppstod vid uppdatering av aviseringsinställningen."</string> <string name="screen_notification_settings_edit_failed_updating_default_mode">"Ett fel uppstod vid uppdatering av aviseringsinställningen."</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Din hemserver stöder inte det här alternativet i krypterade rum, du kanske inte aviseras i vissa rum."</string> <string name="screen_notification_settings_mentions_only_disclaimer">"Din hemserver stöder inte det här alternativet i krypterade rum, du kanske inte aviseras i vissa rum."</string>
<string name="screen_polls_history_title">"Omröstningar"</string> <string name="screen_polls_history_title">"Omröstningar"</string>
<string name="screen_room_change_permissions_administrators">"Endast administratörer"</string> <string name="screen_room_change_permissions_administrators">"Admin"</string>
<string name="screen_room_change_permissions_ban_people">"Banna personer"</string> <string name="screen_room_change_permissions_ban_people">"Banna personer"</string>
<string name="screen_room_change_permissions_delete_messages">"Ta bort meddelanden"</string> <string name="screen_room_change_permissions_delete_messages">"Ta bort meddelanden"</string>
<string name="screen_room_change_permissions_invite_people">"Bjuda in personer och acceptera förfrågningar om att gå med"</string> <string name="screen_room_change_permissions_everyone">"Medlem"</string>
<string name="screen_room_change_permissions_invite_people">"Bjud in personer"</string>
<string name="screen_room_change_permissions_member_moderation">"Hantera medlemmar"</string>
<string name="screen_room_change_permissions_messages_and_content">"Meddelanden och innehåll"</string> <string name="screen_room_change_permissions_messages_and_content">"Meddelanden och innehåll"</string>
<string name="screen_room_change_permissions_moderators">"Administratörer och moderatorer"</string> <string name="screen_room_change_permissions_moderators">"Moderator"</string>
<string name="screen_room_change_permissions_remove_people">"Ta bort personer och avslå förfrågningar om att gå med"</string> <string name="screen_room_change_permissions_remove_people">"Ta bort personer"</string>
<string name="screen_room_change_permissions_room_avatar">"Byt rumsavatar"</string> <string name="screen_room_change_permissions_room_avatar">"Byt rumsavatar"</string>
<string name="screen_room_change_permissions_room_details">"Redigera rummet"</string> <string name="screen_room_change_permissions_room_details">"Redigera detaljer"</string>
<string name="screen_room_change_permissions_room_name">"Byt rumsnamn"</string> <string name="screen_room_change_permissions_room_name">"Byt rumsnamn"</string>
<string name="screen_room_change_permissions_room_topic">"Byt rumsämne"</string> <string name="screen_room_change_permissions_room_topic">"Byt rumsämne"</string>
<string name="screen_room_change_permissions_send_messages">"Skicka meddelanden"</string> <string name="screen_room_change_permissions_send_messages">"Skicka meddelanden"</string>
@ -40,7 +42,7 @@
<string name="screen_room_details_badge_encrypted">"Krypterat"</string> <string name="screen_room_details_badge_encrypted">"Krypterat"</string>
<string name="screen_room_details_badge_not_encrypted">"Inte krypterat"</string> <string name="screen_room_details_badge_not_encrypted">"Inte krypterat"</string>
<string name="screen_room_details_badge_public">"Offentligt rum"</string> <string name="screen_room_details_badge_public">"Offentligt rum"</string>
<string name="screen_room_details_edit_room_title">"Redigera rummet"</string> <string name="screen_room_details_edit_room_title">"Redigera detaljer"</string>
<string name="screen_room_details_edition_error">"Ett okänt fel uppstod och informationen kunde inte ändras."</string> <string name="screen_room_details_edition_error">"Ett okänt fel uppstod och informationen kunde inte ändras."</string>
<string name="screen_room_details_edition_error_title">"Kunde inte uppdatera rummet"</string> <string name="screen_room_details_edition_error_title">"Kunde inte uppdatera rummet"</string>
<string name="screen_room_details_encryption_enabled_subtitle">"Meddelanden är säkrade med lås. Bara du och mottagarna har de unika nycklarna för att låsa upp dem."</string> <string name="screen_room_details_encryption_enabled_subtitle">"Meddelanden är säkrade med lås. Bara du och mottagarna har de unika nycklarna för att låsa upp dem."</string>
@ -65,10 +67,10 @@
<string name="screen_room_details_title">"Rumsinfo"</string> <string name="screen_room_details_title">"Rumsinfo"</string>
<string name="screen_room_details_topic_title">"Ämne"</string> <string name="screen_room_details_topic_title">"Ämne"</string>
<string name="screen_room_details_updating_room">"Uppdaterar rummet …"</string> <string name="screen_room_details_updating_room">"Uppdaterar rummet …"</string>
<string name="screen_room_member_list_banned_empty">"Det finns inga bannade användare i det här rummet."</string> <string name="screen_room_member_list_banned_empty">"Det finns inga bannade användare."</string>
<plurals name="screen_room_member_list_header_title"> <plurals name="screen_room_member_list_header_title">
<item quantity="one">"%1$d person"</item> <item quantity="one">"%1$d Person"</item>
<item quantity="other">"%1$d personer"</item> <item quantity="other">"%1$d Personer"</item>
</plurals> </plurals>
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Ta bort och banna medlem"</string> <string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Ta bort och banna medlem"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_kick">"Ta bara bort medlem"</string> <string name="screen_room_member_list_manage_member_remove_confirmation_kick">"Ta bara bort medlem"</string>
@ -77,8 +79,8 @@
<string name="screen_room_member_list_manage_member_unban_title">"Avbanna från rummet"</string> <string name="screen_room_member_list_manage_member_unban_title">"Avbanna från rummet"</string>
<string name="screen_room_member_list_mode_banned">"Bannade"</string> <string name="screen_room_member_list_mode_banned">"Bannade"</string>
<string name="screen_room_member_list_mode_members">"Medlemmar"</string> <string name="screen_room_member_list_mode_members">"Medlemmar"</string>
<string name="screen_room_member_list_role_administrator">"Endast administratörer"</string> <string name="screen_room_member_list_role_administrator">"Admin"</string>
<string name="screen_room_member_list_role_moderator">"Administratörer och moderatorer"</string> <string name="screen_room_member_list_role_moderator">"Moderator"</string>
<string name="screen_room_member_list_role_owner">"Ägare"</string> <string name="screen_room_member_list_role_owner">"Ägare"</string>
<string name="screen_room_member_list_room_members_header_title">"Rumsmedlemmar"</string> <string name="screen_room_member_list_room_members_header_title">"Rumsmedlemmar"</string>
<string name="screen_room_member_list_unbanning_user">"Avbannar %1$s"</string> <string name="screen_room_member_list_unbanning_user">"Avbannar %1$s"</string>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Redigera rummet"</string> <string name="screen_room_details_edit_room_title">"Redigera detaljer"</string>
<string name="screen_room_details_edition_error">"Ett okänt fel uppstod och informationen kunde inte ändras."</string> <string name="screen_room_details_edition_error">"Ett okänt fel uppstod och informationen kunde inte ändras."</string>
<string name="screen_room_details_edition_error_title">"Kunde inte uppdatera rummet"</string> <string name="screen_room_details_edition_error_title">"Kunde inte uppdatera rummet"</string>
<string name="screen_room_details_updating_room">"Uppdaterar rummet …"</string> <string name="screen_room_details_updating_room">"Uppdaterar rummet …"</string>

View file

@ -9,7 +9,7 @@
<string name="screen_bottom_sheet_manage_room_member_kick_member_confirmation_description">"Denne kommer kunna gå med i rummet igen om denne bjuds in"</string> <string name="screen_bottom_sheet_manage_room_member_kick_member_confirmation_description">"Denne kommer kunna gå med i rummet igen om denne bjuds in"</string>
<string name="screen_bottom_sheet_manage_room_member_kick_member_confirmation_title">"Är du säker på att du vill ta bort den här medlemmen?"</string> <string name="screen_bottom_sheet_manage_room_member_kick_member_confirmation_title">"Är du säker på att du vill ta bort den här medlemmen?"</string>
<string name="screen_bottom_sheet_manage_room_member_member_user_info">"Visa profil"</string> <string name="screen_bottom_sheet_manage_room_member_member_user_info">"Visa profil"</string>
<string name="screen_bottom_sheet_manage_room_member_remove">"Ta bort från rummet"</string> <string name="screen_bottom_sheet_manage_room_member_remove">"Ta bort användare"</string>
<string name="screen_bottom_sheet_manage_room_member_remove_confirmation_title">"Ta bort medlem och banna från att gå med i framtiden?"</string> <string name="screen_bottom_sheet_manage_room_member_remove_confirmation_title">"Ta bort medlem och banna från att gå med i framtiden?"</string>
<string name="screen_bottom_sheet_manage_room_member_removing_user">"Tar bort %1$s …"</string> <string name="screen_bottom_sheet_manage_room_member_removing_user">"Tar bort %1$s …"</string>
<string name="screen_bottom_sheet_manage_room_member_unban">"Avbanna från rummet"</string> <string name="screen_bottom_sheet_manage_room_member_unban">"Avbanna från rummet"</string>

View file

@ -21,7 +21,7 @@ Me ei soovita krüptimise kasutamist selliste avalike jututubade puhul, millega
<string name="screen_security_and_privacy_encryption_section_header">"Krüptimine"</string> <string name="screen_security_and_privacy_encryption_section_header">"Krüptimine"</string>
<string name="screen_security_and_privacy_encryption_toggle_title">"Võta läbiv krüptimine kasutusele"</string> <string name="screen_security_and_privacy_encryption_toggle_title">"Võta läbiv krüptimine kasutusele"</string>
<string name="screen_security_and_privacy_room_access_anyone_option_description">"Kõik võivad jututoaga liituda"</string> <string name="screen_security_and_privacy_room_access_anyone_option_description">"Kõik võivad jututoaga liituda"</string>
<string name="screen_security_and_privacy_room_access_anyone_option_title">"Avalik"</string> <string name="screen_security_and_privacy_room_access_anyone_option_title">"ik"</string>
<string name="screen_security_and_privacy_room_access_footer">"Vali kogukonnad, mille liikmed saavad selle jututoaga liituda ilma kutseta. %1$s"</string> <string name="screen_security_and_privacy_room_access_footer">"Vali kogukonnad, mille liikmed saavad selle jututoaga liituda ilma kutseta. %1$s"</string>
<string name="screen_security_and_privacy_room_access_footer_manage_spaces_action">"Halda kogukondi"</string> <string name="screen_security_and_privacy_room_access_footer_manage_spaces_action">"Halda kogukondi"</string>
<string name="screen_security_and_privacy_room_access_invite_only_option_description">"Liituda saab vaid kutse olemasolul"</string> <string name="screen_security_and_privacy_room_access_invite_only_option_description">"Liituda saab vaid kutse olemasolul"</string>

View file

@ -52,4 +52,5 @@ dependencies {
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.features.createroom.test) testImplementation(projects.features.createroom.test)
testImplementation(projects.features.invite.test) testImplementation(projects.features.invite.test)
testImplementation(projects.features.rolesandpermissions.test)
} }

View file

@ -22,10 +22,13 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType
import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.features.space.impl.addroom.AddRoomToSpaceNode import io.element.android.features.space.impl.addroom.AddRoomToSpaceNode
import io.element.android.features.space.impl.di.SpaceFlowGraph import io.element.android.features.space.impl.di.SpaceFlowGraph
@ -38,10 +41,15 @@ import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.di.DependencyInjectionGraphOwner
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.api.spaces.loadAllIncrementally import io.element.android.libraries.matrix.api.spaces.loadAllIncrementally
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@ -49,10 +57,12 @@ import kotlinx.parcelize.Parcelize
class SpaceFlowNode( class SpaceFlowNode(
@Assisted val buildContext: BuildContext, @Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
room: JoinedRoom, private val room: JoinedRoom,
spaceService: SpaceService, spaceService: SpaceService,
graphFactory: SpaceFlowGraph.Factory, graphFactory: SpaceFlowGraph.Factory,
private val createRoomEntryPoint: CreateRoomEntryPoint, private val createRoomEntryPoint: CreateRoomEntryPoint,
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : BaseFlowNode<SpaceFlowNode.NavTarget>( ) : BaseFlowNode<SpaceFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Root, initialElement = NavTarget.Root,
@ -80,6 +90,9 @@ class SpaceFlowNode(
@Parcelize @Parcelize
data object AddRoom : NavTarget data object AddRoom : NavTarget
@Parcelize
data object ChangeOwners : NavTarget
} }
override fun onBuilt() { override fun onBuilt() {
@ -105,6 +118,10 @@ class SpaceFlowNode(
override fun navigateToRolesAndPermissions() { override fun navigateToRolesAndPermissions() {
backstack.push(NavTarget.Settings(SpaceSettingsFlowNode.NavTarget.RolesAndPermissions)) backstack.push(NavTarget.Settings(SpaceSettingsFlowNode.NavTarget.RolesAndPermissions))
} }
override fun navigateToChooseOwners() {
backstack.replace(NavTarget.ChangeOwners)
}
} }
createNode<LeaveSpaceNode>(buildContext, listOf(callback)) createNode<LeaveSpaceNode>(buildContext, listOf(callback))
} }
@ -177,6 +194,29 @@ class SpaceFlowNode(
} }
createNode<AddRoomToSpaceNode>(buildContext, listOf(callback)) createNode<AddRoomToSpaceNode>(buildContext, listOf(callback))
} }
NavTarget.ChangeOwners -> {
val node = changeRoomMemberRolesEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
room = room,
listType = ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving,
)
val completionProxy = node as ChangeRoomMemberRolesEntryPoint.NodeProxy
sessionCoroutineScope.launch {
val changedOwners = withContext(NonCancellable) {
completionProxy.waitForCompletion()
}
if (changedOwners) {
backstack.replace(NavTarget.Leave)
} else {
backstack.pop()
}
}
node
}
} }
} }

View file

@ -15,4 +15,5 @@ sealed interface AddRoomToSpaceEvent {
data object Save : AddRoomToSpaceEvent data object Save : AddRoomToSpaceEvent
data object ResetSaveAction : AddRoomToSpaceEvent data object ResetSaveAction : AddRoomToSpaceEvent
data object Dismiss : AddRoomToSpaceEvent data object Dismiss : AddRoomToSpaceEvent
data class UpdateSearchVisibleRange(val range: IntRange) : AddRoomToSpaceEvent
} }

View file

@ -58,9 +58,6 @@ class AddRoomToSpacePresenter(
LaunchedEffect(searchQuery.text) { LaunchedEffect(searchQuery.text) {
dataSource.setSearchQuery(searchQuery.text.toString()) dataSource.setSearchQuery(searchQuery.text.toString())
} }
LaunchedEffect(isSearchActive) {
dataSource.setIsActive(isSearchActive)
}
val suggestions by dataSource.suggestions.collectAsState(initial = persistentListOf()) val suggestions by dataSource.suggestions.collectAsState(initial = persistentListOf())
@ -111,6 +108,9 @@ class AddRoomToSpacePresenter(
coroutineScope.launch { spaceRoomList.reset() } coroutineScope.launch { spaceRoomList.reset() }
} }
} }
is AddRoomToSpaceEvent.UpdateSearchVisibleRange -> coroutineScope.launch {
dataSource.updateVisibleRange(event.range)
}
} }
} }

View file

@ -20,14 +20,13 @@ import io.element.android.libraries.matrix.api.room.recent.getRecentlyVisitedRoo
import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally import io.element.android.libraries.matrix.api.roomlist.updateVisibleRange
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -58,7 +57,6 @@ class AddRoomToSpaceSearchDataSource(
private val roomList = roomListService.createRoomList( private val roomList = roomListService.createRoomList(
pageSize = PAGE_SIZE, pageSize = PAGE_SIZE,
initialFilter = RoomListFilter.all(),
source = RoomList.Source.All, source = RoomList.Source.All,
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
) )
@ -87,7 +85,7 @@ class AddRoomToSpaceSearchDataSource(
} }
val roomInfoList: Flow<ImmutableList<SelectRoomInfo>> = combine( val roomInfoList: Flow<ImmutableList<SelectRoomInfo>> = combine(
roomList.filteredSummaries, roomList.summaries,
spaceChildrenFlow, spaceChildrenFlow,
addedRoomIdsFlow, addedRoomIdsFlow,
) { roomSummaries, childIds, addedIds -> ) { roomSummaries, childIds, addedIds ->
@ -109,12 +107,8 @@ class AddRoomToSpaceSearchDataSource(
.toImmutableList() .toImmutableList()
}.flowOn(coroutineDispatchers.computation) }.flowOn(coroutineDispatchers.computation)
suspend fun setIsActive(isActive: Boolean) = coroutineScope { suspend fun updateVisibleRange(visibleRange: IntRange) {
if (isActive) { roomList.updateVisibleRange(visibleRange)
roomList.loadAllIncrementally(this)
} else {
roomList.reset()
}
} }
suspend fun setSearchQuery(searchQuery: String) { suspend fun setSearchQuery(searchQuery: String) {

View file

@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -43,6 +44,7 @@ import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect
import io.element.android.libraries.matrix.ui.components.SelectedRoom import io.element.android.libraries.matrix.ui.components.SelectedRoom
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
@ -121,6 +123,10 @@ fun AddRoomToSpaceView(
} }
}, },
) { rooms -> ) { rooms ->
val lazyListState = rememberLazyListState()
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
state.eventSink(AddRoomToSpaceEvent.UpdateSearchVisibleRange(visibleRange))
}
LazyColumn { LazyColumn {
items(rooms, key = { it.roomId }) { roomInfo -> items(rooms, key = { it.roomId }) { roomInfo ->
RoomListItem( RoomListItem(

View file

@ -34,6 +34,7 @@ class LeaveSpaceNode(
interface Callback : Plugin { interface Callback : Plugin {
fun closeLeaveSpaceFlow() fun closeLeaveSpaceFlow()
fun navigateToRolesAndPermissions() fun navigateToRolesAndPermissions()
fun navigateToChooseOwners()
} }
private val leaveSpaceHandle = matrixClient.spaceService.getLeaveSpaceHandle(room.roomId) private val leaveSpaceHandle = matrixClient.spaceService.getLeaveSpaceHandle(room.roomId)
@ -57,6 +58,7 @@ class LeaveSpaceNode(
state = state, state = state,
onCancel = callback::closeLeaveSpaceFlow, onCancel = callback::closeLeaveSpaceFlow,
onRolesAndPermissionsClick = callback::navigateToRolesAndPermissions, onRolesAndPermissionsClick = callback::navigateToRolesAndPermissions,
onChooseOwnersClick = callback::navigateToChooseOwners,
modifier = modifier modifier = modifier
) )
} }

View file

@ -92,6 +92,7 @@ class LeaveSpacePresenter(
SelectableSpaceRoom( SelectableSpaceRoom(
spaceRoom = room.spaceRoom, spaceRoom = room.spaceRoom,
isLastOwner = room.isLastOwner, isLastOwner = room.isLastOwner,
joinedMembersCount = room.spaceRoom.numJoinedMembers,
isSelected = selectedRoomIds.contains(room.spaceRoom.roomId), isSelected = selectedRoomIds.contains(room.spaceRoom.roomId),
) )
}.toImmutableList() }.toImmutableList()
@ -130,9 +131,11 @@ class LeaveSpacePresenter(
} }
} }
val currentSpaceToLeave = leaveSpaceRooms.dataOrNull()?.current
return LeaveSpaceState( return LeaveSpaceState(
spaceName = leaveSpaceRooms.dataOrNull()?.current?.spaceRoom?.displayName, spaceName = currentSpaceToLeave?.spaceRoom?.displayName,
isLastOwner = leaveSpaceRooms.dataOrNull()?.current?.isLastOwner == true, needsOwnerChange = currentSpaceToLeave?.let { it.spaceRoom.numJoinedMembers > 1 && it.isLastOwner } == true,
areCreatorsPrivileged = currentSpaceToLeave?.areCreatorsPrivileged == true,
selectableSpaceRooms = selectableSpaceRooms, selectableSpaceRooms = selectableSpaceRooms,
leaveSpaceAction = leaveSpaceAction.value, leaveSpaceAction = leaveSpaceAction.value,
eventSink = ::handleEvent, eventSink = ::handleEvent,

View file

@ -15,7 +15,8 @@ import kotlinx.collections.immutable.toImmutableList
data class LeaveSpaceState( data class LeaveSpaceState(
val spaceName: String?, val spaceName: String?,
val isLastOwner: Boolean, val needsOwnerChange: Boolean,
val areCreatorsPrivileged: Boolean,
val selectableSpaceRooms: AsyncData<ImmutableList<SelectableSpaceRoom>>, val selectableSpaceRooms: AsyncData<ImmutableList<SelectableSpaceRoom>>,
val leaveSpaceAction: AsyncAction<Unit>, val leaveSpaceAction: AsyncAction<Unit>,
val eventSink: (LeaveSpaceEvents) -> Unit, val eventSink: (LeaveSpaceEvents) -> Unit,
@ -25,7 +26,7 @@ data class LeaveSpaceState(
private val selectableRooms: ImmutableList<SelectableSpaceRoom> private val selectableRooms: ImmutableList<SelectableSpaceRoom>
init { init {
val partition = rooms.partition { it.isLastOwner } val partition = rooms.partition { it.isLastOwner && it.joinedMembersCount > 1 }
lastAdminRooms = partition.first.toImmutableList() lastAdminRooms = partition.first.toImmutableList()
selectableRooms = partition.second.toImmutableList() selectableRooms = partition.second.toImmutableList()
} }
@ -33,12 +34,12 @@ data class LeaveSpaceState(
/** /**
* True if we should show the quick action to select/deselect all rooms. * True if we should show the quick action to select/deselect all rooms.
*/ */
val showQuickAction = isLastOwner.not() && selectableRooms.isNotEmpty() val showQuickAction = needsOwnerChange.not() && selectableRooms.isNotEmpty()
/** /**
* True if we should show the leave button. * True if we should show the leave button.
*/ */
val showLeaveButton = isLastOwner.not() && selectableSpaceRooms is AsyncData.Success val showLeaveButton = needsOwnerChange.not() && selectableSpaceRooms is AsyncData.Success
/** /**
* True if there all the selectable rooms are selected. * True if there all the selectable rooms are selected.

View file

@ -109,17 +109,23 @@ class LeaveSpaceStateProvider : PreviewParameterProvider<LeaveSpaceState> {
aLeaveSpaceState( aLeaveSpaceState(
isLastOwner = true, isLastOwner = true,
), ),
aLeaveSpaceState(
isLastOwner = true,
areCreatorsPrivileged = true,
),
) )
} }
fun aLeaveSpaceState( fun aLeaveSpaceState(
spaceName: String? = "Space name", spaceName: String? = "Space name",
isLastOwner: Boolean = false, isLastOwner: Boolean = false,
areCreatorsPrivileged: Boolean = false,
selectableSpaceRooms: AsyncData<ImmutableList<SelectableSpaceRoom>> = AsyncData.Uninitialized, selectableSpaceRooms: AsyncData<ImmutableList<SelectableSpaceRoom>> = AsyncData.Uninitialized,
leaveSpaceAction: AsyncAction<Unit> = AsyncAction.Uninitialized, leaveSpaceAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
) = LeaveSpaceState( ) = LeaveSpaceState(
spaceName = spaceName, spaceName = spaceName,
isLastOwner = isLastOwner, needsOwnerChange = isLastOwner,
areCreatorsPrivileged = areCreatorsPrivileged,
selectableSpaceRooms = selectableSpaceRooms, selectableSpaceRooms = selectableSpaceRooms,
leaveSpaceAction = leaveSpaceAction, leaveSpaceAction = leaveSpaceAction,
eventSink = { } eventSink = { }
@ -128,9 +134,11 @@ fun aLeaveSpaceState(
fun aSelectableSpaceRoom( fun aSelectableSpaceRoom(
spaceRoom: SpaceRoom = aSpaceRoom(), spaceRoom: SpaceRoom = aSpaceRoom(),
isLastOwner: Boolean = false, isLastOwner: Boolean = false,
joinedMembersCount: Int = 2,
isSelected: Boolean = false, isSelected: Boolean = false,
) = SelectableSpaceRoom( ) = SelectableSpaceRoom(
spaceRoom = spaceRoom, spaceRoom = spaceRoom,
isLastOwner = isLastOwner, isLastOwner = isLastOwner,
joinedMembersCount = joinedMembersCount,
isSelected = isSelected, isSelected = isSelected,
) )

View file

@ -12,14 +12,13 @@ package io.element.android.features.space.impl.leave
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@ -40,6 +39,7 @@ import io.element.android.features.space.impl.R
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncFailure import io.element.android.libraries.designsystem.components.async.AsyncFailure
@ -54,7 +54,6 @@ import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Checkbox import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
@ -71,30 +70,42 @@ fun LeaveSpaceView(
state: LeaveSpaceState, state: LeaveSpaceState,
onCancel: () -> Unit, onCancel: () -> Unit,
onRolesAndPermissionsClick: () -> Unit, onRolesAndPermissionsClick: () -> Unit,
onChooseOwnersClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Scaffold( HeaderFooterPage(
modifier = modifier, modifier = modifier,
contentPadding = PaddingValues(bottom = 14.dp),
topBar = { topBar = {
LeaveSpaceHeader( TopAppBar(
state = state, navigationIcon = {
onBackClick = onCancel, BackButton(onClick = onCancel)
},
title = {},
) )
}, },
containerColor = ElementTheme.colors.bgCanvasDefault, header = {
) { padding -> LeaveSpaceHeader(state = state)
Column( },
modifier = Modifier footer = {
.padding(padding) LeaveSpaceButtons(
.imePadding() showLeaveButton = state.showLeaveButton,
.consumeWindowInsets(padding) selectedRoomsCount = state.selectedRoomsCount,
.fillMaxSize() onLeaveSpace = {
) { state.eventSink(LeaveSpaceEvents.LeaveSpace)
LazyColumn( },
modifier = Modifier onCancel = onCancel,
.weight(1f), showRolesAndPermissionsButton = state.needsOwnerChange && !state.areCreatorsPrivileged,
) { showChooseOwnersButton = state.needsOwnerChange && state.areCreatorsPrivileged,
if (state.isLastOwner.not()) { onChooseOwnersButtonClick = onChooseOwnersClick,
onRolesAndPermissionsClick = onRolesAndPermissionsClick,
)
},
content = {
if (state.needsOwnerChange.not()) {
LazyColumn(
modifier = Modifier.padding(top = 20.dp),
) {
when (state.selectableSpaceRooms) { when (state.selectableSpaceRooms) {
is AsyncData.Success -> { is AsyncData.Success -> {
// List rooms where the user is the only admin // List rooms where the user is the only admin
@ -125,18 +136,8 @@ fun LeaveSpaceView(
} }
} }
} }
LeaveSpaceButtons(
showLeaveButton = state.showLeaveButton,
selectedRoomsCount = state.selectedRoomsCount,
onLeaveSpace = {
state.eventSink(LeaveSpaceEvents.LeaveSpace)
},
onCancel = onCancel,
showRolesAndPermissionsButton = state.isLastOwner,
onRolesAndPermissionsClick = onRolesAndPermissionsClick,
)
} }
} )
AsyncActionView( AsyncActionView(
async = state.leaveSpaceAction, async = state.leaveSpaceAction,
@ -149,25 +150,27 @@ fun LeaveSpaceView(
@Composable @Composable
private fun LeaveSpaceHeader( private fun LeaveSpaceHeader(
state: LeaveSpaceState, state: LeaveSpaceState,
onBackClick: () -> Unit,
) { ) {
Column { Column {
TopAppBar(
navigationIcon = {
BackButton(onClick = onBackClick)
},
title = {},
)
IconTitleSubtitleMolecule( IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 0.dp, bottom = 8.dp, start = 24.dp, end = 24.dp), modifier = Modifier.padding(top = 24.dp, bottom = 8.dp, start = 24.dp, end = 24.dp),
iconStyle = BigIcon.Style.AlertSolid, iconStyle = BigIcon.Style.AlertSolid,
title = stringResource( title = if (state.needsOwnerChange) {
if (state.isLastOwner) R.string.screen_leave_space_title_last_admin else R.string.screen_leave_space_title, if (state.areCreatorsPrivileged) {
state.spaceName ?: stringResource(CommonStrings.common_space) stringResource(R.string.screen_leave_space_title_last_owner)
), } else {
stringResource(R.string.screen_leave_space_title_last_admin, state.spaceName ?: stringResource(CommonStrings.common_space))
}
} else {
stringResource(R.string.screen_leave_space_title, state.spaceName ?: stringResource(CommonStrings.common_space))
},
subTitle = subTitle =
if (state.isLastOwner) { if (state.needsOwnerChange) {
stringResource(R.string.screen_leave_space_subtitle_last_admin) if (state.areCreatorsPrivileged) {
stringResource(R.string.screen_leave_space_subtitle_last_owner, state.spaceName ?: stringResource(CommonStrings.common_space))
} else {
stringResource(R.string.screen_leave_space_subtitle_last_admin)
}
} else if (state.selectableSpaceRooms is AsyncData.Success && state.selectableSpaceRooms.data.isNotEmpty()) { } else if (state.selectableSpaceRooms is AsyncData.Success && state.selectableSpaceRooms.data.isNotEmpty()) {
if (state.hasOnlyLastAdminRoom) { if (state.hasOnlyLastAdminRoom) {
stringResource(R.string.screen_leave_space_subtitle_only_last_admin) stringResource(R.string.screen_leave_space_subtitle_only_last_admin)
@ -216,10 +219,12 @@ private fun LeaveSpaceButtons(
onLeaveSpace: () -> Unit, onLeaveSpace: () -> Unit,
showRolesAndPermissionsButton: Boolean, showRolesAndPermissionsButton: Boolean,
onRolesAndPermissionsClick: () -> Unit, onRolesAndPermissionsClick: () -> Unit,
showChooseOwnersButton: Boolean,
onChooseOwnersButtonClick: () -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
) { ) {
ButtonColumnMolecule( ButtonColumnMolecule(
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(top = 16.dp)
) { ) {
if (showLeaveButton) { if (showLeaveButton) {
val text = if (selectedRoomsCount > 0) { val text = if (selectedRoomsCount > 0) {
@ -243,6 +248,14 @@ private fun LeaveSpaceButtons(
leadingIcon = IconSource.Vector(CompoundIcons.Settings()), leadingIcon = IconSource.Vector(CompoundIcons.Settings()),
) )
} }
if (showChooseOwnersButton) {
Button(
text = stringResource(R.string.screen_leave_space_choose_owners_action),
onClick = onChooseOwnersButtonClick,
modifier = Modifier.fillMaxWidth(),
destructive = true,
)
}
TextButton( TextButton(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_cancel), text = stringResource(CommonStrings.action_cancel),
@ -262,6 +275,7 @@ private fun SpaceItem(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 66.dp) .heightIn(min = 66.dp)
.padding(horizontal = 16.dp)
.toggleable( .toggleable(
value = selectableSpaceRoom.isSelected, value = selectableSpaceRoom.isSelected,
role = Role.Checkbox, role = Role.Checkbox,
@ -276,9 +290,9 @@ private fun SpaceItem(
onClick = onClick, onClick = onClick,
), ),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Avatar( Avatar(
modifier = Modifier.padding(horizontal = 16.dp),
avatarData = room.getAvatarData(AvatarSize.LeaveSpaceRoom), avatarData = room.getAvatarData(AvatarSize.LeaveSpaceRoom),
avatarType = if (room.isSpace) AvatarType.Space() else AvatarType.Room(), avatarType = if (room.isSpace) AvatarType.Space() else AvatarType.Room(),
) )
@ -358,5 +372,6 @@ internal fun LeaveSpaceViewPreview(
state = state, state = state,
onCancel = {}, onCancel = {},
onRolesAndPermissionsClick = {}, onRolesAndPermissionsClick = {},
onChooseOwnersClick = {},
) )
} }

View file

@ -13,5 +13,6 @@ import io.element.android.libraries.matrix.api.spaces.SpaceRoom
data class SelectableSpaceRoom( data class SelectableSpaceRoom(
val spaceRoom: SpaceRoom, val spaceRoom: SpaceRoom,
val isLastOwner: Boolean, val isLastOwner: Boolean,
val joinedMembersCount: Int,
val isSelected: Boolean, val isSelected: Boolean,
) )

View file

@ -10,7 +10,9 @@
<string name="screen_leave_space_subtitle_only_last_admin">"Du wirst aus den folgenden Chats nicht entfernt, weil du der einzige Admin bist:"</string> <string name="screen_leave_space_subtitle_only_last_admin">"Du wirst aus den folgenden Chats nicht entfernt, weil du der einzige Admin bist:"</string>
<string name="screen_leave_space_title">"%1$s verlassen?"</string> <string name="screen_leave_space_title">"%1$s verlassen?"</string>
<string name="screen_leave_space_title_last_admin">"Du bist der einzige Administrator für %1$s"</string> <string name="screen_leave_space_title_last_admin">"Du bist der einzige Administrator für %1$s"</string>
<string name="screen_space_add_room_action">"Chat"</string>
<string name="screen_space_add_rooms_room_access_description">"Das Hinzufügen eines Chats hat keinen Einfluss auf die Beitrittsregeln. Um die Regeln zu ändern, gehe zu \"Raum Info\" und dann zu \"Datenschutz und Sicherheit\""</string> <string name="screen_space_add_rooms_room_access_description">"Das Hinzufügen eines Chats hat keinen Einfluss auf die Beitrittsregeln. Um die Regeln zu ändern, gehe zu \"Raum Info\" und dann zu \"Datenschutz und Sicherheit\""</string>
<string name="screen_space_empty_state_title">"Füge deinen ersten Chat hinzu"</string>
<string name="screen_space_menu_action_members">"Mitglieder anzeigen"</string> <string name="screen_space_menu_action_members">"Mitglieder anzeigen"</string>
<string name="screen_space_remove_rooms_confirmation_content">"Das Entfernen eines Chats hat keinen Einfluss auf die Beitrittsregeln. Um die Regeln zu ändern, gehe zu \"Raum Info\" und dann zu \"Datenschutz und Sicherheit\""</string> <string name="screen_space_remove_rooms_confirmation_content">"Das Entfernen eines Chats hat keinen Einfluss auf die Beitrittsregeln. Um die Regeln zu ändern, gehe zu \"Raum Info\" und dann zu \"Datenschutz und Sicherheit\""</string>
<plurals name="screen_space_remove_rooms_confirmation_title"> <plurals name="screen_space_remove_rooms_confirmation_title">

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_choose_owners_action">"Vali omanikud"</string>
<string name="screen_leave_space_last_admin_info">"%1$s (Peakasutaja)"</string> <string name="screen_leave_space_last_admin_info">"%1$s (Peakasutaja)"</string>
<plurals name="screen_leave_space_submit"> <plurals name="screen_leave_space_submit">
<item quantity="one">"Lahku %1$d-st jututoast ja kogukonnast"</item> <item quantity="one">"Lahku %1$d-st jututoast ja kogukonnast"</item>
@ -10,7 +11,14 @@
<string name="screen_leave_space_subtitle_only_last_admin">"Sind ei saa järgnevatest jututubadest eemaldada, kuna oled seal/neis ainus peakasutaja:"</string> <string name="screen_leave_space_subtitle_only_last_admin">"Sind ei saa järgnevatest jututubadest eemaldada, kuna oled seal/neis ainus peakasutaja:"</string>
<string name="screen_leave_space_title">"Kas lahkud %1$s kogukonnast?"</string> <string name="screen_leave_space_title">"Kas lahkud %1$s kogukonnast?"</string>
<string name="screen_leave_space_title_last_admin">"Sa oled siin ainus peakasutaja: %1$s"</string> <string name="screen_leave_space_title_last_admin">"Sa oled siin ainus peakasutaja: %1$s"</string>
<string name="screen_leave_space_title_last_owner">"Anna omand üle"</string>
<string name="screen_space_add_room_action">"Jututuba"</string>
<string name="screen_space_empty_state_title">"Lisa oma esimene jututuba"</string>
<string name="screen_space_menu_action_members">"Vaata liikmeid"</string> <string name="screen_space_menu_action_members">"Vaata liikmeid"</string>
<plurals name="screen_space_remove_rooms_confirmation_title">
<item quantity="one">"Eemalda %1$d jututuba „%2$s“ kogukonnast"</item>
<item quantity="other">"Eemalda %1$d jututuba „%2$s“ kogukonnast"</item>
</plurals>
<string name="screen_space_settings_leave_space">"Lahku kogukonnast"</string> <string name="screen_space_settings_leave_space">"Lahku kogukonnast"</string>
<string name="screen_space_settings_roles_and_permissions">"Rollid ja õigused"</string> <string name="screen_space_settings_roles_and_permissions">"Rollid ja õigused"</string>
<string name="screen_space_settings_security_and_privacy">"Turvalisus ja privaatsus"</string> <string name="screen_space_settings_security_and_privacy">"Turvalisus ja privaatsus"</string>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_choose_owners_action">"Choisir les propriétaires"</string>
<string name="screen_leave_space_last_admin_info">"%1$s (Admin)"</string> <string name="screen_leave_space_last_admin_info">"%1$s (Admin)"</string>
<plurals name="screen_leave_space_submit"> <plurals name="screen_leave_space_submit">
<item quantity="one">"Quitter %1$d salon et lespace"</item> <item quantity="one">"Quitter %1$d salon et lespace"</item>
@ -7,9 +8,11 @@
</plurals> </plurals>
<string name="screen_leave_space_subtitle">"Sélectionnez les salons que vous souhaitez quitter et dont vous nêtes pas le seul administrateur:"</string> <string name="screen_leave_space_subtitle">"Sélectionnez les salons que vous souhaitez quitter et dont vous nêtes pas le seul administrateur:"</string>
<string name="screen_leave_space_subtitle_last_admin">"Vous devez désigner un autre administrateur pour cet espace avant de pouvoir partir."</string> <string name="screen_leave_space_subtitle_last_admin">"Vous devez désigner un autre administrateur pour cet espace avant de pouvoir partir."</string>
<string name="screen_leave_space_subtitle_last_owner">"Vous êtes le seul propriétaire de %1$s. Vous devez transférer la propriété de lespace à quelquun dautre avant de le quitter."</string>
<string name="screen_leave_space_subtitle_only_last_admin">"Vous ne quitterez pas le ou les salons suivants car vous y êtes le seul administrateur:"</string> <string name="screen_leave_space_subtitle_only_last_admin">"Vous ne quitterez pas le ou les salons suivants car vous y êtes le seul administrateur:"</string>
<string name="screen_leave_space_title">"Quitter %1$s?"</string> <string name="screen_leave_space_title">"Quitter %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"Vous êtes le seul administrateur de %1$s"</string> <string name="screen_leave_space_title_last_admin">"Vous êtes le seul administrateur de %1$s"</string>
<string name="screen_leave_space_title_last_owner">"Transfert de propriété"</string>
<string name="screen_space_add_room_action">"Salon"</string> <string name="screen_space_add_room_action">"Salon"</string>
<string name="screen_space_add_rooms_room_access_description">"Ajouter un salon ne changera pas laccès au salon. Pour modifier laccès, aller dans les paramètres du salon puis dans Sécurité &amp; confidentialité."</string> <string name="screen_space_add_rooms_room_access_description">"Ajouter un salon ne changera pas laccès au salon. Pour modifier laccès, aller dans les paramètres du salon puis dans Sécurité &amp; confidentialité."</string>
<string name="screen_space_empty_state_title">"Ajoutez votre premier salon"</string> <string name="screen_space_empty_state_title">"Ajoutez votre premier salon"</string>

View file

@ -7,6 +7,7 @@
</plurals> </plurals>
<string name="screen_leave_space_subtitle">"Ez a tér összes szobájából is eltávolítja."</string> <string name="screen_leave_space_subtitle">"Ez a tér összes szobájából is eltávolítja."</string>
<string name="screen_leave_space_subtitle_last_admin">"Mielőtt elhagyhatná ezt a teret, ki kell jelölnie egy másik adminisztrátort."</string> <string name="screen_leave_space_subtitle_last_admin">"Mielőtt elhagyhatná ezt a teret, ki kell jelölnie egy másik adminisztrátort."</string>
<string name="screen_leave_space_subtitle_last_owner">"Ön a(z) %1$s egyetlen tulajdonosa. Mielőtt távozik, át kell ruháznia a tulajdonjogot valaki másra."</string>
<string name="screen_leave_space_subtitle_only_last_admin">"Nem lesz eltávolítva a következő szobá(k)ból, mert ön az egyetlen adminisztrátor:"</string> <string name="screen_leave_space_subtitle_only_last_admin">"Nem lesz eltávolítva a következő szobá(k)ból, mert ön az egyetlen adminisztrátor:"</string>
<string name="screen_leave_space_title">"Kilép innen: %1$s?"</string> <string name="screen_leave_space_title">"Kilép innen: %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"Ön az egyetlen adminisztrátor itt: %1$s"</string> <string name="screen_leave_space_title_last_admin">"Ön az egyetlen adminisztrátor itt: %1$s"</string>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_choose_owners_action">"Choose owners"</string>
<string name="screen_leave_space_last_admin_info">"%1$s (Admin)"</string> <string name="screen_leave_space_last_admin_info">"%1$s (Admin)"</string>
<plurals name="screen_leave_space_submit"> <plurals name="screen_leave_space_submit">
<item quantity="one">"Leave %1$d room and space"</item> <item quantity="one">"Leave %1$d room and space"</item>
@ -7,9 +8,11 @@
</plurals> </plurals>
<string name="screen_leave_space_subtitle">"Select the rooms youd like to leave which you\'re not the only administrator for:"</string> <string name="screen_leave_space_subtitle">"Select the rooms youd like to leave which you\'re not the only administrator for:"</string>
<string name="screen_leave_space_subtitle_last_admin">"You need to assign another admin for this space before you can leave."</string> <string name="screen_leave_space_subtitle_last_admin">"You need to assign another admin for this space before you can leave."</string>
<string name="screen_leave_space_subtitle_last_owner">"You are the only owner of %1$s. You need to transfer ownership to someone else before you leave."</string>
<string name="screen_leave_space_subtitle_only_last_admin">"You will not be removed from the following room(s) because you\'re the only administrator:"</string> <string name="screen_leave_space_subtitle_only_last_admin">"You will not be removed from the following room(s) because you\'re the only administrator:"</string>
<string name="screen_leave_space_title">"Leave %1$s?"</string> <string name="screen_leave_space_title">"Leave %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"You are the only admin for %1$s"</string> <string name="screen_leave_space_title_last_admin">"You are the only admin for %1$s"</string>
<string name="screen_leave_space_title_last_owner">"Transfer ownership"</string>
<string name="screen_space_add_room_action">"Room"</string> <string name="screen_space_add_room_action">"Room"</string>
<string name="screen_space_add_rooms_room_access_description">"Adding a room will not affect the room access. To change the access go to Room settings &gt; Security &amp; privacy."</string> <string name="screen_space_add_rooms_room_access_description">"Adding a room will not affect the room access. To change the access go to Room settings &gt; Security &amp; privacy."</string>
<string name="screen_space_empty_state_title">"Add your first room"</string> <string name="screen_space_empty_state_title">"Add your first room"</string>

View file

@ -12,6 +12,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.changeroommemberroles.test.FakeChangeRoomMemberRolesEntryPoint
import io.element.android.features.createroom.api.FakeCreateRoomEntryPoint import io.element.android.features.createroom.api.FakeCreateRoomEntryPoint
import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.features.space.impl.di.FakeSpaceFlowGraph import io.element.android.features.space.impl.di.FakeSpaceFlowGraph
@ -22,6 +23,7 @@ import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode import io.element.android.tests.testutils.node.TestParentNode
import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -33,7 +35,7 @@ class DefaultSpaceEntryPointTest {
val mainDispatcherRule = MainDispatcherRule() val mainDispatcherRule = MainDispatcherRule()
@Test @Test
fun `test node builder`() { fun `test node builder`() = runTest {
val entryPoint = DefaultSpaceEntryPoint() val entryPoint = DefaultSpaceEntryPoint()
val nodeInputs = SpaceEntryPoint.Inputs(A_ROOM_ID) val nodeInputs = SpaceEntryPoint.Inputs(A_ROOM_ID)
val parentNode = TestParentNode.create { buildContext, plugins -> val parentNode = TestParentNode.create { buildContext, plugins ->
@ -46,6 +48,8 @@ class DefaultSpaceEntryPointTest {
room = FakeJoinedRoom(), room = FakeJoinedRoom(),
graphFactory = FakeSpaceFlowGraph.Factory, graphFactory = FakeSpaceFlowGraph.Factory,
createRoomEntryPoint = FakeCreateRoomEntryPoint(), createRoomEntryPoint = FakeCreateRoomEntryPoint(),
changeRoomMemberRolesEntryPoint = FakeChangeRoomMemberRolesEntryPoint(),
sessionCoroutineScope = backgroundScope,
) )
} }
val callback = object : SpaceEntryPoint.Callback { val callback = object : SpaceEntryPoint.Callback {

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.aRoomSummary import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
@ -116,12 +117,15 @@ class AddRoomToSpacePresenterTest {
@Test @Test
fun `present - searchResults shows Results when rooms available`() = runTest { fun `present - searchResults shows Results when rooms available`() = runTest {
val roomListService = FakeRoomListService() val roomList = FakeDynamicRoomList()
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createAddRoomToSpacePresenter(roomListService = roomListService) val presenter = createAddRoomToSpacePresenter(roomListService = roomListService)
presenter.test { presenter.test {
awaitItem() // Initial state awaitItem() // Initial state
// Post rooms to the service // Post rooms to the service
roomListService.postAllRooms( roomList.summaries.emit(
listOf( listOf(
aRoomSummary( aRoomSummary(
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@ -296,6 +300,29 @@ class AddRoomToSpacePresenterTest {
} }
} }
@Test
fun `present - UpdateSearchVisibleRange triggers pagination when near end`() = runTest {
val loadMoreLambda = lambdaRecorder<Unit> { }
val roomList = FakeDynamicRoomList(loadMoreLambda = loadMoreLambda)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createAddRoomToSpacePresenter(roomListService = roomListService)
presenter.test {
val state = awaitItem()
// Post rooms to simulate loaded content
roomList.summaries.emit(listOf(aRoomSummary()))
advanceUntilIdle()
skipItems(1)
// UpdateSearchVisibleRange should trigger loadMore
state.eventSink(AddRoomToSpaceEvent.UpdateSearchVisibleRange(IntRange(0, 9)))
advanceUntilIdle()
assert(loadMoreLambda).isCalledOnce()
}
}
@Test @Test
fun `present - Dismiss after partial success calls reset`() = runTest { fun `present - Dismiss after partial success calls reset`() = runTest {
val resetResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) } val resetResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }

View file

@ -14,6 +14,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
@ -99,6 +100,21 @@ class AddRoomToSpaceViewTest {
) )
} }
} }
@Config(qualifiers = "h1024dp")
@Test
fun `displaying search results sends UpdateSearchVisibleRange event`() {
val eventsRecorder = EventsRecorder<AddRoomToSpaceEvent>()
val rooms = aSelectRoomInfoList()
rule.setAddRoomToSpaceView(
anAddRoomToSpaceState(
isSearchActive = true,
searchResults = SearchBarResultState.Results(rooms),
eventSink = eventsRecorder,
),
)
eventsRecorder.assertTrue(0) { it is AddRoomToSpaceEvent.UpdateSearchVisibleRange }
}
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAddRoomToSpaceView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAddRoomToSpaceView(

View file

@ -29,11 +29,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class LeaveSpacePresenterTest { class LeaveSpacePresenterTest {
private val aSpace = aSpaceRoom(
roomId = A_SPACE_ID,
displayName = A_SPACE_NAME,
)
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createLeaveSpacePresenter( val presenter = createLeaveSpacePresenter(
@ -44,7 +39,7 @@ class LeaveSpacePresenterTest {
presenter.test { presenter.test {
val state = awaitItem() val state = awaitItem()
assertThat(state.spaceName).isNull() assertThat(state.spaceName).isNull()
assertThat(state.isLastOwner).isFalse() assertThat(state.needsOwnerChange).isFalse()
assertThat(state.selectableSpaceRooms.isLoading()).isTrue() assertThat(state.selectableSpaceRooms.isLoading()).isTrue()
assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized) assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@ -87,7 +82,7 @@ class LeaveSpacePresenterTest {
skipItems(2) skipItems(2)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.spaceName).isEqualTo(A_SPACE_NAME) assertThat(finalState.spaceName).isEqualTo(A_SPACE_NAME)
assertThat(finalState.isLastOwner).isTrue() assertThat(finalState.needsOwnerChange).isTrue()
// The current state is not in the sub room list // The current state is not in the sub room list
assertThat(finalState.selectableSpaceRooms.dataOrNull()!!).isEmpty() assertThat(finalState.selectableSpaceRooms.dataOrNull()!!).isEmpty()
} }
@ -145,8 +140,8 @@ class LeaveSpacePresenterTest {
roomsResult = { roomsResult = {
Result.success( Result.success(
listOf( listOf(
LeaveSpaceRoom(aSpaceRoom(roomId = A_ROOM_ID), isLastOwner = false), LeaveSpaceRoom(aSpaceRoom(roomId = A_ROOM_ID), isLastOwner = false, areCreatorsPrivileged = false),
LeaveSpaceRoom(aSpaceRoom(roomId = A_ROOM_ID_2), isLastOwner = true), LeaveSpaceRoom(aSpaceRoom(roomId = A_ROOM_ID_2), isLastOwner = true, areCreatorsPrivileged = false),
) )
) )
}, },
@ -157,7 +152,7 @@ class LeaveSpacePresenterTest {
skipItems(3) skipItems(3)
val state = awaitItem() val state = awaitItem()
assertThat(state.spaceName).isNull() assertThat(state.spaceName).isNull()
assertThat(state.isLastOwner).isFalse() assertThat(state.needsOwnerChange).isFalse()
val data = state.selectableSpaceRooms.dataOrNull()!! val data = state.selectableSpaceRooms.dataOrNull()!!
assertThat(data.size).isEqualTo(2) assertThat(data.size).isEqualTo(2)
// Only one room is selectable as the user is the last admin in the other one // Only one room is selectable as the user is the last admin in the other one
@ -232,6 +227,20 @@ class LeaveSpacePresenterTest {
} }
} }
@Test
fun `present - needsOwnerChange is false if user is the last joined member`() = runTest {
val presenter = createLeaveSpacePresenter(
leaveSpaceHandle = FakeLeaveSpaceHandle(
roomsResult = { Result.success(listOf(aLeaveSpaceRoom(spaceRoom = aSpaceRoom(numJoinedMembers = 1), isLastOwner = true))) },
)
)
presenter.test {
skipItems(3)
val state = awaitItem()
assertThat(state.needsOwnerChange).isFalse()
}
}
private fun createLeaveSpacePresenter( private fun createLeaveSpacePresenter(
leaveSpaceHandle: LeaveSpaceHandle = FakeLeaveSpaceHandle(), leaveSpaceHandle: LeaveSpaceHandle = FakeLeaveSpaceHandle(),
): LeaveSpacePresenter { ): LeaveSpacePresenter {
@ -241,13 +250,18 @@ class LeaveSpacePresenterTest {
} }
} }
private val aSpace = aSpaceRoom(
roomId = A_SPACE_ID,
displayName = A_SPACE_NAME,
numJoinedMembers = 2,
)
private fun aLeaveSpaceRoom( private fun aLeaveSpaceRoom(
spaceRoom: SpaceRoom = aSpaceRoom( spaceRoom: SpaceRoom = aSpace,
roomId = A_SPACE_ID,
displayName = A_SPACE_NAME,
),
isLastOwner: Boolean = false, isLastOwner: Boolean = false,
areCreatorsPrivileged: Boolean = false,
) = LeaveSpaceRoom( ) = LeaveSpaceRoom(
spaceRoom = spaceRoom, spaceRoom = spaceRoom,
isLastOwner = isLastOwner, isLastOwner = isLastOwner,
areCreatorsPrivileged = areCreatorsPrivileged,
) )

View file

@ -16,7 +16,7 @@
<string name="screen_session_verification_compare_emojis_user_subtitle">"Bekräfta att emojierna nedan matchar de som visas på den andra användarens enhet."</string> <string name="screen_session_verification_compare_emojis_user_subtitle">"Bekräfta att emojierna nedan matchar de som visas på den andra användarens enhet."</string>
<string name="screen_session_verification_compare_numbers_subtitle">"Bekräfta att siffrorna nedan matchar de som visas på din andra session."</string> <string name="screen_session_verification_compare_numbers_subtitle">"Bekräfta att siffrorna nedan matchar de som visas på din andra session."</string>
<string name="screen_session_verification_compare_numbers_title">"Jämför siffror"</string> <string name="screen_session_verification_compare_numbers_title">"Jämför siffror"</string>
<string name="screen_session_verification_complete_subtitle">"Din nya session är nu verifierad. Den har tillgång till dina krypterade meddelanden, och andra användare kommer att se den som betrodd."</string> <string name="screen_session_verification_complete_subtitle">"Du kan nu läsa eller skicka meddelanden säkert på din andra enhet."</string>
<string name="screen_session_verification_complete_user_subtitle">"Nu kan du lita på användarens identitet när du skickar eller tar emot meddelanden."</string> <string name="screen_session_verification_complete_user_subtitle">"Nu kan du lita på användarens identitet när du skickar eller tar emot meddelanden."</string>
<string name="screen_session_verification_device_verified">"Enhet verifierad"</string> <string name="screen_session_verification_device_verified">"Enhet verifierad"</string>
<string name="screen_session_verification_enter_recovery_key">"Ange återställningsnyckel"</string> <string name="screen_session_verification_enter_recovery_key">"Ange återställningsnyckel"</string>
@ -33,7 +33,7 @@
<string name="screen_session_verification_request_failure_title">"Verifiering misslyckades"</string> <string name="screen_session_verification_request_failure_title">"Verifiering misslyckades"</string>
<string name="screen_session_verification_request_footer">"Fortsätt bara om du initierade denna verifiering."</string> <string name="screen_session_verification_request_footer">"Fortsätt bara om du initierade denna verifiering."</string>
<string name="screen_session_verification_request_subtitle">"Verifiera den andra enheten för att hålla din meddelandehistorik säker."</string> <string name="screen_session_verification_request_subtitle">"Verifiera den andra enheten för att hålla din meddelandehistorik säker."</string>
<string name="screen_session_verification_request_success_subtitle">"Din nya session är nu verifierad. Den har tillgång till dina krypterade meddelanden, och andra användare kommer att se den som betrodd."</string> <string name="screen_session_verification_request_success_subtitle">"Du kan nu läsa eller skicka meddelanden säkert på din andra enhet."</string>
<string name="screen_session_verification_request_success_title">"Enhet verifierad"</string> <string name="screen_session_verification_request_success_title">"Enhet verifierad"</string>
<string name="screen_session_verification_request_title">"Verifiering begärd"</string> <string name="screen_session_verification_request_title">"Verifiering begärd"</string>
<string name="screen_session_verification_they_dont_match">"De matchar inte"</string> <string name="screen_session_verification_they_dont_match">"De matchar inte"</string>

View file

@ -7,7 +7,7 @@ android_gradle_plugin = "8.13.2"
# When updateing this, please also update the version in the file ./idea/kotlinc.xml # When updateing this, please also update the version in the file ./idea/kotlinc.xml
kotlin = "2.3.0" kotlin = "2.3.0"
kotlinpoet = "2.2.0" kotlinpoet = "2.2.0"
ksp = "2.3.4" ksp = "2.3.5"
firebaseAppDistribution = "5.2.0" firebaseAppDistribution = "5.2.0"
# AndroidX # AndroidX
@ -16,9 +16,9 @@ datastore = "1.2.0"
constraintlayout = "2.2.1" constraintlayout = "2.2.1"
constraintlayout_compose = "1.1.1" constraintlayout_compose = "1.1.1"
lifecycle = "2.10.0" lifecycle = "2.10.0"
activity = "1.12.2" activity = "1.12.3"
media3 = "1.9.1" media3 = "1.9.1"
camera = "1.5.2" camera = "1.5.3"
work = "2.11.1" work = "2.11.1"
# Compose # Compose
@ -52,7 +52,7 @@ haze = "1.7.1"
dependencyAnalysis = "3.5.1" dependencyAnalysis = "3.5.1"
# DI # DI
metro = "0.10.1" metro = "0.10.2"
# Auto service # Auto service
autoservice = "1.1.1" autoservice = "1.1.1"
@ -62,7 +62,7 @@ detekt = "1.23.8"
# See https://github.com/pinterest/ktlint/releases/ # See https://github.com/pinterest/ktlint/releases/
ktlint = "1.8.0" ktlint = "1.8.0"
androidx-test-ext-junit = "1.3.0" androidx-test-ext-junit = "1.3.0"
kover = "0.9.4" kover = "0.9.5"
[libraries] [libraries]
# Project # Project
@ -218,7 +218,7 @@ haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref =
color_picker = "io.mhssn:colorpicker:1.0.0" color_picker = "io.mhssn:colorpicker:1.0.0"
# Analytics # Analytics
posthog = "com.posthog:posthog-android:3.29.1" posthog = "com.posthog:posthog-android:3.30.0"
sentry = "io.sentry:sentry-android:8.31.0" sentry = "io.sentry:sentry-android:8.31.0"
# main branch can be tested replacing the version with main-SNAPSHOT # main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.29.2" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.29.2"

View file

@ -8,7 +8,6 @@
package io.element.android.libraries.core.extensions package io.element.android.libraries.core.extensions
import java.text.Normalizer
import java.util.Locale import java.util.Locale
fun Boolean.toOnOff() = if (this) "ON" else "OFF" fun Boolean.toOnOff() = if (this) "ON" else "OFF"
@ -86,11 +85,6 @@ fun String.safeCapitalize(): String {
} }
} }
fun String.withoutAccents(): String {
return Normalizer.normalize(this, Normalizer.Form.NFD)
.replace("\\p{Mn}+".toRegex(), "")
}
private const val RTL_OVERRIDE_CHAR = '\u202E' private const val RTL_OVERRIDE_CHAR = '\u202E'
private const val LTR_OVERRIDE_CHAR = '\u202D' private const val LTR_OVERRIDE_CHAR = '\u202D'

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.utils
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
@Composable
fun OnVisibleRangeChangeEffect(lazyListState: LazyListState, onChange: (IntRange) -> Unit) {
val onChangeUpdated by rememberUpdatedState(onChange)
LaunchedEffect(lazyListState) {
snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo }
.map { visibleItemsInfo ->
val firstItemIndex = visibleItemsInfo.firstOrNull()?.index ?: 0
val size = visibleItemsInfo.size
firstItemIndex until firstItemIndex + size
}
.distinctUntilChanged()
.collectLatest { visibleRange ->
onChangeUpdated(visibleRange)
}
}
}

View file

@ -8,25 +8,14 @@
package io.element.android.libraries.matrix.api.roomlist package io.element.android.libraries.matrix.api.roomlist
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
/** /**
* RoomList with dynamic filtering and loading. * RoomList with dynamic filtering and loading.
* This is useful for large lists of rooms. * This is useful for large lists of rooms.
* It lets load rooms on demand and filter them. * It lets load rooms on demand and filter them.
*/ */
interface DynamicRoomList : RoomList { interface DynamicRoomList : RoomList {
val currentFilter: StateFlow<RoomListFilter>
val loadedPages: StateFlow<Int>
val pageSize: Int val pageSize: Int
val filteredSummaries: SharedFlow<List<RoomSummary>>
/** /**
* Load more rooms into the list if possible. * Load more rooms into the list if possible.
*/ */
@ -44,28 +33,13 @@ interface DynamicRoomList : RoomList {
suspend fun updateFilter(filter: RoomListFilter) suspend fun updateFilter(filter: RoomListFilter)
} }
/** suspend fun DynamicRoomList.updateVisibleRange(
* Offers a way to load all the rooms incrementally. visibleRange: IntRange,
* It will load more room until all are loaded. paginationThreshold: Int = pageSize * 3
* If total number of rooms increase, it will load more pages if needed. ) {
* The number of rooms is independent of the filter. val loadedCount = summaries.replayCache.firstOrNull().orEmpty().count()
*/ val threshold = loadedCount - paginationThreshold
fun DynamicRoomList.loadAllIncrementally(coroutineScope: CoroutineScope) { if (visibleRange.last >= threshold) {
combine( loadMore()
loadedPages,
loadingState,
) { loadedPages, loadingState ->
loadedPages to loadingState
} }
.onEach { (loadedPages, loadingState) ->
when (loadingState) {
is RoomList.LoadingState.Loaded -> {
if (pageSize * loadedPages < loadingState.numberOfRooms) {
loadMore()
}
}
RoomList.LoadingState.NotLoaded -> Unit
}
}
.launchIn(coroutineScope)
} }

View file

@ -8,8 +8,6 @@
package io.element.android.libraries.matrix.api.roomlist package io.element.android.libraries.matrix.api.roomlist
import io.element.android.libraries.core.extensions.withoutAccents
sealed interface RoomListFilter { sealed interface RoomListFilter {
companion object { companion object {
/** /**
@ -77,7 +75,5 @@ sealed interface RoomListFilter {
*/ */
data class NormalizedMatchRoomName( data class NormalizedMatchRoomName(
val pattern: String val pattern: String
) : RoomListFilter { ) : RoomListFilter
val normalizedPattern: String = pattern.withoutAccents()
}
} }

View file

@ -38,13 +38,11 @@ interface RoomListService {
/** /**
* Creates a room list that can be used to load more rooms and filter them dynamically. * Creates a room list that can be used to load more rooms and filter them dynamically.
* @param pageSize the number of rooms to load at once. * @param pageSize the number of rooms to load at once.
* @param initialFilter the initial filter to apply to the rooms.
* @param source the source of the rooms, either all rooms or invites. * @param source the source of the rooms, either all rooms or invites.
* @param coroutineScope the coroutine scope to use for the room list operations. * @param coroutineScope the coroutine scope to use for the room list operations.
*/ */
fun createRoomList( fun createRoomList(
pageSize: Int, pageSize: Int,
initialFilter: RoomListFilter,
source: RoomList.Source, source: RoomList.Source,
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
): DynamicRoomList ): DynamicRoomList
@ -56,10 +54,10 @@ interface RoomListService {
suspend fun subscribeToVisibleRooms(roomIds: List<RoomId>) suspend fun subscribeToVisibleRooms(roomIds: List<RoomId>)
/** /**
* Returns a [DynamicRoomList] object of all rooms we want to display. * Returns a [RoomList] object with all rooms locally known.
* If you want to get a filtered room list, consider using [createRoomList]. * If you want to get a filtered room list, consider using [createRoomList].
*/ */
val allRooms: DynamicRoomList val allRooms: RoomList
/** /**
* The sync indicator as a flow. * The sync indicator as a flow.

View file

@ -11,4 +11,5 @@ package io.element.android.libraries.matrix.api.spaces
data class LeaveSpaceRoom( data class LeaveSpaceRoom(
val spaceRoom: SpaceRoom, val spaceRoom: SpaceRoom,
val isLastOwner: Boolean, val isLastOwner: Boolean,
val areCreatorsPrivileged: Boolean,
) )

View file

@ -161,7 +161,8 @@ class RustMatrixClientFactory(
.requestConfig( .requestConfig(
RequestConfig( RequestConfig(
timeout = 30_000uL, timeout = 30_000uL,
retryLimit = 0u, // retryLimit must be non-zero for the SDK to retry API calls in case of error (including 429 Too Many Requests error).
retryLimit = 3u,
// Use default values for the rest // Use default values for the rest
maxConcurrentRequests = null, maxConcurrentRequests = null,
maxRetryTime = null, maxRetryTime = null,

View file

@ -1,17 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.roomlist
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
internal sealed interface RoomListDynamicEvents {
data object Reset : RoomListDynamicEvents
data object LoadMore : RoomListDynamicEvents
data class SetFilter(val filter: RoomListEntriesDynamicFilterKind) : RoomListDynamicEvents
}

View file

@ -18,9 +18,8 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
import org.matrix.rustcomponents.sdk.RoomListEntriesListener import org.matrix.rustcomponents.sdk.RoomListEntriesListener
import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
@ -57,8 +56,8 @@ fun RoomListInterface.loadingStateFlow(): Flow<RoomListLoadingState> =
internal fun RoomListInterface.entriesFlow( internal fun RoomListInterface.entriesFlow(
pageSize: Int, pageSize: Int,
roomListDynamicEvents: Flow<RoomListDynamicEvents>, initialFilterKind: RoomListEntriesDynamicFilterKind,
initialFilterKind: RoomListEntriesDynamicFilterKind onControllerCreated: (RoomListDynamicEntriesController) -> Unit,
): Flow<List<RoomListEntriesUpdate>> = ): Flow<List<RoomListEntriesUpdate>> =
callbackFlow { callbackFlow {
val listener = object : RoomListEntriesListener { val listener = object : RoomListEntriesListener {
@ -73,19 +72,7 @@ internal fun RoomListInterface.entriesFlow(
) )
val controller = result.controller() val controller = result.controller()
controller.setFilter(initialFilterKind) controller.setFilter(initialFilterKind)
roomListDynamicEvents.onEach { controllerEvents -> onControllerCreated(controller)
when (controllerEvents) {
is RoomListDynamicEvents.SetFilter -> {
controller.setFilter(controllerEvents.filter)
}
is RoomListDynamicEvents.LoadMore -> {
controller.addOnePage()
}
is RoomListDynamicEvents.Reset -> {
controller.resetToOnePage()
}
}
}.launchIn(this)
awaitClose { awaitClose {
result.entriesStream().cancelAndDestroy() result.entriesStream().cancelAndDestroy()
controller.destroy() controller.destroy()

View file

@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList
import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter.Companion.all
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@ -18,24 +19,16 @@ import io.element.android.services.analytics.api.finishLongRunningTransaction
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController
import org.matrix.rustcomponents.sdk.RoomListLoadingState import org.matrix.rustcomponents.sdk.RoomListLoadingState
import org.matrix.rustcomponents.sdk.RoomListService import org.matrix.rustcomponents.sdk.RoomListService
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import org.matrix.rustcomponents.sdk.RoomList as InnerRoomList import org.matrix.rustcomponents.sdk.RoomList as InnerRoomList
private val ROOM_LIST_RUST_FILTERS = listOf(
RoomListEntriesDynamicFilterKind.NonLeft,
RoomListEntriesDynamicFilterKind.DeduplicateVersions
)
internal class RoomListFactory( internal class RoomListFactory(
private val innerRoomListService: RoomListService, private val innerRoomListService: RoomListService,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
@ -49,18 +42,14 @@ internal class RoomListFactory(
pageSize: Int, pageSize: Int,
coroutineContext: CoroutineContext, coroutineContext: CoroutineContext,
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
initialFilter: RoomListFilter = RoomListFilter.all(), initialFilter: RoomListFilter = all(),
innerProvider: suspend () -> InnerRoomList innerProvider: suspend () -> InnerRoomList
): DynamicRoomList { ): DynamicRoomList {
val loadingStateFlow: MutableStateFlow<RoomList.LoadingState> = MutableStateFlow(RoomList.LoadingState.NotLoaded) val loadingStateFlow: MutableStateFlow<RoomList.LoadingState> = MutableStateFlow(RoomList.LoadingState.NotLoaded)
val filteredSummariesFlow = MutableSharedFlow<List<RoomSummary>>(replay = 1, extraBufferCapacity = 1)
val summariesFlow = MutableSharedFlow<List<RoomSummary>>(replay = 1, extraBufferCapacity = 1) val summariesFlow = MutableSharedFlow<List<RoomSummary>>(replay = 1, extraBufferCapacity = 1)
val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryFactory, analyticsService) val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryFactory, analyticsService)
// Makes sure we don't miss any events
val dynamicEvents = MutableSharedFlow<RoomListDynamicEvents>(replay = 100)
val currentFilter = MutableStateFlow(initialFilter)
val loadedPages = MutableStateFlow(1)
var innerRoomList: InnerRoomList? = null var innerRoomList: InnerRoomList? = null
var dynamicController: RoomListDynamicEntriesController? = null
val firstRoomsTransaction = analyticsService.startTransaction("Load first set of rooms", "innerRoomList.entriesFlow") val firstRoomsTransaction = analyticsService.startTransaction("Load first set of rooms", "innerRoomList.entriesFlow")
@ -69,8 +58,10 @@ internal class RoomListFactory(
innerRoomList.let { innerRoomList -> innerRoomList.let { innerRoomList ->
innerRoomList.entriesFlow( innerRoomList.entriesFlow(
pageSize = pageSize, pageSize = pageSize,
roomListDynamicEvents = dynamicEvents, initialFilterKind = RoomListFilterMapper.toRustFilter(initialFilter),
initialFilterKind = RoomListEntriesDynamicFilterKind.All(ROOM_LIST_RUST_FILTERS), onControllerCreated = { controller ->
dynamicController = controller
}
).onEach { update -> ).onEach { update ->
if (!firstRoomsTransaction.isFinished()) { if (!firstRoomsTransaction.isFinished()) {
analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.FirstRoomsDisplayed) analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.FirstRoomsDisplayed)
@ -85,61 +76,20 @@ internal class RoomListFactory(
loadingStateFlow.value = it loadingStateFlow.value = it
} }
.launchIn(this) .launchIn(this)
combine(
currentFilter,
summariesFlow
) { filter, summaries ->
summaries.filter(filter)
}.onEach {
filteredSummariesFlow.emit(it)
}.launchIn(this)
} }
}.invokeOnCompletion { }.invokeOnCompletion {
innerRoomList?.destroy() innerRoomList?.destroy()
} }
return RustDynamicRoomList( return RustDynamicRoomList(
summaries = summariesFlow, summaries = summariesFlow,
filteredSummaries = filteredSummariesFlow,
loadingState = loadingStateFlow, loadingState = loadingStateFlow,
currentFilter = currentFilter,
loadedPages = loadedPages,
dynamicEvents = dynamicEvents,
processor = processor, processor = processor,
pageSize = pageSize, pageSize = pageSize,
dynamicController = { dynamicController }
) )
} }
} }
private class RustDynamicRoomList(
override val summaries: MutableSharedFlow<List<RoomSummary>>,
override val filteredSummaries: SharedFlow<List<RoomSummary>>,
override val loadingState: MutableStateFlow<RoomList.LoadingState>,
override val currentFilter: MutableStateFlow<RoomListFilter>,
override val loadedPages: MutableStateFlow<Int>,
private val dynamicEvents: MutableSharedFlow<RoomListDynamicEvents>,
private val processor: RoomSummaryListProcessor,
override val pageSize: Int,
) : DynamicRoomList {
override suspend fun rebuildSummaries() {
processor.rebuildRoomSummaries()
}
override suspend fun updateFilter(filter: RoomListFilter) {
currentFilter.emit(filter)
}
override suspend fun loadMore() {
dynamicEvents.emit(RoomListDynamicEvents.LoadMore)
loadedPages.getAndUpdate { it + 1 }
}
override suspend fun reset() {
dynamicEvents.emit(RoomListDynamicEvents.Reset)
loadedPages.emit(1)
}
}
private fun RoomListLoadingState.toLoadingState(): RoomList.LoadingState { private fun RoomListLoadingState.toLoadingState(): RoomList.LoadingState {
return when (this) { return when (this) {
is RoomListLoadingState.Loaded -> RoomList.LoadingState.Loaded(maximumNumberOfRooms?.toInt() ?: 0) is RoomListLoadingState.Loaded -> RoomList.LoadingState.Loaded(maximumNumberOfRooms?.toInt() ?: 0)

View file

@ -1,72 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.core.extensions.withoutAccents
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
val RoomListFilter.predicate
get() = when (this) {
is RoomListFilter.All -> { roomSummary -> NonSpacePredicate(roomSummary) || IsInvitedPredicate(roomSummary) }
is RoomListFilter.Any -> { roomSummary -> NonSpacePredicate(roomSummary) || IsInvitedPredicate(roomSummary) }
RoomListFilter.None -> { _ -> false }
RoomListFilter.Category.Group -> { roomSummary: RoomSummary ->
!roomSummary.info.isDm && NonInvitedPredicate(roomSummary) && NonSpacePredicate(roomSummary)
}
RoomListFilter.Category.People -> { roomSummary: RoomSummary ->
roomSummary.info.isDm && NonInvitedPredicate(roomSummary) && NonSpacePredicate(roomSummary)
}
RoomListFilter.Category.Space -> IsSpacePredicate
RoomListFilter.Favorite -> { roomSummary: RoomSummary ->
roomSummary.info.isFavorite && NonInvitedPredicate(roomSummary) && NonSpacePredicate(roomSummary)
}
RoomListFilter.Unread -> { roomSummary: RoomSummary ->
NonInvitedPredicate(roomSummary) &&
NonSpacePredicate(roomSummary) &&
(roomSummary.info.numUnreadNotifications > 0 || roomSummary.info.isMarkedUnread)
}
is RoomListFilter.NormalizedMatchRoomName -> { roomSummary: RoomSummary ->
roomSummary.info.name?.withoutAccents().orEmpty().contains(normalizedPattern, ignoreCase = true) &&
(NonSpacePredicate(roomSummary) || IsInvitedPredicate(roomSummary))
}
RoomListFilter.Invite -> IsInvitedPredicate
}
fun List<RoomSummary>.filter(filter: RoomListFilter): List<RoomSummary> {
return when (filter) {
is RoomListFilter.All -> {
val predicates = if (filter.filters.isNotEmpty()) {
filter.filters.map { it.predicate }
} else {
listOf(filter.predicate)
}
filter { roomSummary -> predicates.all { it(roomSummary) } }
}
is RoomListFilter.Any -> {
val predicates = if (filter.filters.isNotEmpty()) {
filter.filters.map { it.predicate }
} else {
listOf(filter.predicate)
}
filter { roomSummary -> predicates.any { it(roomSummary) } }
}
else -> filter(filter.predicate)
}
}
private val IsSpacePredicate = { roomSummary: RoomSummary -> roomSummary.info.isSpace }
private val NonSpacePredicate = { roomSummary: RoomSummary -> !IsSpacePredicate(roomSummary) }
private val IsInvitedPredicate = { roomSummary: RoomSummary -> roomSummary.info.currentUserMembership == CurrentUserMembership.INVITED }
private val NonInvitedPredicate = { roomSummary: RoomSummary -> !IsInvitedPredicate(roomSummary) }

View file

@ -0,0 +1,75 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.All
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Any
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Category
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.DeduplicateVersions
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Favourite
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Invite
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.NonLeft
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.NonSpace
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.None
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.NormalizedMatchRoomName
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Space
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Unread
import org.matrix.rustcomponents.sdk.RoomListFilterCategory
/**
* Mapper for converting RoomListFilter to Rust SDK filter kinds.
*/
internal object RoomListFilterMapper {
/**
* Base rust filters to always apply across all room lists.
* These filters ensure we show:
* - Non-space, non-left rooms (regular rooms user is part of)
* - OR space invites (pending space invitations)
* - With version deduplication enabled
*/
private val RUST_BASE_FILTERS = listOf(
Any(
listOf(
All(listOf(NonSpace, NonLeft)),
All(listOf(Space, Invite)),
)
),
DeduplicateVersions
)
/**
* Converts a RoomListFilter to a Rust SDK RoomListEntriesDynamicFilterKind.
* Applies base filters along with the provided filter.
*/
fun toRustFilter(filter: RoomListFilter): RoomListEntriesDynamicFilterKind {
return All(RUST_BASE_FILTERS + mapFilter(filter))
}
/**
* Maps a RoomListFilter to its Rust SDK equivalent.
* This replaces the previous RoomListFilter.into() extension function.
*/
private fun mapFilter(filter: RoomListFilter): RoomListEntriesDynamicFilterKind {
return when (filter) {
is RoomListFilter.All -> All(filters = filter.filters.map { mapFilter(it) })
is RoomListFilter.Any -> Any(filters = filter.filters.map { mapFilter(it) })
RoomListFilter.None -> None
RoomListFilter.Category.Group -> Category(RoomListFilterCategory.GROUP)
RoomListFilter.Category.People -> Category(RoomListFilterCategory.PEOPLE)
RoomListFilter.Category.Space -> Space
RoomListFilter.Favorite -> Favourite
RoomListFilter.Unread -> Unread
is RoomListFilter.NormalizedMatchRoomName -> NormalizedMatchRoomName(
pattern = filter.pattern
)
RoomListFilter.Invite -> Invite
}
}
}

View file

@ -0,0 +1,62 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController
private const val DEFAULT_ADD_PAGES_COUNT = 3
internal class RustDynamicRoomList(
override val summaries: MutableSharedFlow<List<RoomSummary>>,
override val loadingState: MutableStateFlow<RoomList.LoadingState>,
private val processor: RoomSummaryListProcessor,
override val pageSize: Int,
private val dynamicController: () -> RoomListDynamicEntriesController?,
private val addPagesCount: Int = DEFAULT_ADD_PAGES_COUNT
) : DynamicRoomList {
private val mutex = Mutex()
override suspend fun rebuildSummaries() {
processor.rebuildRoomSummaries()
}
override suspend fun updateFilter(filter: RoomListFilter) {
mutex.withLock {
dynamicController()?.let { controller ->
// Reset pagination when filter changes
controller.resetToOnePage()
val rustFilter = RoomListFilterMapper.toRustFilter(filter)
controller.setFilter(rustFilter)
// Then preload some pages
controller.addPages(addPagesCount)
}
}
}
override suspend fun loadMore() {
mutex.withLock {
dynamicController()?.addPages(addPagesCount)
}
}
override suspend fun reset() {
mutex.withLock {
dynamicController()?.resetToOnePage()
}
}
private fun RoomListDynamicEntriesController.addPages(pageCount: Int) = repeat(pageCount) { addOnePage() }
}

View file

@ -11,9 +11,7 @@ package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList
import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally
import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -28,8 +26,6 @@ import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicator
import timber.log.Timber import timber.log.Timber
import org.matrix.rustcomponents.sdk.RoomListService as InnerRustRoomListService import org.matrix.rustcomponents.sdk.RoomListService as InnerRustRoomListService
private const val DEFAULT_PAGE_SIZE = 20
internal class RustRoomListService( internal class RustRoomListService(
private val innerRoomListService: InnerRustRoomListService, private val innerRoomListService: InnerRustRoomListService,
private val sessionDispatcher: CoroutineDispatcher, private val sessionDispatcher: CoroutineDispatcher,
@ -39,13 +35,11 @@ internal class RustRoomListService(
) : RoomListService { ) : RoomListService {
override fun createRoomList( override fun createRoomList(
pageSize: Int, pageSize: Int,
initialFilter: RoomListFilter,
source: RoomList.Source, source: RoomList.Source,
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
): DynamicRoomList { ): DynamicRoomList {
return roomListFactory.createRoomList( return roomListFactory.createRoomList(
pageSize = pageSize, pageSize = pageSize,
initialFilter = initialFilter,
coroutineContext = sessionDispatcher, coroutineContext = sessionDispatcher,
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
) { ) {
@ -59,18 +53,14 @@ internal class RustRoomListService(
roomSyncSubscriber.batchSubscribe(roomIds) roomSyncSubscriber.batchSubscribe(roomIds)
} }
override val allRooms: DynamicRoomList = roomListFactory.createRoomList( override val allRooms: RoomList = roomListFactory.createRoomList(
pageSize = DEFAULT_PAGE_SIZE, pageSize = Int.MAX_VALUE,
coroutineContext = sessionDispatcher, coroutineContext = sessionDispatcher,
coroutineScope = sessionCoroutineScope, coroutineScope = sessionCoroutineScope,
) { ) {
innerRoomListService.allRooms() innerRoomListService.allRooms()
} }
init {
allRooms.loadAllIncrementally(sessionCoroutineScope)
}
override val syncIndicator: StateFlow<RoomListService.SyncIndicator> = override val syncIndicator: StateFlow<RoomListService.SyncIndicator> =
innerRoomListService.syncIndicator() innerRoomListService.syncIndicator()
.map { it.toSyncIndicator() } .map { it.toSyncIndicator() }

View file

@ -41,6 +41,7 @@ class RustLeaveSpaceHandle(
LeaveSpaceRoom( LeaveSpaceRoom(
spaceRoom = spaceRoomMapper.map(leaveSpaceRoom.spaceRoom), spaceRoom = spaceRoomMapper.map(leaveSpaceRoom.spaceRoom),
isLastOwner = leaveSpaceRoom.isLastOwner, isLastOwner = leaveSpaceRoom.isLastOwner,
areCreatorsPrivileged = leaveSpaceRoom.areCreatorsPrivileged,
) )
} }
} }

View file

@ -1,156 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.roomlist
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.test.room.aRoomSummary
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RoomListFilterTest {
private val regularRoom = aRoomSummary(
isDirect = false,
)
private val dmRoom = aRoomSummary(
isDirect = true,
activeMembersCount = 2
)
private val favoriteRoom = aRoomSummary(
isFavorite = true
)
private val markedAsUnreadRoom = aRoomSummary(
isMarkedUnread = true
)
private val unreadNotificationRoom = aRoomSummary(
numUnreadNotifications = 1
)
private val roomToSearch = aRoomSummary(
name = "Room to search"
)
private val roomWithAccent = aRoomSummary(
name = "Frédéric"
)
private val invitedRoom = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED
)
private val space = aRoomSummary(
isSpace = true
)
private val invitedSpace = aRoomSummary(
isSpace = true,
currentUserMembership = CurrentUserMembership.INVITED
)
private val roomSummaries = listOf(
regularRoom,
dmRoom,
favoriteRoom,
markedAsUnreadRoom,
unreadNotificationRoom,
roomToSearch,
roomWithAccent,
invitedRoom,
space,
invitedSpace,
)
@Test
fun `Room list filter all empty`() = runTest {
val filter = RoomListFilter.all()
assertThat(roomSummaries.filter(filter)).isEqualTo(roomSummaries - space)
}
@Test
fun `Room list filter none`() = runTest {
val filter = RoomListFilter.None
assertThat(roomSummaries.filter(filter)).isEmpty()
}
@Test
fun `Room list filter people`() = runTest {
val filter = RoomListFilter.Category.People
assertThat(roomSummaries.filter(filter)).containsExactly(dmRoom)
}
@Test
fun `Room list filter group`() = runTest {
val filter = RoomListFilter.Category.Group
assertThat(roomSummaries.filter(filter)).containsExactly(
regularRoom,
favoriteRoom,
markedAsUnreadRoom,
unreadNotificationRoom,
roomToSearch,
roomWithAccent,
)
}
@Test
fun `Room list filter space`() = runTest {
val filter = RoomListFilter.Category.Space
assertThat(roomSummaries.filter(filter)).containsExactly(space, invitedSpace)
}
@Test
fun `Room list filter favorite`() = runTest {
val filter = RoomListFilter.Favorite
assertThat(roomSummaries.filter(filter)).containsExactly(favoriteRoom)
}
@Test
fun `Room list filter unread`() = runTest {
val filter = RoomListFilter.Unread
assertThat(roomSummaries.filter(filter)).containsExactly(markedAsUnreadRoom, unreadNotificationRoom)
}
@Test
fun `Room list filter invites`() = runTest {
val filter = RoomListFilter.Invite
assertThat(roomSummaries.filter(filter)).containsExactly(invitedRoom, invitedSpace)
}
@Test
fun `Room list filter normalized match room name`() = runTest {
val filter = RoomListFilter.NormalizedMatchRoomName("search")
assertThat(roomSummaries.filter(filter)).containsExactly(roomToSearch)
}
@Test
fun `Room list filter normalized match room name with accent`() = runTest {
val filter = RoomListFilter.NormalizedMatchRoomName("Fred")
assertThat(roomSummaries.filter(filter)).containsExactly(roomWithAccent)
}
@Test
fun `Room list filter normalized match room name with accent when searching with accent`() = runTest {
val filter = RoomListFilter.NormalizedMatchRoomName("Fréd")
assertThat(roomSummaries.filter(filter)).containsExactly(roomWithAccent)
}
@Test
fun `Room list filter all with one match`() = runTest {
val filter = RoomListFilter.all(
RoomListFilter.Category.Group,
RoomListFilter.Favorite
)
assertThat(roomSummaries.filter(filter)).containsExactly(favoriteRoom)
}
@Test
fun `Room list filter all with no match`() = runTest {
val filter = RoomListFilter.all(
RoomListFilter.Category.People,
RoomListFilter.Favorite
)
assertThat(roomSummaries.filter(filter)).isEmpty()
}
}

View file

@ -13,34 +13,30 @@ import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.getAndUpdate
data class SimplePagedRoomList( class FakeDynamicRoomList(
override val summaries: MutableStateFlow<List<RoomSummary>>, override val summaries: MutableStateFlow<List<RoomSummary>> = MutableStateFlow(emptyList()),
override val loadingState: StateFlow<RoomList.LoadingState>, override val loadingState: MutableStateFlow<RoomList.LoadingState> = MutableStateFlow(RoomList.LoadingState.NotLoaded),
override val currentFilter: MutableStateFlow<RoomListFilter> override val pageSize: Int = Int.MAX_VALUE,
val currentFilter: MutableStateFlow<RoomListFilter> = MutableStateFlow(RoomListFilter.None),
private val loadMoreLambda: () -> Unit = {},
private val resetLambda: () -> Unit = {},
private val updateFilterLambda: (RoomListFilter) -> Unit = { filter -> currentFilter.value = filter },
private val rebuildSummariesLambda: () -> Unit = {},
) : DynamicRoomList { ) : DynamicRoomList {
override val pageSize: Int = Int.MAX_VALUE
override val loadedPages = MutableStateFlow(1)
override val filteredSummaries: SharedFlow<List<RoomSummary>> = summaries
override suspend fun loadMore() { override suspend fun loadMore() {
// No-op loadMoreLambda()
loadedPages.getAndUpdate { it + 1 }
} }
override suspend fun reset() { override suspend fun reset() {
loadedPages.emit(1) resetLambda()
} }
override suspend fun updateFilter(filter: RoomListFilter) { override suspend fun updateFilter(filter: RoomListFilter) {
currentFilter.emit(filter) updateFilterLambda(filter)
} }
override suspend fun rebuildSummaries() { override suspend fun rebuildSummaries() {
// No-op rebuildSummariesLambda()
} }
} }

View file

@ -11,29 +11,19 @@ package io.element.android.libraries.matrix.test.roomlist
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList
import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
class FakeRoomListService( class FakeRoomListService(
var subscribeToVisibleRoomsLambda: (List<RoomId>) -> Unit = {}, private val subscribeToVisibleRoomsLambda: (List<RoomId>) -> Unit = {},
private val createRoomListLambda: (pageSize: Int) -> DynamicRoomList = { pageSize -> FakeDynamicRoomList(pageSize = pageSize) },
override val allRooms: RoomList = createRoomListLambda(Int.MAX_VALUE),
) : RoomListService { ) : RoomListService {
private val allRoomSummariesFlow = MutableStateFlow<List<RoomSummary>>(emptyList())
private val allRoomsLoadingStateFlow = MutableStateFlow<RoomList.LoadingState>(RoomList.LoadingState.NotLoaded)
private val roomListStateFlow = MutableStateFlow<RoomListService.State>(RoomListService.State.Idle) private val roomListStateFlow = MutableStateFlow<RoomListService.State>(RoomListService.State.Idle)
private val syncIndicatorStateFlow = MutableStateFlow<RoomListService.SyncIndicator>(RoomListService.SyncIndicator.Hide) private val syncIndicatorStateFlow = MutableStateFlow<RoomListService.SyncIndicator>(RoomListService.SyncIndicator.Hide)
suspend fun postAllRooms(roomSummaries: List<RoomSummary>) {
allRoomSummariesFlow.emit(roomSummaries)
}
suspend fun postAllRoomsLoadingState(loadingState: RoomList.LoadingState) {
allRoomsLoadingStateFlow.emit(loadingState)
}
suspend fun postState(state: RoomListService.State) { suspend fun postState(state: RoomListService.State) {
roomListStateFlow.emit(state) roomListStateFlow.emit(state)
} }
@ -44,25 +34,14 @@ class FakeRoomListService(
override fun createRoomList( override fun createRoomList(
pageSize: Int, pageSize: Int,
initialFilter: RoomListFilter,
source: RoomList.Source, source: RoomList.Source,
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
): DynamicRoomList { ) = createRoomListLambda(pageSize)
return when (source) {
RoomList.Source.All -> allRooms
}
}
override suspend fun subscribeToVisibleRooms(roomIds: List<RoomId>) { override suspend fun subscribeToVisibleRooms(roomIds: List<RoomId>) {
subscribeToVisibleRoomsLambda(roomIds) subscribeToVisibleRoomsLambda(roomIds)
} }
override val allRooms = SimplePagedRoomList(
allRoomSummariesFlow,
allRoomsLoadingStateFlow,
MutableStateFlow(RoomListFilter.all())
)
override val state: StateFlow<RoomListService.State> = roomListStateFlow override val state: StateFlow<RoomListService.State> = roomListStateFlow
override val syncIndicator: StateFlow<RoomListService.SyncIndicator> = syncIndicatorStateFlow override val syncIndicator: StateFlow<RoomListService.SyncIndicator> = syncIndicatorStateFlow

View file

@ -16,6 +16,12 @@ plugins {
android { android {
namespace = "io.element.android.libraries.roomselect.impl" namespace = "io.element.android.libraries.roomselect.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
} }
setupDependencyInjection() setupDependencyInjection()
@ -30,6 +36,6 @@ dependencies {
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
api(projects.libraries.roomselect.api) api(projects.libraries.roomselect.api)
testCommonDependencies(libs) testCommonDependencies(libs, includeTestComposeView = true)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
} }

View file

@ -16,4 +16,5 @@ sealed interface RoomSelectEvents {
// TODO remove to restore multi-selection // TODO remove to restore multi-selection
data object RemoveSelectedRoom : RoomSelectEvents data object RemoveSelectedRoom : RoomSelectEvents
data object ToggleSearchActive : RoomSelectEvents data object ToggleSearchActive : RoomSelectEvents
data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvents
} }

View file

@ -29,6 +29,7 @@ import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
@AssistedInject @AssistedInject
class RoomSelectPresenter( class RoomSelectPresenter(
@ -80,6 +81,9 @@ class RoomSelectPresenter(
} }
RoomSelectEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf() RoomSelectEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf()
RoomSelectEvents.ToggleSearchActive -> isSearchActive = !isSearchActive RoomSelectEvents.ToggleSearchActive -> isSearchActive = !isSearchActive
is RoomSelectEvents.UpdateVisibleRange -> coroutineScope.launch {
dataSource.updateVisibleRange(event.range)
}
} }
} }

View file

@ -16,7 +16,7 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally import io.element.android.libraries.matrix.api.roomlist.updateVisibleRange
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -46,14 +46,11 @@ class RoomSelectSearchDataSource(
private val roomList = roomListService.createRoomList( private val roomList = roomListService.createRoomList(
pageSize = PAGE_SIZE, pageSize = PAGE_SIZE,
initialFilter = RoomListFilter.all(),
source = RoomList.Source.All, source = RoomList.Source.All,
coroutineScope = coroutineScope coroutineScope = coroutineScope
).apply { )
loadAllIncrementally(coroutineScope)
}
val roomInfoList: Flow<ImmutableList<SelectRoomInfo>> = roomList.filteredSummaries val roomInfoList: Flow<ImmutableList<SelectRoomInfo>> = roomList.summaries
.map { roomSummaries -> .map { roomSummaries ->
roomSummaries roomSummaries
.filter { it.info.currentUserMembership == CurrentUserMembership.JOINED } .filter { it.info.currentUserMembership == CurrentUserMembership.JOINED }
@ -63,6 +60,10 @@ class RoomSelectSearchDataSource(
} }
.flowOn(coroutineDispatchers.computation) .flowOn(coroutineDispatchers.computation)
suspend fun updateVisibleRange(visibleRange: IntRange) {
roomList.updateVisibleRange(visibleRange)
}
suspend fun setSearchQuery(searchQuery: String) = coroutineScope { suspend fun setSearchQuery(searchQuery: String) = coroutineScope {
val filter = if (searchQuery.isBlank()) { val filter = if (searchQuery.isBlank()) {
RoomListFilter.all() RoomListFilter.all()

View file

@ -43,22 +43,23 @@ open class RoomSelectStateProvider : PreviewParameterProvider<RoomSelectState> {
) )
} }
private fun aRoomSelectState( internal fun aRoomSelectState(
mode: RoomSelectMode = RoomSelectMode.Forward, mode: RoomSelectMode = RoomSelectMode.Forward,
resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>> = SearchBarResultState.Initial(), resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>> = SearchBarResultState.Initial(),
searchQuery: String = "", searchQuery: String = "",
isSearchActive: Boolean = false, isSearchActive: Boolean = false,
selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(), selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(),
eventSink: (RoomSelectEvents) -> Unit = {},
) = RoomSelectState( ) = RoomSelectState(
mode = mode, mode = mode,
resultState = resultState, resultState = resultState,
searchQuery = TextFieldState(initialText = searchQuery), searchQuery = TextFieldState(initialText = searchQuery),
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
selectedRooms = selectedRooms, selectedRooms = selectedRooms,
eventSink = {} eventSink = eventSink,
) )
private fun aRoomSelectRoomList() = persistentListOf( internal fun aRoomSelectRoomList() = persistentListOf(
aSelectRoomInfo( aSelectRoomInfo(
roomId = RoomId("!room1:domain"), roomId = RoomId("!room1:domain"),
name = "Room with name", name = "Room with name",

View file

@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -51,6 +52,7 @@ import io.element.android.libraries.designsystem.theme.components.SearchBarResul
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.SelectedRoom import io.element.android.libraries.matrix.ui.components.SelectedRoom
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
@ -100,6 +102,11 @@ fun RoomSelectView(
onBack = { onBackButton(state) } onBack = { onBackButton(state) }
) )
val lazyListState = rememberLazyListState()
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
state.eventSink(RoomSelectEvents.UpdateVisibleRange(visibleRange))
}
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
@ -138,7 +145,7 @@ fun RoomSelectView(
resultState = state.resultState, resultState = state.resultState,
showBackButton = false, showBackButton = false,
) { summaries -> ) { summaries ->
LazyColumn { LazyColumn(state = lazyListState) {
item { item {
SelectedRoomsHelper( SelectedRoomsHelper(
// TODO state.isForwarding // TODO state.isForwarding
@ -170,7 +177,7 @@ fun RoomSelectView(
Spacer(modifier = Modifier.height(20.dp)) Spacer(modifier = Modifier.height(20.dp))
if (state.resultState is SearchBarResultState.Results) { if (state.resultState is SearchBarResultState.Results) {
LazyColumn { LazyColumn(state = lazyListState) {
items(state.resultState.results, key = { it.roomId.value }) { roomSummary -> items(state.resultState.results, key = { it.roomId.value }) { roomSummary ->
Column { Column {
RoomSummaryView( RoomSummaryView(

View file

@ -17,13 +17,17 @@ import io.element.android.libraries.designsystem.theme.components.SearchBarResul
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.room.aRoomSummary import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode import io.element.android.libraries.roomselect.api.RoomSelectMode
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -63,9 +67,12 @@ class RoomSelectPresenterTest {
@Test @Test
fun `present - update query`() = runTest { fun `present - update query`() = runTest {
val roomSummary = aRoomSummary() val roomSummary = aRoomSummary()
val roomListService = FakeRoomListService().apply { val roomList = FakeDynamicRoomList(
postAllRooms(listOf(roomSummary)) summaries = MutableStateFlow(listOf(roomSummary))
} )
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createRoomSelectPresenter( val presenter = createRoomSelectPresenter(
roomListService = roomListService roomListService = roomListService
) )
@ -81,12 +88,12 @@ class RoomSelectPresenterTest {
skipItems(1) skipItems(1)
initialState.searchQuery.setTextAndPlaceCursorAtEnd("string not contained") initialState.searchQuery.setTextAndPlaceCursorAtEnd("string not contained")
assertThat( assertThat(
roomListService.allRooms.currentFilter.value roomList.currentFilter.value
).isEqualTo( ).isEqualTo(
RoomListFilter.NormalizedMatchRoomName("string not contained") RoomListFilter.NormalizedMatchRoomName("string not contained")
) )
assertThat(awaitItem().searchQuery.text.toString()).isEqualTo("string not contained") assertThat(awaitItem().searchQuery.text.toString()).isEqualTo("string not contained")
roomListService.postAllRooms( roomList.summaries.emit(
emptyList() emptyList()
) )
assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResultsFound::class.java) assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResultsFound::class.java)
@ -96,9 +103,12 @@ class RoomSelectPresenterTest {
@Test @Test
fun `present - select and remove a room`() = runTest { fun `present - select and remove a room`() = runTest {
val roomSummary = aRoomSummary() val roomSummary = aRoomSummary()
val roomListService = FakeRoomListService().apply { val roomList = FakeDynamicRoomList(
postAllRooms(listOf(roomSummary)) summaries = MutableStateFlow(listOf(roomSummary))
} )
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createRoomSelectPresenter( val presenter = createRoomSelectPresenter(
roomListService = roomListService, roomListService = roomListService,
) )
@ -114,6 +124,35 @@ class RoomSelectPresenterTest {
cancel() cancel()
} }
} }
@Test
fun `present - UpdateVisibleRange triggers pagination when near end`() = runTest {
val loadMoreLambda = lambdaRecorder<Unit> { }
val roomList = FakeDynamicRoomList(
summaries = MutableStateFlow(listOf()),
loadMoreLambda = loadMoreLambda,
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createRoomSelectPresenter(roomListService = roomListService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.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(RoomSelectEvents.UpdateVisibleRange(IntRange(0, 9)))
// Give time for the coroutine to complete
testScheduler.advanceUntilIdle()
assert(loadMoreLambda).isCalledOnce()
}
}
} }
internal fun TestScope.createRoomSelectPresenter( internal fun TestScope.createRoomSelectPresenter(

View file

@ -93,6 +93,7 @@
<string name="action_enable">"Aktivieren"</string> <string name="action_enable">"Aktivieren"</string>
<string name="action_end_poll">"Umfrage beenden"</string> <string name="action_end_poll">"Umfrage beenden"</string>
<string name="action_enter_pin">"PIN eingeben"</string> <string name="action_enter_pin">"PIN eingeben"</string>
<string name="action_explore_public_spaces">"Erkunde öffentliche Spaces"</string>
<string name="action_finish">"Fertigstellen"</string> <string name="action_finish">"Fertigstellen"</string>
<string name="action_forgot_password">"Passwort vergessen?"</string> <string name="action_forgot_password">"Passwort vergessen?"</string>
<string name="action_forward">"Weiterleiten"</string> <string name="action_forward">"Weiterleiten"</string>
@ -303,6 +304,7 @@ Grund: %1$s."</string>
<string name="common_report_a_problem">"Ein Problem melden"</string> <string name="common_report_a_problem">"Ein Problem melden"</string>
<string name="common_report_submitted">"Bericht eingereicht"</string> <string name="common_report_submitted">"Bericht eingereicht"</string>
<string name="common_rich_text_editor">"Rich-Text-Editor"</string> <string name="common_rich_text_editor">"Rich-Text-Editor"</string>
<string name="common_role">"Rolle"</string>
<string name="common_room">"Chat"</string> <string name="common_room">"Chat"</string>
<string name="common_room_name">"Chat-Name"</string> <string name="common_room_name">"Chat-Name"</string>
<string name="common_room_name_placeholder">"z.B. Projektname"</string> <string name="common_room_name_placeholder">"z.B. Projektname"</string>
@ -332,6 +334,7 @@ Grund: %1$s."</string>
<string name="common_server_url">"Server-URL"</string> <string name="common_server_url">"Server-URL"</string>
<string name="common_settings">"Einstellungen"</string> <string name="common_settings">"Einstellungen"</string>
<string name="common_share_space">"Space teilen"</string> <string name="common_share_space">"Space teilen"</string>
<string name="common_shared_history">"Neue Mitglieder sehen den Nachrichtenverlauf"</string>
<string name="common_shared_location">"Geteilter Standort"</string> <string name="common_shared_location">"Geteilter Standort"</string>
<string name="common_shared_space">"Gemeinsamer Space"</string> <string name="common_shared_space">"Gemeinsamer Space"</string>
<string name="common_signing_out">"Abmelden"</string> <string name="common_signing_out">"Abmelden"</string>
@ -383,6 +386,7 @@ Grund: %1$s."</string>
<string name="common_voice_message">"Sprachnachricht"</string> <string name="common_voice_message">"Sprachnachricht"</string>
<string name="common_waiting">"Warten…"</string> <string name="common_waiting">"Warten…"</string>
<string name="common_waiting_for_decryption_key">"Warte auf diese Nachricht"</string> <string name="common_waiting_for_decryption_key">"Warte auf diese Nachricht"</string>
<string name="common_world_readable_history">"Jeder kann den Nachrichtenverlauf sehen"</string>
<string name="common_you">"Du"</string> <string name="common_you">"Du"</string>
<string name="crypto_event_key_forwarded_known_profile_dialog_content">"%1$s (%2$s) hat diese Nachricht geteilt, weil du nicht im Chat warst, als sie verschickt wurde."</string> <string name="crypto_event_key_forwarded_known_profile_dialog_content">"%1$s (%2$s) hat diese Nachricht geteilt, weil du nicht im Chat warst, als sie verschickt wurde."</string>
<string name="crypto_event_key_forwarded_unknown_profile_dialog_content">"Diese Nachricht wurde von %1$s weitergeleitet, da du zum Zeitpunkt des Versands kein Mitglied der Gruppe warst."</string> <string name="crypto_event_key_forwarded_unknown_profile_dialog_content">"Diese Nachricht wurde von %1$s weitergeleitet, da du zum Zeitpunkt des Versands kein Mitglied der Gruppe warst."</string>

View file

@ -56,6 +56,7 @@
<string name="a11y_your_avatar">"Sinu tunnuspilt"</string> <string name="a11y_your_avatar">"Sinu tunnuspilt"</string>
<string name="action_accept">"Nõustu"</string> <string name="action_accept">"Nõustu"</string>
<string name="action_add_caption">"Lisa selgitus"</string> <string name="action_add_caption">"Lisa selgitus"</string>
<string name="action_add_existing_rooms">"Lisa olemasolevaid jututube"</string>
<string name="action_add_to_timeline">"Lisa ajajoonele"</string> <string name="action_add_to_timeline">"Lisa ajajoonele"</string>
<string name="action_back">"Tagasi"</string> <string name="action_back">"Tagasi"</string>
<string name="action_call">"Helista"</string> <string name="action_call">"Helista"</string>
@ -75,6 +76,7 @@
<string name="action_copy_text">"Kopeeri tekst"</string> <string name="action_copy_text">"Kopeeri tekst"</string>
<string name="action_create">"Loo"</string> <string name="action_create">"Loo"</string>
<string name="action_create_room">"Loo jututuba"</string> <string name="action_create_room">"Loo jututuba"</string>
<string name="action_create_space">"Loo kogukond"</string>
<string name="action_deactivate">"Eemalda konto"</string> <string name="action_deactivate">"Eemalda konto"</string>
<string name="action_deactivate_account">"Eemalda konto kasutusest"</string> <string name="action_deactivate_account">"Eemalda konto kasutusest"</string>
<string name="action_decline">"Keeldu"</string> <string name="action_decline">"Keeldu"</string>
@ -91,6 +93,7 @@
<string name="action_enable">"Võta kasutusele"</string> <string name="action_enable">"Võta kasutusele"</string>
<string name="action_end_poll">"Lõpeta küsitlus"</string> <string name="action_end_poll">"Lõpeta küsitlus"</string>
<string name="action_enter_pin">"Sisesta PIN-kood"</string> <string name="action_enter_pin">"Sisesta PIN-kood"</string>
<string name="action_explore_public_spaces">"Uuri ja avasta avalikke kogukondi"</string>
<string name="action_finish">"Lõpeta"</string> <string name="action_finish">"Lõpeta"</string>
<string name="action_forgot_password">"Kas unustasid salasõna?"</string> <string name="action_forgot_password">"Kas unustasid salasõna?"</string>
<string name="action_forward">"Edasta"</string> <string name="action_forward">"Edasta"</string>
@ -160,7 +163,7 @@
<string name="action_start_over">"Alusta uuesti"</string> <string name="action_start_over">"Alusta uuesti"</string>
<string name="action_start_verification">"Alusta verifitseerimist"</string> <string name="action_start_verification">"Alusta verifitseerimist"</string>
<string name="action_static_map_load">"Kaardi laadimiseks klõpsa"</string> <string name="action_static_map_load">"Kaardi laadimiseks klõpsa"</string>
<string name="action_take_photo">"Tee pilt"</string> <string name="action_take_photo">"Pildista"</string>
<string name="action_tap_for_options">"Valikuteks klõpsa"</string> <string name="action_tap_for_options">"Valikuteks klõpsa"</string>
<string name="action_translate">"Tõlgi"</string> <string name="action_translate">"Tõlgi"</string>
<string name="action_try_again">"Proovi uuesti"</string> <string name="action_try_again">"Proovi uuesti"</string>
@ -192,6 +195,7 @@
<string name="common_copied_to_clipboard">"Kopeeritud lõikelauale"</string> <string name="common_copied_to_clipboard">"Kopeeritud lõikelauale"</string>
<string name="common_copyright">"Autoriõigused"</string> <string name="common_copyright">"Autoriõigused"</string>
<string name="common_creating_room">"Loome jututoa…"</string> <string name="common_creating_room">"Loome jututoa…"</string>
<string name="common_creating_space">"Loon kogukonda…"</string>
<string name="common_current_user_canceled_knock">"Päring on tühistatud"</string> <string name="common_current_user_canceled_knock">"Päring on tühistatud"</string>
<string name="common_current_user_left_room">"Lahkus jututoast"</string> <string name="common_current_user_left_room">"Lahkus jututoast"</string>
<string name="common_current_user_left_space">"Lahkus kogukonnast"</string> <string name="common_current_user_left_space">"Lahkus kogukonnast"</string>
@ -290,6 +294,7 @@ Põhjus: %1$s."</string>
<string name="common_reason">"Põhjus"</string> <string name="common_reason">"Põhjus"</string>
<string name="common_recovery_key">"Taastevõti"</string> <string name="common_recovery_key">"Taastevõti"</string>
<string name="common_refreshing">"Värskendame andmeid…"</string> <string name="common_refreshing">"Värskendame andmeid…"</string>
<string name="common_removing">"Eemaldan…"</string>
<plurals name="common_replies"> <plurals name="common_replies">
<item quantity="one">"%1$d vastus"</item> <item quantity="one">"%1$d vastus"</item>
<item quantity="other">"%1$d vastust"</item> <item quantity="other">"%1$d vastust"</item>
@ -299,9 +304,10 @@ Põhjus: %1$s."</string>
<string name="common_report_a_problem">"Teata veast"</string> <string name="common_report_a_problem">"Teata veast"</string>
<string name="common_report_submitted">"Veateade on saadetud"</string> <string name="common_report_submitted">"Veateade on saadetud"</string>
<string name="common_rich_text_editor">"Vormindatud teksti toimeti"</string> <string name="common_rich_text_editor">"Vormindatud teksti toimeti"</string>
<string name="common_role">"Roll"</string>
<string name="common_room">"Jututuba"</string> <string name="common_room">"Jututuba"</string>
<string name="common_room_name">"Jututoa nimi"</string> <string name="common_room_name">"Jututoa nimi"</string>
<string name="common_room_name_placeholder">"näiteks sinu projekti või seltsingu nimi"</string> <string name="common_room_name_placeholder">"nt. sinu projekti või seltsingu nimi"</string>
<plurals name="common_rooms"> <plurals name="common_rooms">
<item quantity="one">"%1$d jututuba"</item> <item quantity="one">"%1$d jututuba"</item>
<item quantity="other">"%1$d jututuba"</item> <item quantity="other">"%1$d jututuba"</item>
@ -314,6 +320,10 @@ Põhjus: %1$s."</string>
<string name="common_security">"Turvalisus"</string> <string name="common_security">"Turvalisus"</string>
<string name="common_seen_by">"Seda nägi(d)"</string> <string name="common_seen_by">"Seda nägi(d)"</string>
<string name="common_select_account">"Vali kasutajakonto"</string> <string name="common_select_account">"Vali kasutajakonto"</string>
<plurals name="common_selected_count">
<item quantity="one">"%1$d on valitud"</item>
<item quantity="other">"%1$d on valitud"</item>
</plurals>
<string name="common_send_to">"Saada kasutajale"</string> <string name="common_send_to">"Saada kasutajale"</string>
<string name="common_sending">"Saadame…"</string> <string name="common_sending">"Saadame…"</string>
<string name="common_sending_failed">"Saatmine ei õnnestunud"</string> <string name="common_sending_failed">"Saatmine ei õnnestunud"</string>
@ -324,6 +334,7 @@ Põhjus: %1$s."</string>
<string name="common_server_url">"Serveri URL"</string> <string name="common_server_url">"Serveri URL"</string>
<string name="common_settings">"Seadistused"</string> <string name="common_settings">"Seadistused"</string>
<string name="common_share_space">"Jaga kogukonda"</string> <string name="common_share_space">"Jaga kogukonda"</string>
<string name="common_shared_history">"Uued liikmed näevad ajalugu"</string>
<string name="common_shared_location">"Jagatud asukoht"</string> <string name="common_shared_location">"Jagatud asukoht"</string>
<string name="common_shared_space">"Jagatud kogukond"</string> <string name="common_shared_space">"Jagatud kogukond"</string>
<string name="common_signing_out">"Logime välja"</string> <string name="common_signing_out">"Logime välja"</string>
@ -338,6 +349,7 @@ Põhjus: %1$s."</string>
<string name="common_starting_chat">"Alustame vestlust…"</string> <string name="common_starting_chat">"Alustame vestlust…"</string>
<string name="common_sticker">"Kleeps"</string> <string name="common_sticker">"Kleeps"</string>
<string name="common_success">"Õnnestus"</string> <string name="common_success">"Õnnestus"</string>
<string name="common_suggested">"Soovitatud"</string>
<string name="common_suggestions">"Soovitused"</string> <string name="common_suggestions">"Soovitused"</string>
<string name="common_syncing">"Sünkroniseerime"</string> <string name="common_syncing">"Sünkroniseerime"</string>
<string name="common_system">"Süsteem"</string> <string name="common_system">"Süsteem"</string>
@ -374,7 +386,9 @@ Põhjus: %1$s."</string>
<string name="common_voice_message">"Häälsõnum"</string> <string name="common_voice_message">"Häälsõnum"</string>
<string name="common_waiting">"Ootame…"</string> <string name="common_waiting">"Ootame…"</string>
<string name="common_waiting_for_decryption_key">"Ootame selle sõnumi dekrüptimisvõtit"</string> <string name="common_waiting_for_decryption_key">"Ootame selle sõnumi dekrüptimisvõtit"</string>
<string name="common_world_readable_history">"Kõik võivad ajalugu näha"</string>
<string name="common_you">"Sina"</string> <string name="common_you">"Sina"</string>
<string name="crypto_event_key_forwarded_known_profile_dialog_content">"Kuna sind polnud saatmise ajal jututoas, siis %1$s (%2$s) jagas seda sõnumit sinuga."</string>
<string name="crypto_history_visible">"See jututuba on seadistatud sedaviisi, et ka uued liikmed saavad lugeda varasemat ajalugu. %1$s"</string> <string name="crypto_history_visible">"See jututuba on seadistatud sedaviisi, et ka uued liikmed saavad lugeda varasemat ajalugu. %1$s"</string>
<string name="crypto_identity_change_pin_violation">"Kasutaja %1$s võrguidentiteet on lähtestatud. %2$s"</string> <string name="crypto_identity_change_pin_violation">"Kasutaja %1$s võrguidentiteet on lähtestatud. %2$s"</string>
<string name="crypto_identity_change_pin_violation_new">"Kasutaja %1$s %2$s võrguidentiteet on lähtestatud. %3$s"</string> <string name="crypto_identity_change_pin_violation_new">"Kasutaja %1$s %2$s võrguidentiteet on lähtestatud. %3$s"</string>
@ -467,6 +481,7 @@ Kas sa oled kindel, et soovid jätkata?"</string>
<string name="screen_share_this_location_action">"Jaga seda asukohta"</string> <string name="screen_share_this_location_action">"Jaga seda asukohta"</string>
<string name="screen_space_list_description">"Sinu loodud kogukonnad ning need, millega oled liitunud."</string> <string name="screen_space_list_description">"Sinu loodud kogukonnad ning need, millega oled liitunud."</string>
<string name="screen_space_list_details">"%1$s • %2$s"</string> <string name="screen_space_list_details">"%1$s • %2$s"</string>
<string name="screen_space_list_empty_state_title">"Jututubade haldamiseks võid luua kogukondi"</string>
<string name="screen_space_list_parent_space">"Kogukond: %1$s"</string> <string name="screen_space_list_parent_space">"Kogukond: %1$s"</string>
<string name="screen_space_list_title">"Kogukonnad"</string> <string name="screen_space_list_title">"Kogukonnad"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Sõnum on saatmata, kuna kasutaja %1$s verifitseeritud identiteet on lähtestatud."</string> <string name="screen_timeline_item_menu_send_failure_changed_identity">"Sõnum on saatmata, kuna kasutaja %1$s verifitseeritud identiteet on lähtestatud."</string>

View file

@ -224,7 +224,7 @@ Raison : %1$s."</string>
<string name="common_everyone">"Tout le monde"</string> <string name="common_everyone">"Tout le monde"</string>
<string name="common_failed">"Échec"</string> <string name="common_failed">"Échec"</string>
<string name="common_favourite">"Favori"</string> <string name="common_favourite">"Favori"</string>
<string name="common_favourited">"Favorisé"</string> <string name="common_favourited">"Ajouté aux favoris"</string>
<string name="common_file">"Fichier"</string> <string name="common_file">"Fichier"</string>
<string name="common_file_deleted">"Fichier supprimé"</string> <string name="common_file_deleted">"Fichier supprimé"</string>
<string name="common_file_saved">"Fichier enregistré"</string> <string name="common_file_saved">"Fichier enregistré"</string>
@ -334,6 +334,7 @@ Raison : %1$s."</string>
<string name="common_server_url">"URL du serveur"</string> <string name="common_server_url">"URL du serveur"</string>
<string name="common_settings">"Paramètres"</string> <string name="common_settings">"Paramètres"</string>
<string name="common_share_space">"Partager lespace"</string> <string name="common_share_space">"Partager lespace"</string>
<string name="common_shared_history">"Les nouveaux membres voient les anciens messages"</string>
<string name="common_shared_location">"Position partagée"</string> <string name="common_shared_location">"Position partagée"</string>
<string name="common_shared_space">"Espace partagé"</string> <string name="common_shared_space">"Espace partagé"</string>
<string name="common_signing_out">"Déconnexion"</string> <string name="common_signing_out">"Déconnexion"</string>
@ -385,6 +386,7 @@ Raison : %1$s."</string>
<string name="common_voice_message">"Message vocal"</string> <string name="common_voice_message">"Message vocal"</string>
<string name="common_waiting">"En attente…"</string> <string name="common_waiting">"En attente…"</string>
<string name="common_waiting_for_decryption_key">"En attente de la clé de déchiffrement"</string> <string name="common_waiting_for_decryption_key">"En attente de la clé de déchiffrement"</string>
<string name="common_world_readable_history">"Tout le monde peut voir les anciens messages"</string>
<string name="common_you">"Vous"</string> <string name="common_you">"Vous"</string>
<string name="crypto_event_key_forwarded_known_profile_dialog_content">"%1$s (%2$s) a partagé ce message avec vous car vous nétiez pas dans le salon lors de son envoi."</string> <string name="crypto_event_key_forwarded_known_profile_dialog_content">"%1$s (%2$s) a partagé ce message avec vous car vous nétiez pas dans le salon lors de son envoi."</string>
<string name="crypto_event_key_forwarded_unknown_profile_dialog_content">"%1$s a partagé ce message avec vous car vous nétiez pas dans le salon lors de son envoi."</string> <string name="crypto_event_key_forwarded_unknown_profile_dialog_content">"%1$s a partagé ce message avec vous car vous nétiez pas dans le salon lors de son envoi."</string>

View file

@ -333,6 +333,7 @@ Ok: %1$s."</string>
<string name="common_server_url">"Kiszolgáló webcíme"</string> <string name="common_server_url">"Kiszolgáló webcíme"</string>
<string name="common_settings">"Beállítások"</string> <string name="common_settings">"Beállítások"</string>
<string name="common_share_space">"Tér megosztása"</string> <string name="common_share_space">"Tér megosztása"</string>
<string name="common_shared_history">"Az új tagok látják az előzményeket"</string>
<string name="common_shared_location">"Megosztott tartózkodási hely"</string> <string name="common_shared_location">"Megosztott tartózkodási hely"</string>
<string name="common_shared_space">"Megosztott tér"</string> <string name="common_shared_space">"Megosztott tér"</string>
<string name="common_signing_out">"Kijelentkezés"</string> <string name="common_signing_out">"Kijelentkezés"</string>

View file

@ -72,7 +72,7 @@
<string name="action_copy_link_to_message">"Kopiera länk till meddelande"</string> <string name="action_copy_link_to_message">"Kopiera länk till meddelande"</string>
<string name="action_copy_text">"Kopiera text"</string> <string name="action_copy_text">"Kopiera text"</string>
<string name="action_create">"Skapa"</string> <string name="action_create">"Skapa"</string>
<string name="action_create_room">"Skapa ett rum"</string> <string name="action_create_room">"Skapa rum"</string>
<string name="action_deactivate">"Inaktivera"</string> <string name="action_deactivate">"Inaktivera"</string>
<string name="action_deactivate_account">"Inaktivera konto"</string> <string name="action_deactivate_account">"Inaktivera konto"</string>
<string name="action_decline">"Neka"</string> <string name="action_decline">"Neka"</string>

View file

@ -386,6 +386,7 @@ Reason: %1$s."</string>
<string name="common_voice_message">"Voice message"</string> <string name="common_voice_message">"Voice message"</string>
<string name="common_waiting">"Waiting…"</string> <string name="common_waiting">"Waiting…"</string>
<string name="common_waiting_for_decryption_key">"Waiting for this message"</string> <string name="common_waiting_for_decryption_key">"Waiting for this message"</string>
<string name="common_world_readable_history">"Anyone can see history"</string>
<string name="common_you">"You"</string> <string name="common_you">"You"</string>
<string name="crypto_event_key_forwarded_known_profile_dialog_content">"%1$s (%2$s) shared this message since you were not in the room when it was sent."</string> <string name="crypto_event_key_forwarded_known_profile_dialog_content">"%1$s (%2$s) shared this message since you were not in the room when it was sent."</string>
<string name="crypto_event_key_forwarded_unknown_profile_dialog_content">"%1$s shared this message since you were not in the room when it was sent."</string> <string name="crypto_event_key_forwarded_unknown_profile_dialog_content">"%1$s shared this message since you were not in the room when it was sent."</string>

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:153b2806d0c7d5365655f251574dc4d8efdabb2ed7df730d6868ef26c4220aeb oid sha256:74b77bc74d63b75022a0a9359f5bdf3954cfb0666655668944ff15921c319471
size 35747 size 34408

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