Merge remote-tracking branch 'kknappe/fix/start-voice-recording-when-permission-is-granted' into fix/start-voice-recording-when-permission-is-granted

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

View File

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

View File

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

View File

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

View File

@@ -3,14 +3,14 @@
<string name="screen_create_room_action_create_room">"Nytt rum"</string>
<string name="screen_create_room_add_people_title">"Bjud in personer"</string>
<string name="screen_create_room_error_creating_room">"Ett fel uppstod när rummet skapades"</string>
<string name="screen_create_room_private_option_description">"Endast inbjudna personer har tillgång till detta rum. Alla meddelanden är totalsträckskrypterade."</string>
<string name="screen_create_room_private_option_description">"Endast inbjudna personer kan gå med."</string>
<string name="screen_create_room_public_option_description">"Vem som helst kan hitta det här rummet.
Du kan ändra detta när som helst i rumsinställningarna."</string>
<string name="screen_create_room_public_option_title">"Offentligt rum"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Vem som helst kan be om att gå med i rummet men en administratör eller en moderator måste acceptera begäran"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Be om att gå med"</string>
<string name="screen_create_room_room_access_section_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_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_title">"Rumsadress"</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_new_room_title">"New room"</string>
<string name="screen_create_room_new_space_title">"New space"</string>
<string name="screen_create_room_parent_space_home_description">"(no space)"</string>
<string name="screen_create_room_parent_space_home_title">"Home"</string>
<string name="screen_create_room_private_option_description">"Only people invited can join."</string>
<string name="screen_create_room_private_option_title">"Private"</string>
<string name="screen_create_room_public_option_description">"Anyone can find this room.

View File

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

View File

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

View File

@@ -12,16 +12,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
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.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter
@Inject
class RoomListFiltersPresenter(
private val roomListService: RoomListService,
private val roomListDataSource: RoomListDataSource,
private val filterSelectionStrategy: FilterSelectionStrategy,
) : Presenter<RoomListFiltersState> {
private val initialFilters = filterSelectionStrategy.filterSelectionStates.value.toImmutableList()
@@ -56,9 +57,9 @@ class RoomListFiltersPresenter(
}
}
}
.collect { filters ->
.collectLatest { 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.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
@@ -68,9 +66,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
private const val EXTENDED_RANGE_SIZE = 40
private const val SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS = 300L
@Inject
class RoomListPresenter(
private val client: MatrixClient,
@@ -119,7 +114,7 @@ class RoomListPresenter(
fun handleEvent(event: RoomListEvent) {
when (event) {
is RoomListEvent.UpdateVisibleRange -> coroutineScope.launch {
updateVisibleRange(event.range)
roomListDataSource.updateVisibleRange(event.range)
}
RoomListEvent.DismissRequestVerificationPrompt -> securityBannerDismissed = true
RoomListEvent.DismissBanner -> securityBannerDismissed = true
@@ -217,7 +212,7 @@ class RoomListPresenter(
showNewNotificationSoundBanner: Boolean,
): RoomListContentState {
val roomSummaries by produceState(initialValue = AsyncData.Loading()) {
roomListDataSource.allRooms.collect { value = AsyncData.Success(it) }
roomListDataSource.roomSummariesFlow.collect { value = AsyncData.Success(it) }
}
val loadingState by roomListDataSource.loadingState.collectAsState()
val showEmpty by remember {
@@ -322,23 +317,4 @@ class RoomListPresenter(
room.clearEventCacheStorage()
}
}
private var currentUpdateVisibleRangeJob: Job? = null
private fun CoroutineScope.updateVisibleRange(range: IntRange) {
currentUpdateVisibleRangeJob?.cancel()
currentUpdateVisibleRangeJob = launch {
// Debounce the subscription to avoid subscribing to too many rooms
delay(SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS)
if (range.isEmpty()) return@launch
val currentRoomList = roomListDataSource.allRooms.first()
// Use extended range to 'prefetch' the next rooms info
val midExtendedRangeSize = EXTENDED_RANGE_SIZE / 2
val extendedRange = range.first until range.last + midExtendedRangeSize
val roomIds = extendedRange.mapNotNull { index ->
currentRoomList.getOrNull(index)?.roomId
}
roomListDataSource.subscribeToVisibleRooms(roomIds)
}
}
}

View File

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

View File

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

View File

@@ -21,6 +21,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
@Inject
class RoomListSearchPresenter(
@@ -37,10 +38,6 @@ class RoomListSearchPresenter(
val coroutineScope = rememberCoroutineScope()
val dataSource = remember { dataSourceFactory.create(coroutineScope) }
LaunchedEffect(isSearchActive) {
dataSource.setIsActive(isSearchActive)
}
LaunchedEffect(searchQuery.text) {
dataSource.setSearchQuery(searchQuery.text.toString())
}
@@ -54,6 +51,9 @@ class RoomListSearchPresenter(
isSearchActive = !isSearchActive
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
@@ -47,6 +48,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
@@ -154,7 +156,12 @@ private fun RoomListSearchContent(
.padding(padding)
.consumeWindowInsets(padding)
) {
val lazyListState = rememberLazyListState()
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
state.eventSink(RoomListSearchEvent.UpdateVisibleRange(visibleRange))
}
LazyColumn(
state = lazyListState,
modifier = Modifier.weight(1f),
) {
items(

View File

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

View File

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

View File

@@ -9,15 +9,28 @@
package io.element.android.features.home.impl.filters
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.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.test.notificationsettings.FakeNotificationSettingsService
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.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 org.junit.Test
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter
class RoomListFiltersPresenterTest {
@Test
@@ -39,13 +52,13 @@ class RoomListFiltersPresenterTest {
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `present - toggle rooms filter`() = runTest {
val roomListService = FakeRoomListService()
val presenter = createRoomListFiltersPresenter(roomListService)
presenter.test {
awaitItem().eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms))
awaitLastSequentialItem().let { state ->
assertThat(state.hasAnyFilterSelected).isTrue()
assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.Rooms, true),
@@ -56,12 +69,9 @@ class RoomListFiltersPresenterTest {
assertThat(state.selectedFilters()).containsExactly(
RoomListFilter.Rooms,
)
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
assertThat(roomListCurrentFilter.filters).containsExactly(
MatrixRoomListFilter.Category.Group,
)
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms))
}
advanceUntilIdle()
awaitLastSequentialItem().let { state ->
assertThat(state.hasAnyFilterSelected).isFalse()
assertThat(state.filterSelectionStates).containsExactly(
@@ -72,13 +82,12 @@ class RoomListFiltersPresenterTest {
filterSelectionState(RoomListFilter.Invites, false),
).inOrder()
assertThat(state.selectedFilters()).isEmpty()
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
assertThat(roomListCurrentFilter.filters).isEmpty()
}
}
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `present - clear filters event`() = runTest {
val roomListService = FakeRoomListService()
val presenter = createRoomListFiltersPresenter(roomListService)
@@ -88,6 +97,7 @@ class RoomListFiltersPresenterTest {
assertThat(state.hasAnyFilterSelected).isTrue()
state.eventSink.invoke(RoomListFiltersEvent.ClearSelectedFilters)
}
advanceUntilIdle()
awaitLastSequentialItem().let { state ->
assertThat(state.hasAnyFilterSelected).isFalse()
}
@@ -100,11 +110,25 @@ private fun filterSelectionState(filter: RoomListFilter, selected: Boolean) = Fi
isSelected = selected,
)
private fun createRoomListFiltersPresenter(
private fun TestScope.createRoomListFiltersPresenter(
roomListService: RoomListService = FakeRoomListService(),
notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
dateFormatter: DateFormatter = FakeDateFormatter(),
roomLatestEventFormatter: RoomLatestEventFormatter = FakeRoomLatestEventFormatter(),
): 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(),
)
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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_hint">"Sellest sisust teatamise põhjus"</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_files">"Manus"</string>
<string name="screen_room_attachment_source_gallery">"Fotode ja videote galerii"</string>

View File

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

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

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

View File

@@ -1,14 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<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_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_moderators">"Administratörer och moderatorer"</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_moderators">"Moderator"</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_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_topic">"Byt rumsämne"</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_unsaved_changes_description">"Du har osparade ä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">
<item quantity="one">"%1$d person"</item>
<item quantity="other">"%1$d personer"</item>
<item quantity="one">"%1$d Person"</item>
<item quantity="other">"%1$d Personer"</item>
</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_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_mode_banned">"Bannade"</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_moderator">"Administratörer och moderatorer"</string>
<string name="screen_room_member_list_role_administrator">"Admin"</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_room_members_header_title">"Rumsmedlemmar"</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_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_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_manage_spaces_action">"Halda kogukondi"</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_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_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_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_moderators">"Administratörer och moderatorer"</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_moderators">"Moderator"</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_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_topic">"Byt rumsämne"</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_not_encrypted">"Inte krypterat"</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_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>
@@ -65,10 +67,10 @@
<string name="screen_room_details_title">"Rumsinfo"</string>
<string name="screen_room_details_topic_title">"Ämne"</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">
<item quantity="one">"%1$d person"</item>
<item quantity="other">"%1$d personer"</item>
<item quantity="one">"%1$d Person"</item>
<item quantity="other">"%1$d Personer"</item>
</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_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_mode_banned">"Bannade"</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_moderator">"Administratörer och moderatorer"</string>
<string name="screen_room_member_list_role_administrator">"Admin"</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_room_members_header_title">"Rumsmedlemmar"</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"?>
<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_title">"Kunde inte uppdatera 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_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_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_removing_user">"Tar bort %1$s …"</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_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_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_manage_spaces_action">"Halda kogukondi"</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.features.createroom.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.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.features.space.impl.addroom.AddRoomToSpaceNode
import io.element.android.features.space.impl.di.SpaceFlowGraph
@@ -38,10 +41,15 @@ import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.DependencyInjectionGraphOwner
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.api.spaces.loadAllIncrementally
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
@@ -49,10 +57,12 @@ import kotlinx.parcelize.Parcelize
class SpaceFlowNode(
@Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
room: JoinedRoom,
private val room: JoinedRoom,
spaceService: SpaceService,
graphFactory: SpaceFlowGraph.Factory,
private val createRoomEntryPoint: CreateRoomEntryPoint,
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : BaseFlowNode<SpaceFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
@@ -80,6 +90,9 @@ class SpaceFlowNode(
@Parcelize
data object AddRoom : NavTarget
@Parcelize
data object ChangeOwners : NavTarget
}
override fun onBuilt() {
@@ -105,6 +118,10 @@ class SpaceFlowNode(
override fun navigateToRolesAndPermissions() {
backstack.push(NavTarget.Settings(SpaceSettingsFlowNode.NavTarget.RolesAndPermissions))
}
override fun navigateToChooseOwners() {
backstack.replace(NavTarget.ChangeOwners)
}
}
createNode<LeaveSpaceNode>(buildContext, listOf(callback))
}
@@ -177,6 +194,29 @@ class SpaceFlowNode(
}
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 ResetSaveAction : AddRoomToSpaceEvent
data object Dismiss : AddRoomToSpaceEvent
data class UpdateSearchVisibleRange(val range: IntRange) : AddRoomToSpaceEvent
}

View File

@@ -58,9 +58,6 @@ class AddRoomToSpacePresenter(
LaunchedEffect(searchQuery.text) {
dataSource.setSearchQuery(searchQuery.text.toString())
}
LaunchedEffect(isSearchActive) {
dataSource.setIsActive(isSearchActive)
}
val suggestions by dataSource.suggestions.collectAsState(initial = persistentListOf())
@@ -111,6 +108,9 @@ class AddRoomToSpacePresenter(
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.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally
import io.element.android.libraries.matrix.api.roomlist.updateVisibleRange
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
@@ -58,7 +57,6 @@ class AddRoomToSpaceSearchDataSource(
private val roomList = roomListService.createRoomList(
pageSize = PAGE_SIZE,
initialFilter = RoomListFilter.all(),
source = RoomList.Source.All,
coroutineScope = coroutineScope,
)
@@ -87,7 +85,7 @@ class AddRoomToSpaceSearchDataSource(
}
val roomInfoList: Flow<ImmutableList<SelectRoomInfo>> = combine(
roomList.filteredSummaries,
roomList.summaries,
spaceChildrenFlow,
addedRoomIdsFlow,
) { roomSummaries, childIds, addedIds ->
@@ -109,12 +107,8 @@ class AddRoomToSpaceSearchDataSource(
.toImmutableList()
}.flowOn(coroutineDispatchers.computation)
suspend fun setIsActive(isActive: Boolean) = coroutineScope {
if (isActive) {
roomList.loadAllIncrementally(this)
} else {
roomList.reset()
}
suspend fun updateVisibleRange(visibleRange: IntRange) {
roomList.updateVisibleRange(visibleRange)
}
suspend fun setSearchQuery(searchQuery: String) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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_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_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_empty_state_title">"Füge deinen ersten Chat hinzu"</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>
<plurals name="screen_space_remove_rooms_confirmation_title">

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<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>
<plurals name="screen_leave_space_submit">
<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_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_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>
<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_roles_and_permissions">"Rollid ja õigused"</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"?>
<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>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Quitter %1$d salon et lespace"</item>
@@ -7,9 +8,11 @@
</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_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_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_owner">"Transfert de propriété"</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_empty_state_title">"Ajoutez votre premier salon"</string>

View File

@@ -7,6 +7,7 @@
</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_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_title">"Kilép innen: %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"?>
<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>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Leave %1$d room and space"</item>
@@ -7,9 +8,11 @@
</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_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_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_owner">"Transfer ownership"</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_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.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.changeroommemberroles.test.FakeChangeRoomMemberRolesEntryPoint
import io.element.android.features.createroom.api.FakeCreateRoomEntryPoint
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.features.space.impl.di.FakeSpaceFlowGraph
@@ -22,6 +23,7 @@ import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -33,7 +35,7 @@ class DefaultSpaceEntryPointTest {
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder`() {
fun `test node builder`() = runTest {
val entryPoint = DefaultSpaceEntryPoint()
val nodeInputs = SpaceEntryPoint.Inputs(A_ROOM_ID)
val parentNode = TestParentNode.create { buildContext, plugins ->
@@ -46,6 +48,8 @@ class DefaultSpaceEntryPointTest {
room = FakeJoinedRoom(),
graphFactory = FakeSpaceFlowGraph.Factory,
createRoomEntryPoint = FakeCreateRoomEntryPoint(),
changeRoomMemberRolesEntryPoint = FakeChangeRoomMemberRolesEntryPoint(),
sessionCoroutineScope = backgroundScope,
)
}
val callback = object : SpaceEntryPoint.Callback {

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.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
@@ -116,12 +117,15 @@ class AddRoomToSpacePresenterTest {
@Test
fun `present - searchResults shows Results when rooms available`() = runTest {
val roomListService = FakeRoomListService()
val roomList = FakeDynamicRoomList()
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createAddRoomToSpacePresenter(roomListService = roomListService)
presenter.test {
awaitItem() // Initial state
// Post rooms to the service
roomListService.postAllRooms(
roomList.summaries.emit(
listOf(
aRoomSummary(
roomId = A_ROOM_ID,
@@ -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
fun `present - Dismiss after partial success calls reset`() = runTest {
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.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
@@ -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(

View File

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

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

View File

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

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

View File

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

View File

@@ -38,13 +38,11 @@ interface RoomListService {
/**
* Creates a room list that can be used to load more rooms and filter them dynamically.
* @param pageSize the number of rooms to load at once.
* @param initialFilter the initial filter to apply to the rooms.
* @param source the source of the rooms, either all rooms or invites.
* @param coroutineScope the coroutine scope to use for the room list operations.
*/
fun createRoomList(
pageSize: Int,
initialFilter: RoomListFilter,
source: RoomList.Source,
coroutineScope: CoroutineScope,
): DynamicRoomList
@@ -56,10 +54,10 @@ interface RoomListService {
suspend fun subscribeToVisibleRooms(roomIds: List<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].
*/
val allRooms: DynamicRoomList
val allRooms: RoomList
/**
* The sync indicator as a flow.

View File

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

View File

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

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

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.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter.Companion.all
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService
@@ -18,24 +19,16 @@ import io.element.android.services.analytics.api.finishLongRunningTransaction
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind
import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController
import org.matrix.rustcomponents.sdk.RoomListLoadingState
import org.matrix.rustcomponents.sdk.RoomListService
import kotlin.coroutines.CoroutineContext
import org.matrix.rustcomponents.sdk.RoomList as InnerRoomList
private val ROOM_LIST_RUST_FILTERS = listOf(
RoomListEntriesDynamicFilterKind.NonLeft,
RoomListEntriesDynamicFilterKind.DeduplicateVersions
)
internal class RoomListFactory(
private val innerRoomListService: RoomListService,
private val analyticsService: AnalyticsService,
@@ -49,18 +42,14 @@ internal class RoomListFactory(
pageSize: Int,
coroutineContext: CoroutineContext,
coroutineScope: CoroutineScope,
initialFilter: RoomListFilter = RoomListFilter.all(),
initialFilter: RoomListFilter = all(),
innerProvider: suspend () -> InnerRoomList
): DynamicRoomList {
val loadingStateFlow: MutableStateFlow<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 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 dynamicController: RoomListDynamicEntriesController? = null
val firstRoomsTransaction = analyticsService.startTransaction("Load first set of rooms", "innerRoomList.entriesFlow")
@@ -69,8 +58,10 @@ internal class RoomListFactory(
innerRoomList.let { innerRoomList ->
innerRoomList.entriesFlow(
pageSize = pageSize,
roomListDynamicEvents = dynamicEvents,
initialFilterKind = RoomListEntriesDynamicFilterKind.All(ROOM_LIST_RUST_FILTERS),
initialFilterKind = RoomListFilterMapper.toRustFilter(initialFilter),
onControllerCreated = { controller ->
dynamicController = controller
}
).onEach { update ->
if (!firstRoomsTransaction.isFinished()) {
analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.FirstRoomsDisplayed)
@@ -85,61 +76,20 @@ internal class RoomListFactory(
loadingStateFlow.value = it
}
.launchIn(this)
combine(
currentFilter,
summariesFlow
) { filter, summaries ->
summaries.filter(filter)
}.onEach {
filteredSummariesFlow.emit(it)
}.launchIn(this)
}
}.invokeOnCompletion {
innerRoomList?.destroy()
}
return RustDynamicRoomList(
summaries = summariesFlow,
filteredSummaries = filteredSummariesFlow,
loadingState = loadingStateFlow,
currentFilter = currentFilter,
loadedPages = loadedPages,
dynamicEvents = dynamicEvents,
processor = processor,
pageSize = pageSize,
dynamicController = { dynamicController }
)
}
}
private class RustDynamicRoomList(
override val summaries: MutableSharedFlow<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 {
return when (this) {
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.roomlist.DynamicRoomList
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally
import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
@@ -28,8 +26,6 @@ import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicator
import timber.log.Timber
import org.matrix.rustcomponents.sdk.RoomListService as InnerRustRoomListService
private const val DEFAULT_PAGE_SIZE = 20
internal class RustRoomListService(
private val innerRoomListService: InnerRustRoomListService,
private val sessionDispatcher: CoroutineDispatcher,
@@ -39,13 +35,11 @@ internal class RustRoomListService(
) : RoomListService {
override fun createRoomList(
pageSize: Int,
initialFilter: RoomListFilter,
source: RoomList.Source,
coroutineScope: CoroutineScope,
): DynamicRoomList {
return roomListFactory.createRoomList(
pageSize = pageSize,
initialFilter = initialFilter,
coroutineContext = sessionDispatcher,
coroutineScope = coroutineScope,
) {
@@ -59,18 +53,14 @@ internal class RustRoomListService(
roomSyncSubscriber.batchSubscribe(roomIds)
}
override val allRooms: DynamicRoomList = roomListFactory.createRoomList(
pageSize = DEFAULT_PAGE_SIZE,
override val allRooms: RoomList = roomListFactory.createRoomList(
pageSize = Int.MAX_VALUE,
coroutineContext = sessionDispatcher,
coroutineScope = sessionCoroutineScope,
) {
innerRoomListService.allRooms()
}
init {
allRooms.loadAllIncrementally(sessionCoroutineScope)
}
override val syncIndicator: StateFlow<RoomListService.SyncIndicator> =
innerRoomListService.syncIndicator()
.map { it.toSyncIndicator() }

View File

@@ -41,6 +41,7 @@ class RustLeaveSpaceHandle(
LeaveSpaceRoom(
spaceRoom = spaceRoomMapper.map(leaveSpaceRoom.spaceRoom),
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.RoomSummary
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.getAndUpdate
data class SimplePagedRoomList(
override val summaries: MutableStateFlow<List<RoomSummary>>,
override val loadingState: StateFlow<RoomList.LoadingState>,
override val currentFilter: MutableStateFlow<RoomListFilter>
class FakeDynamicRoomList(
override val summaries: MutableStateFlow<List<RoomSummary>> = MutableStateFlow(emptyList()),
override val loadingState: MutableStateFlow<RoomList.LoadingState> = MutableStateFlow(RoomList.LoadingState.NotLoaded),
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 {
override val pageSize: Int = Int.MAX_VALUE
override val loadedPages = MutableStateFlow(1)
override val filteredSummaries: SharedFlow<List<RoomSummary>> = summaries
override suspend fun loadMore() {
// No-op
loadedPages.getAndUpdate { it + 1 }
loadMoreLambda()
}
override suspend fun reset() {
loadedPages.emit(1)
resetLambda()
}
override suspend fun updateFilter(filter: RoomListFilter) {
currentFilter.emit(filter)
updateFilterLambda(filter)
}
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.roomlist.DynamicRoomList
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
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 {
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 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) {
roomListStateFlow.emit(state)
}
@@ -44,25 +34,14 @@ class FakeRoomListService(
override fun createRoomList(
pageSize: Int,
initialFilter: RoomListFilter,
source: RoomList.Source,
coroutineScope: CoroutineScope,
): DynamicRoomList {
return when (source) {
RoomList.Source.All -> allRooms
}
}
) = createRoomListLambda(pageSize)
override suspend fun subscribeToVisibleRooms(roomIds: List<RoomId>) {
subscribeToVisibleRoomsLambda(roomIds)
}
override val allRooms = SimplePagedRoomList(
allRoomSummariesFlow,
allRoomsLoadingStateFlow,
MutableStateFlow(RoomListFilter.all())
)
override val state: StateFlow<RoomListService.State> = roomListStateFlow
override val syncIndicator: StateFlow<RoomListService.SyncIndicator> = syncIndicatorStateFlow

View File

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

View File

@@ -16,4 +16,5 @@ sealed interface RoomSelectEvents {
// TODO remove to restore multi-selection
data object RemoveSelectedRoom : 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.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
@AssistedInject
class RoomSelectPresenter(
@@ -80,6 +81,9 @@ class RoomSelectPresenter(
}
RoomSelectEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf()
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.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally
import io.element.android.libraries.matrix.api.roomlist.updateVisibleRange
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import kotlinx.collections.immutable.ImmutableList
@@ -46,14 +46,11 @@ class RoomSelectSearchDataSource(
private val roomList = roomListService.createRoomList(
pageSize = PAGE_SIZE,
initialFilter = RoomListFilter.all(),
source = RoomList.Source.All,
coroutineScope = coroutineScope
).apply {
loadAllIncrementally(coroutineScope)
}
)
val roomInfoList: Flow<ImmutableList<SelectRoomInfo>> = roomList.filteredSummaries
val roomInfoList: Flow<ImmutableList<SelectRoomInfo>> = roomList.summaries
.map { roomSummaries ->
roomSummaries
.filter { it.info.currentUserMembership == CurrentUserMembership.JOINED }
@@ -63,6 +60,10 @@ class RoomSelectSearchDataSource(
}
.flowOn(coroutineDispatchers.computation)
suspend fun updateVisibleRange(visibleRange: IntRange) {
roomList.updateVisibleRange(visibleRange)
}
suspend fun setSearchQuery(searchQuery: String) = coroutineScope {
val filter = if (searchQuery.isBlank()) {
RoomListFilter.all()

View File

@@ -43,22 +43,23 @@ open class RoomSelectStateProvider : PreviewParameterProvider<RoomSelectState> {
)
}
private fun aRoomSelectState(
internal fun aRoomSelectState(
mode: RoomSelectMode = RoomSelectMode.Forward,
resultState: SearchBarResultState<ImmutableList<SelectRoomInfo>> = SearchBarResultState.Initial(),
searchQuery: String = "",
isSearchActive: Boolean = false,
selectedRooms: ImmutableList<SelectRoomInfo> = persistentListOf(),
eventSink: (RoomSelectEvents) -> Unit = {},
) = RoomSelectState(
mode = mode,
resultState = resultState,
searchQuery = TextFieldState(initialText = searchQuery),
isSearchActive = isSearchActive,
selectedRooms = selectedRooms,
eventSink = {}
eventSink = eventSink,
)
private fun aRoomSelectRoomList() = persistentListOf(
internal fun aRoomSelectRoomList() = persistentListOf(
aSelectRoomInfo(
roomId = RoomId("!room1:domain"),
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.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.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.TextButton
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.ui.components.SelectedRoom
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
@@ -100,6 +102,11 @@ fun RoomSelectView(
onBack = { onBackButton(state) }
)
val lazyListState = rememberLazyListState()
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
state.eventSink(RoomSelectEvents.UpdateVisibleRange(visibleRange))
}
Scaffold(
modifier = modifier,
topBar = {
@@ -138,7 +145,7 @@ fun RoomSelectView(
resultState = state.resultState,
showBackButton = false,
) { summaries ->
LazyColumn {
LazyColumn(state = lazyListState) {
item {
SelectedRoomsHelper(
// TODO state.isForwarding
@@ -170,7 +177,7 @@ fun RoomSelectView(
Spacer(modifier = Modifier.height(20.dp))
if (state.resultState is SearchBarResultState.Results) {
LazyColumn {
LazyColumn(state = lazyListState) {
items(state.resultState.results, key = { it.roomId.value }) { roomSummary ->
Column {
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.RoomListService
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode
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 kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -63,9 +67,12 @@ class RoomSelectPresenterTest {
@Test
fun `present - update query`() = runTest {
val roomSummary = aRoomSummary()
val roomListService = FakeRoomListService().apply {
postAllRooms(listOf(roomSummary))
}
val roomList = FakeDynamicRoomList(
summaries = MutableStateFlow(listOf(roomSummary))
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createRoomSelectPresenter(
roomListService = roomListService
)
@@ -81,12 +88,12 @@ class RoomSelectPresenterTest {
skipItems(1)
initialState.searchQuery.setTextAndPlaceCursorAtEnd("string not contained")
assertThat(
roomListService.allRooms.currentFilter.value
roomList.currentFilter.value
).isEqualTo(
RoomListFilter.NormalizedMatchRoomName("string not contained")
)
assertThat(awaitItem().searchQuery.text.toString()).isEqualTo("string not contained")
roomListService.postAllRooms(
roomList.summaries.emit(
emptyList()
)
assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResultsFound::class.java)
@@ -96,9 +103,12 @@ class RoomSelectPresenterTest {
@Test
fun `present - select and remove a room`() = runTest {
val roomSummary = aRoomSummary()
val roomListService = FakeRoomListService().apply {
postAllRooms(listOf(roomSummary))
}
val roomList = FakeDynamicRoomList(
summaries = MutableStateFlow(listOf(roomSummary))
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList }
)
val presenter = createRoomSelectPresenter(
roomListService = roomListService,
)
@@ -114,6 +124,35 @@ class RoomSelectPresenterTest {
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(

View File

@@ -93,6 +93,7 @@
<string name="action_enable">"Aktivieren"</string>
<string name="action_end_poll">"Umfrage beenden"</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_forgot_password">"Passwort vergessen?"</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_submitted">"Bericht eingereicht"</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_name">"Chat-Name"</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_settings">"Einstellungen"</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_space">"Gemeinsamer Space"</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_waiting">"Warten…"</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="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>

View File

@@ -56,6 +56,7 @@
<string name="a11y_your_avatar">"Sinu tunnuspilt"</string>
<string name="action_accept">"Nõustu"</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_back">"Tagasi"</string>
<string name="action_call">"Helista"</string>
@@ -75,6 +76,7 @@
<string name="action_copy_text">"Kopeeri tekst"</string>
<string name="action_create">"Loo"</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_account">"Eemalda konto kasutusest"</string>
<string name="action_decline">"Keeldu"</string>
@@ -91,6 +93,7 @@
<string name="action_enable">"Võta kasutusele"</string>
<string name="action_end_poll">"Lõpeta küsitlus"</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_forgot_password">"Kas unustasid salasõna?"</string>
<string name="action_forward">"Edasta"</string>
@@ -160,7 +163,7 @@
<string name="action_start_over">"Alusta uuesti"</string>
<string name="action_start_verification">"Alusta verifitseerimist"</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_translate">"Tõlgi"</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_copyright">"Autoriõigused"</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_left_room">"Lahkus jututoast"</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_recovery_key">"Taastevõti"</string>
<string name="common_refreshing">"Värskendame andmeid…"</string>
<string name="common_removing">"Eemaldan…"</string>
<plurals name="common_replies">
<item quantity="one">"%1$d vastus"</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_submitted">"Veateade on saadetud"</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_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">
<item quantity="one">"%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_seen_by">"Seda nägi(d)"</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_sending">"Saadame…"</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_settings">"Seadistused"</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_space">"Jagatud kogukond"</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_sticker">"Kleeps"</string>
<string name="common_success">"Õnnestus"</string>
<string name="common_suggested">"Soovitatud"</string>
<string name="common_suggestions">"Soovitused"</string>
<string name="common_syncing">"Sünkroniseerime"</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_waiting">"Ootame…"</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="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_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>
@@ -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_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_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_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>

View File

@@ -224,7 +224,7 @@ Raison : %1$s."</string>
<string name="common_everyone">"Tout le monde"</string>
<string name="common_failed">"Échec"</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_deleted">"Fichier supprimé"</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_settings">"Paramètres"</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_space">"Espace partagé"</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_waiting">"En attente…"</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="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>

View File

@@ -333,6 +333,7 @@ Ok: %1$s."</string>
<string name="common_server_url">"Kiszolgáló webcíme"</string>
<string name="common_settings">"Beállítások"</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_space">"Megosztott tér"</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_text">"Kopiera text"</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_account">"Inaktivera konto"</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_waiting">"Waiting…"</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="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>

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