Merge branch 'develop' into feature/fga/csam_preferences_server

This commit is contained in:
ganfra 2025-06-30 21:42:06 +02:00
commit 773fa1657a
623 changed files with 4661 additions and 2049 deletions

View file

@ -0,0 +1,35 @@
/*
* Copyright 2023, 2024 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.features.home.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultHomeEntryPoint @Inject constructor() : HomeEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): HomeEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : HomeEntryPoint.NodeBuilder {
override fun callback(callback: HomeEntryPoint.Callback): HomeEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<HomeFlowNode>(buildContext, plugins)
}
}
}
}

View file

@ -0,0 +1,10 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl
sealed interface HomeEvents

View file

@ -0,0 +1,166 @@
/*
* Copyright 2023, 2024 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.features.home.impl
import android.app.Activity
import android.os.Parcelable
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.home.impl.components.RoomListMenuAction
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView
import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint
import io.element.android.features.logout.api.direct.DirectLogoutView
import io.element.android.features.reportroom.api.ReportRoomEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
class HomeFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: HomePresenter,
private val inviteFriendsUseCase: InviteFriendsUseCase,
private val analyticsService: AnalyticsService,
private val acceptDeclineInviteView: AcceptDeclineInviteView,
private val directLogoutView: DirectLogoutView,
private val reportRoomEntryPoint: ReportRoomEntryPoint,
private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint,
) : BaseFlowNode<HomeFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
init {
lifecycle.subscribe(
onResume = {
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.Home))
}
)
}
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
@Parcelize
data class ReportRoom(val roomId: RoomId) : NavTarget
@Parcelize
data class DeclineInviteAndBlockUser(val inviteData: InviteData) : NavTarget
}
private fun onRoomClick(roomId: RoomId) {
plugins<HomeEntryPoint.Callback>().forEach { it.onRoomClick(roomId) }
}
private fun onOpenSettings() {
plugins<HomeEntryPoint.Callback>().forEach { it.onSettingsClick() }
}
private fun onCreateRoomClick() {
plugins<HomeEntryPoint.Callback>().forEach { it.onCreateRoomClick() }
}
private fun onSetUpRecoveryClick() {
plugins<HomeEntryPoint.Callback>().forEach { it.onSetUpRecoveryClick() }
}
private fun onSessionConfirmRecoveryKeyClick() {
plugins<HomeEntryPoint.Callback>().forEach { it.onSessionConfirmRecoveryKeyClick() }
}
private fun onRoomSettingsClick(roomId: RoomId) {
plugins<HomeEntryPoint.Callback>().forEach { it.onRoomSettingsClick(roomId) }
}
private fun onReportRoomClick(roomId: RoomId) {
backstack.push(NavTarget.ReportRoom(roomId))
}
private fun onDeclineInviteAndBlockUserClick(roomSummary: RoomListRoomSummary) {
backstack.push(NavTarget.DeclineInviteAndBlockUser(roomSummary.toInviteData()))
}
private fun onMenuActionClick(activity: Activity, roomListMenuAction: RoomListMenuAction) {
when (roomListMenuAction) {
RoomListMenuAction.InviteFriends -> {
inviteFriendsUseCase.execute(activity)
}
RoomListMenuAction.ReportBug -> {
plugins<HomeEntryPoint.Callback>().forEach { it.onReportBugClick() }
}
}
}
fun rootNode(buildContext: BuildContext): Node {
return node(buildContext) { modifier ->
val state = presenter.present()
val activity = requireNotNull(LocalActivity.current)
HomeView(
homeState = state,
onRoomClick = this::onRoomClick,
onSettingsClick = this::onOpenSettings,
onCreateRoomClick = this::onCreateRoomClick,
onSetUpRecoveryClick = this::onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = this::onSessionConfirmRecoveryKeyClick,
onRoomSettingsClick = this::onRoomSettingsClick,
onMenuActionClick = { onMenuActionClick(activity, it) },
onReportRoomClick = this::onReportRoomClick,
onDeclineInviteAndBlockUser = this::onDeclineInviteAndBlockUserClick,
modifier = modifier,
) {
acceptDeclineInviteView.Render(
state = state.roomListState.acceptDeclineInviteState,
onAcceptInviteSuccess = this::onRoomClick,
onDeclineInviteSuccess = { },
modifier = Modifier
)
}
directLogoutView.Render(state.directLogoutState)
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView()
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.ReportRoom -> reportRoomEntryPoint.createNode(this, buildContext, navTarget.roomId)
is NavTarget.DeclineInviteAndBlockUser -> declineInviteAndBlockUserEntryPoint.createNode(this, buildContext, navTarget.inviteData)
NavTarget.Root -> rootNode(buildContext)
}
}
}

View file

@ -0,0 +1,69 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.sync.SyncService
import javax.inject.Inject
class HomePresenter @Inject constructor(
private val client: MatrixClient,
private val syncService: SyncService,
private val snackbarDispatcher: SnackbarDispatcher,
private val indicatorService: IndicatorService,
private val roomListPresenter: Presenter<RoomListState>,
private val logoutPresenter: Presenter<DirectLogoutState>,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
) : Presenter<HomeState> {
@Composable
override fun present(): HomeState {
val matrixUser = client.userProfile.collectAsState()
val isOnline by syncService.isOnline.collectAsState()
val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() }
val roomListState = roomListPresenter.present()
LaunchedEffect(Unit) {
// Force a refresh of the profile
client.getUserProfile()
}
// Avatar indicator
val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator()
val directLogoutState = logoutPresenter.present()
fun handleEvents(event: HomeEvents) {
// TODO
}
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
return HomeState(
matrixUser = matrixUser.value,
showAvatarIndicator = showAvatarIndicator,
hasNetworkConnection = isOnline,
roomListState = roomListState,
snackbarMessage = snackbarMessage,
canReportBug = canReportBug,
directLogoutState = directLogoutState,
eventSink = ::handleEvents,
)
}
}

View file

@ -0,0 +1,28 @@
/*
* Copyright 2023, 2024 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.features.home.impl
import androidx.compose.runtime.Immutable
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.user.MatrixUser
@Immutable
data class HomeState(
val matrixUser: MatrixUser,
val showAvatarIndicator: Boolean,
val hasNetworkConnection: Boolean,
val roomListState: RoomListState,
val snackbarMessage: SnackbarMessage?,
val canReportBug: Boolean,
val directLogoutState: DirectLogoutState,
val eventSink: (HomeEvents) -> Unit,
) {
val displayActions = true
}

View file

@ -0,0 +1,50 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.roomlist.RoomListStateProvider
import io.element.android.features.home.impl.roomlist.aRoomListState
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
open class HomeStateProvider : PreviewParameterProvider<HomeState> {
override val values: Sequence<HomeState>
get() = sequenceOf(
aHomeState(),
aHomeState(hasNetworkConnection = false),
aHomeState(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)),
) + RoomListStateProvider().values.map {
aHomeState(roomListState = it)
}
}
internal fun aHomeState(
matrixUser: MatrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"),
showAvatarIndicator: Boolean = false,
hasNetworkConnection: Boolean = true,
snackbarMessage: SnackbarMessage? = null,
roomListState: RoomListState = aRoomListState(),
canReportBug: Boolean = true,
directLogoutState: DirectLogoutState = aDirectLogoutState(),
eventSink: (HomeEvents) -> Unit = {}
) = HomeState(
matrixUser = matrixUser,
showAvatarIndicator = showAvatarIndicator,
hasNetworkConnection = hasNetworkConnection,
snackbarMessage = snackbarMessage,
canReportBug = canReportBug,
directLogoutState = directLogoutState,
roomListState = roomListState,
eventSink = eventSink,
)

View file

@ -0,0 +1,209 @@
/*
* Copyright 2023, 2024 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.features.home.impl
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.components.RoomListContentView
import io.element.android.features.home.impl.components.RoomListMenuAction
import io.element.android.features.home.impl.components.RoomListTopBar
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.roomlist.RoomListContextMenu
import io.element.android.features.home.impl.roomlist.RoomListDeclineInviteMenu
import io.element.android.features.home.impl.roomlist.RoomListEvents
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.search.RoomListSearchView
import io.element.android.features.leaveroom.api.LeaveRoomView
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
import io.element.android.libraries.androidutils.throttler.FirstThrottler
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.RoomId
@Composable
fun HomeView(
homeState: HomeState,
onRoomClick: (RoomId) -> Unit,
onSettingsClick: () -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onCreateRoomClick: () -> Unit,
onRoomSettingsClick: (roomId: RoomId) -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
onReportRoomClick: (roomId: RoomId) -> Unit,
onDeclineInviteAndBlockUser: (roomSummary: RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier,
acceptDeclineInviteView: @Composable () -> Unit,
) {
val state: RoomListState = homeState.roomListState
val coroutineScope = rememberCoroutineScope()
val firstThrottler = remember { FirstThrottler(300, coroutineScope) }
ConnectivityIndicatorContainer(
modifier = modifier,
isOnline = homeState.hasNetworkConnection,
) { topPadding ->
Box {
if (state.contextMenu is RoomListState.ContextMenu.Shown) {
RoomListContextMenu(
contextMenu = state.contextMenu,
canReportRoom = state.canReportRoom,
eventSink = state.eventSink,
onRoomSettingsClick = onRoomSettingsClick,
onReportRoomClick = onReportRoomClick,
)
}
if (state.declineInviteMenu is RoomListState.DeclineInviteMenu.Shown) {
RoomListDeclineInviteMenu(
menu = state.declineInviteMenu,
canReportRoom = state.canReportRoom,
eventSink = state.eventSink,
onDeclineAndBlockClick = onDeclineInviteAndBlockUser,
)
}
LeaveRoomView(state = state.leaveRoomState)
HomeScaffold(
state = homeState,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) },
onOpenSettings = { if (firstThrottler.canHandle()) onSettingsClick() },
onCreateRoomClick = { if (firstThrottler.canHandle()) onCreateRoomClick() },
onMenuActionClick = onMenuActionClick,
modifier = Modifier.padding(top = topPadding),
)
// This overlaid view will only be visible when state.displaySearchResults is true
RoomListSearchView(
state = state.searchState,
eventSink = state.eventSink,
hideInvitesAvatars = state.hideInvitesAvatars,
onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) },
modifier = Modifier
.statusBarsPadding()
.padding(top = topPadding)
.fillMaxSize()
.background(ElementTheme.colors.bgCanvasDefault)
)
acceptDeclineInviteView()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HomeScaffold(
state: HomeState,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomId) -> Unit,
onOpenSettings: () -> Unit,
onCreateRoomClick: () -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
modifier: Modifier = Modifier,
) {
fun onRoomClick(room: RoomListRoomSummary) {
onRoomClick(room.roomId)
}
val appBarState = rememberTopAppBarState()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(appBarState)
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
val roomListState: RoomListState = state.roomListState
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
RoomListTopBar(
matrixUser = state.matrixUser,
showAvatarIndicator = state.showAvatarIndicator,
areSearchResultsDisplayed = roomListState.searchState.isSearchActive,
onToggleSearch = { roomListState.eventSink(RoomListEvents.ToggleSearchResults) },
onMenuActionClick = onMenuActionClick,
onOpenSettings = onOpenSettings,
scrollBehavior = scrollBehavior,
displayMenuItems = state.displayActions,
displayFilters = roomListState.displayFilters,
filtersState = roomListState.filtersState,
canReportBug = state.canReportBug,
)
},
content = { padding ->
RoomListContentView(
contentState = roomListState.contentState,
filtersState = roomListState.filtersState,
hideInvitesAvatars = roomListState.hideInvitesAvatars,
eventSink = roomListState.eventSink,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onRoomClick = ::onRoomClick,
onCreateRoomClick = onCreateRoomClick,
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
)
},
floatingActionButton = {
if (state.displayActions) {
FloatingActionButton(
containerColor = ElementTheme.colors.iconPrimary,
onClick = onCreateRoomClick
) {
Icon(
imageVector = CompoundIcons.Plus(),
contentDescription = stringResource(id = R.string.screen_roomlist_a11y_create_message),
tint = ElementTheme.colors.iconOnSolidPrimary,
)
}
}
},
snackbarHost = { SnackbarHost(snackbarHostState) },
)
}
internal fun RoomListRoomSummary.contentType() = displayType.ordinal
@PreviewsDayNight
@Composable
internal fun HomeViewPreview(@PreviewParameter(HomeStateProvider::class) state: HomeState) = ElementPreview {
HomeView(
homeState = state,
onRoomClick = {},
onSettingsClick = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onCreateRoomClick = {},
onRoomSettingsClick = {},
onReportRoomClick = {},
onMenuActionClick = {},
onDeclineInviteAndBlockUser = {},
acceptDeclineInviteView = {},
)
}

View file

@ -0,0 +1,17 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.components
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Common padding for RoomList banners.
*/
internal fun Modifier.roomListBannerPadding() = padding(horizontal = 16.dp, vertical = 8.dp)

View file

@ -0,0 +1,45 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.home.impl.R
import io.element.android.libraries.designsystem.components.Announcement
import io.element.android.libraries.designsystem.components.AnnouncementType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.push.api.battery.BatteryOptimizationEvents
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
@Composable
internal fun BatteryOptimizationBanner(
state: BatteryOptimizationState,
modifier: Modifier = Modifier,
) {
Announcement(
modifier = modifier.roomListBannerPadding(),
title = stringResource(R.string.banner_battery_optimization_title_android),
description = stringResource(R.string.banner_battery_optimization_content_android),
type = AnnouncementType.Actionable(
actionText = stringResource(R.string.banner_battery_optimization_submit_android),
onActionClick = { state.eventSink(BatteryOptimizationEvents.RequestDisableOptimizations) },
onDismissClick = { state.eventSink(BatteryOptimizationEvents.Dismiss) },
),
)
}
@PreviewsDayNight
@Composable
internal fun BatteryOptimizationBannerPreview() = ElementPreview {
BatteryOptimizationBanner(
state = aBatteryOptimizationState(),
)
}

View file

@ -0,0 +1,45 @@
/*
* Copyright 2023, 2024 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.features.home.impl.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.home.impl.R
import io.element.android.libraries.designsystem.components.Announcement
import io.element.android.libraries.designsystem.components.AnnouncementType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun ConfirmRecoveryKeyBanner(
onContinueClick: () -> Unit,
onDismissClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Announcement(
modifier = modifier.roomListBannerPadding(),
title = stringResource(R.string.confirm_recovery_key_banner_title),
description = stringResource(R.string.confirm_recovery_key_banner_message),
type = AnnouncementType.Actionable(
actionText = stringResource(CommonStrings.action_continue),
onActionClick = onContinueClick,
onDismissClick = onDismissClick,
),
)
}
@PreviewsDayNight
@Composable
internal fun ConfirmRecoveryKeyBannerPreview() = ElementPreview {
ConfirmRecoveryKeyBanner(
onContinueClick = {},
onDismissClick = {},
)
}

View file

@ -0,0 +1,46 @@
/*
* Copyright 2024 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.features.home.impl.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.home.impl.R
import io.element.android.libraries.designsystem.components.Announcement
import io.element.android.libraries.designsystem.components.AnnouncementType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun FullScreenIntentPermissionBanner(
state: FullScreenIntentPermissionsState,
modifier: Modifier = Modifier
) {
Announcement(
title = stringResource(R.string.full_screen_intent_banner_title),
description = stringResource(R.string.full_screen_intent_banner_message),
type = AnnouncementType.Actionable(
actionText = stringResource(CommonStrings.action_continue),
onDismissClick = { state.eventSink(FullScreenIntentPermissionsEvents.Dismiss) },
onActionClick = { state.eventSink(FullScreenIntentPermissionsEvents.OpenSettings) },
),
modifier = modifier.roomListBannerPadding(),
)
}
@PreviewsDayNight
@Composable
internal fun FullScreenIntentPermissionBannerPreview() {
ElementPreview {
FullScreenIntentPermissionBanner(aFullScreenIntentPermissionsState())
}
}

View file

@ -0,0 +1,328 @@
/*
* Copyright 2024 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.features.home.impl.components
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
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
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
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.home.impl.R
import io.element.android.features.home.impl.contentType
import io.element.android.features.home.impl.filters.RoomListFilter
import io.element.android.features.home.impl.filters.RoomListFiltersEmptyStateResources
import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.filters.aRoomListFiltersState
import io.element.android.features.home.impl.filters.selection.FilterSelectionState
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.model.RoomSummaryDisplayType
import io.element.android.features.home.impl.roomlist.RoomListContentState
import io.element.android.features.home.impl.roomlist.RoomListContentStateProvider
import io.element.android.features.home.impl.roomlist.RoomListEvents
import io.element.android.features.home.impl.roomlist.SecurityBannerState
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@Composable
fun RoomListContentView(
contentState: RoomListContentState,
filtersState: RoomListFiltersState,
hideInvitesAvatars: Boolean,
eventSink: (RoomListEvents) -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit,
onCreateRoomClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
when (contentState) {
is RoomListContentState.Skeleton -> {
SkeletonView(
count = contentState.count,
)
}
is RoomListContentState.Empty -> {
EmptyView(
state = contentState,
eventSink = eventSink,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onCreateRoomClick = onCreateRoomClick,
)
}
is RoomListContentState.Rooms -> {
RoomsView(
state = contentState,
hideInvitesAvatars = hideInvitesAvatars,
filtersState = filtersState,
eventSink = eventSink,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onRoomClick = onRoomClick,
)
}
}
}
}
@Composable
private fun SkeletonView(count: Int, modifier: Modifier = Modifier) {
LazyColumn(modifier = modifier) {
repeat(count) { index ->
item {
RoomSummaryPlaceholderRow()
if (index != count - 1) {
HorizontalDivider()
}
}
}
}
}
@Composable
private fun EmptyView(
state: RoomListContentState.Empty,
eventSink: (RoomListEvents) -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onCreateRoomClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier.fillMaxSize()) {
EmptyScaffold(
title = R.string.screen_roomlist_empty_title,
subtitle = R.string.screen_roomlist_empty_message,
action = {
Button(
text = stringResource(CommonStrings.action_start_chat),
leadingIcon = IconSource.Vector(CompoundIcons.Compose()),
onClick = onCreateRoomClick,
)
},
modifier = Modifier.align(Alignment.Center),
)
Box {
when (state.securityBannerState) {
SecurityBannerState.SetUpRecovery -> {
SetUpRecoveryKeyBanner(
onContinueClick = onSetUpRecoveryClick,
onDismissClick = { eventSink(RoomListEvents.DismissBanner) },
)
}
SecurityBannerState.RecoveryKeyConfirmation -> {
ConfirmRecoveryKeyBanner(
onContinueClick = onConfirmRecoveryKeyClick,
onDismissClick = { eventSink(RoomListEvents.DismissBanner) },
)
}
SecurityBannerState.None -> Unit
}
}
}
}
@Composable
private fun RoomsView(
state: RoomListContentState.Rooms,
hideInvitesAvatars: Boolean,
filtersState: RoomListFiltersState,
eventSink: (RoomListEvents) -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier,
) {
if (state.summaries.isEmpty() && filtersState.hasAnyFilterSelected) {
EmptyViewForFilterStates(
selectedFilters = filtersState.selectedFilters(),
modifier = modifier.fillMaxSize()
)
} else {
RoomsViewList(
state = state,
hideInvitesAvatars = hideInvitesAvatars,
eventSink = eventSink,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onRoomClick = onRoomClick,
modifier = modifier.fillMaxSize(),
)
}
}
@Composable
private fun RoomsViewList(
state: RoomListContentState.Rooms,
hideInvitesAvatars: Boolean,
eventSink: (RoomListEvents) -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier,
) {
val lazyListState = rememberLazyListState()
val visibleRange by remember {
derivedStateOf {
val layoutInfo = lazyListState.layoutInfo
val firstItemIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: 0
val size = layoutInfo.visibleItemsInfo.size
firstItemIndex until firstItemIndex + size
}
}
val updatedEventSink by rememberUpdatedState(newValue = eventSink)
LaunchedEffect(visibleRange) {
updatedEventSink(RoomListEvents.UpdateVisibleRange(visibleRange))
}
LazyColumn(
state = lazyListState,
modifier = modifier,
// FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80
contentPadding = PaddingValues(bottom = 80.dp)
) {
when (state.securityBannerState) {
SecurityBannerState.SetUpRecovery -> {
item {
SetUpRecoveryKeyBanner(
onContinueClick = onSetUpRecoveryClick,
onDismissClick = { updatedEventSink(RoomListEvents.DismissBanner) },
)
}
}
SecurityBannerState.RecoveryKeyConfirmation -> {
item {
ConfirmRecoveryKeyBanner(
onContinueClick = onConfirmRecoveryKeyClick,
onDismissClick = { updatedEventSink(RoomListEvents.DismissBanner) },
)
}
}
SecurityBannerState.None -> if (state.fullScreenIntentPermissionsState.shouldDisplayBanner) {
item {
FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState)
}
} else if (state.batteryOptimizationState.shouldDisplayBanner) {
item {
BatteryOptimizationBanner(state = state.batteryOptimizationState)
}
}
}
// Note: do not use a key for the LazyColumn, or the scroll will not behave as expected if a room
// is moved to the top of the list.
itemsIndexed(
items = state.summaries,
contentType = { _, room -> room.contentType() },
) { index, room ->
RoomSummaryRow(
room = room,
hideInviteAvatars = hideInvitesAvatars,
isInviteSeen = room.displayType == RoomSummaryDisplayType.INVITE &&
state.seenRoomInvites.contains(room.roomId),
onClick = onRoomClick,
eventSink = eventSink,
)
if (index != state.summaries.lastIndex) {
HorizontalDivider()
}
}
}
}
@Composable
private fun EmptyViewForFilterStates(
selectedFilters: ImmutableList<RoomListFilter>,
modifier: Modifier = Modifier,
) {
val emptyStateResources = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters) ?: return
EmptyScaffold(
title = emptyStateResources.title,
subtitle = emptyStateResources.subtitle,
modifier = modifier,
)
}
@Composable
private fun EmptyScaffold(
@StringRes title: Int,
@StringRes subtitle: Int,
modifier: Modifier = Modifier,
action: @Composable (ColumnScope.() -> Unit)? = null,
) {
Column(
modifier = modifier.padding(horizontal = 60.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(title),
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(subtitle),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(32.dp))
action?.invoke(this)
}
}
@PreviewsDayNight
@Composable
internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStateProvider::class) state: RoomListContentState) = ElementPreview {
RoomListContentView(
contentState = state,
filtersState = aRoomListFiltersState(
filterSelectionStates = RoomListFilter.entries.map {
FilterSelectionState(
filter = it,
isSelected = true
)
}
),
hideInvitesAvatars = false,
eventSink = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onRoomClick = {},
onCreateRoomClick = {},
)
}

View file

@ -0,0 +1,13 @@
/*
* Copyright 2023, 2024 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.features.home.impl.components
enum class RoomListMenuAction {
InviteFriends,
ReportBug
}

View file

@ -0,0 +1,349 @@
/*
* Copyright 2023, 2024 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.features.home.impl.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import io.element.android.appconfig.RoomListConfig
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.filters.RoomListFiltersView
import io.element.android.features.home.impl.filters.aRoomListFiltersState
import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.avatarBloom
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.applyScaleDown
import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
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.MediumTopAppBar
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
private val avatarBloomSize = 430.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomListTopBar(
matrixUser: MatrixUser,
showAvatarIndicator: Boolean,
areSearchResultsDisplayed: Boolean,
onToggleSearch: () -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
onOpenSettings: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
displayMenuItems: Boolean,
displayFilters: Boolean,
filtersState: RoomListFiltersState,
canReportBug: Boolean,
modifier: Modifier = Modifier,
) {
DefaultRoomListTopBar(
matrixUser = matrixUser,
showAvatarIndicator = showAvatarIndicator,
areSearchResultsDisplayed = areSearchResultsDisplayed,
onOpenSettings = onOpenSettings,
onSearchClick = onToggleSearch,
onMenuActionClick = onMenuActionClick,
scrollBehavior = scrollBehavior,
displayMenuItems = displayMenuItems,
displayFilters = displayFilters,
filtersState = filtersState,
canReportBug = canReportBug,
modifier = modifier,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DefaultRoomListTopBar(
matrixUser: MatrixUser,
showAvatarIndicator: Boolean,
areSearchResultsDisplayed: Boolean,
scrollBehavior: TopAppBarScrollBehavior,
onOpenSettings: () -> Unit,
onSearchClick: () -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
displayMenuItems: Boolean,
displayFilters: Boolean,
filtersState: RoomListFiltersState,
canReportBug: Boolean,
modifier: Modifier = Modifier,
) {
// We need this to manually clip the top app bar in preview mode
val previewAppBarHeight = if (LocalInspectionMode.current) {
112.dp.roundToPx()
} else {
null
}
val collapsedFraction = scrollBehavior.state.collapsedFraction
var appBarHeight by remember {
mutableIntStateOf(previewAppBarHeight ?: 0)
}
val avatarData by remember(matrixUser) {
derivedStateOf {
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
}
}
val statusBarPadding = with(LocalDensity.current) { WindowInsets.statusBars.getTop(this).toDp() }
Box(modifier = modifier) {
val collapsedTitleTextStyle = ElementTheme.typography.aliasScreenTitle
val expandedTitleTextStyle = ElementTheme.typography.fontHeadingLgBold.copy(
// Due to a limitation of MediumTopAppBar, and to avoid the text to be truncated,
// ensure that the font size will never be bigger than 28.dp.
fontSize = 28.dp.applyScaleDown().toSp()
)
MaterialTheme(
colorScheme = ElementTheme.materialColors,
shapes = MaterialTheme.shapes,
typography = ElementTheme.materialTypography.copy(
headlineSmall = expandedTitleTextStyle,
titleLarge = collapsedTitleTextStyle
),
) {
Column(
modifier = Modifier
.onSizeChanged {
appBarHeight = it.height
}
.avatarBloom(
avatarData = avatarData,
background = if (ElementTheme.isLightTheme) {
// Workaround to display a very subtle bloom for avatars with very soft colors
Color(0xFFF9F9F9)
} else {
ElementTheme.colors.bgCanvasDefault
},
blurSize = DpSize(avatarBloomSize, avatarBloomSize),
offset = DpOffset(24.dp, 24.dp + statusBarPadding),
clipToSize = if (appBarHeight > 0) {
DpSize(
avatarBloomSize,
appBarHeight.toDp()
)
} else {
DpSize.Unspecified
},
bottomSoftEdgeColor = ElementTheme.colors.bgCanvasDefault,
bottomSoftEdgeAlpha = if (displayFilters) {
1f
} else {
1f - collapsedFraction
},
alpha = if (areSearchResultsDisplayed) 0f else 1f,
)
.statusBarsPadding(),
) {
MediumTopAppBar(
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
),
title = {
Text(text = stringResource(id = R.string.screen_roomlist_main_space_title))
},
navigationIcon = {
NavigationIcon(
avatarData = avatarData,
showAvatarIndicator = showAvatarIndicator,
onClick = onOpenSettings,
)
},
actions = {
if (displayMenuItems) {
IconButton(
onClick = onSearchClick,
) {
Icon(
imageVector = CompoundIcons.Search(),
contentDescription = stringResource(CommonStrings.action_search),
)
}
if (RoomListConfig.HAS_DROP_DOWN_MENU) {
var showMenu by remember { mutableStateOf(false) }
IconButton(
onClick = { showMenu = !showMenu }
) {
Icon(
imageVector = CompoundIcons.OverflowVertical(),
contentDescription = null,
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
if (RoomListConfig.SHOW_INVITE_MENU_ITEM) {
DropdownMenuItem(
onClick = {
showMenu = false
onMenuActionClick(RoomListMenuAction.InviteFriends)
},
text = { Text(stringResource(id = CommonStrings.action_invite)) },
leadingIcon = {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
tint = ElementTheme.colors.iconSecondary,
contentDescription = null,
)
}
)
}
if (RoomListConfig.SHOW_REPORT_PROBLEM_MENU_ITEM && canReportBug) {
DropdownMenuItem(
onClick = {
showMenu = false
onMenuActionClick(RoomListMenuAction.ReportBug)
},
text = { Text(stringResource(id = CommonStrings.common_report_a_problem)) },
leadingIcon = {
Icon(
imageVector = CompoundIcons.ChatProblem(),
tint = ElementTheme.colors.iconSecondary,
contentDescription = null,
)
}
)
}
}
}
}
},
scrollBehavior = scrollBehavior,
windowInsets = WindowInsets(0.dp),
)
if (displayFilters) {
RoomListFiltersView(
state = filtersState,
modifier = Modifier.padding(bottom = 16.dp)
)
}
}
}
HorizontalDivider(
modifier = Modifier
.fillMaxWidth()
.alpha(collapsedFraction)
.align(Alignment.BottomCenter),
color = ElementTheme.materialColors.outlineVariant,
)
}
}
@Composable
private fun NavigationIcon(
avatarData: AvatarData,
showAvatarIndicator: Boolean,
onClick: () -> Unit,
) {
IconButton(
modifier = Modifier.testTag(TestTags.homeScreenSettings),
onClick = onClick,
) {
Box {
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
contentDescription = stringResource(CommonStrings.common_settings),
)
if (showAvatarIndicator) {
RedIndicatorAtom(
modifier = Modifier.align(Alignment.TopEnd)
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
internal fun DefaultRoomListTopBarPreview() = ElementPreview {
DefaultRoomListTopBar(
matrixUser = MatrixUser(UserId("@id:domain"), "Alice"),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
onOpenSettings = {},
onSearchClick = {},
displayMenuItems = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
canReportBug = true,
onMenuActionClick = {},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
internal fun DefaultRoomListTopBarWithIndicatorPreview() = ElementPreview {
DefaultRoomListTopBar(
matrixUser = MatrixUser(UserId("@id:domain"), "Alice"),
showAvatarIndicator = true,
areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
onOpenSettings = {},
onSearchClick = {},
displayMenuItems = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
canReportBug = true,
onMenuActionClick = {},
)
}

View file

@ -0,0 +1,85 @@
/*
* Copyright 2023, 2024 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.features.home.impl.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.placeholderBackground
/**
* https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=6547%3A147623
*/
@Composable
internal fun RoomSummaryPlaceholderRow(
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(minHeight)
.padding(horizontal = 16.dp),
) {
Box(
modifier = Modifier
.size(AvatarSize.RoomListItem.dp)
.align(Alignment.CenterVertically)
.background(color = ElementTheme.colors.placeholderBackground, shape = CircleShape)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, top = 19.dp, end = 4.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(22.dp),
verticalAlignment = Alignment.CenterVertically
) {
PlaceholderAtom(width = 40.dp, height = 7.dp)
Spacer(modifier = Modifier.width(7.dp))
PlaceholderAtom(width = 45.dp, height = 7.dp)
Spacer(modifier = Modifier.weight(1f))
PlaceholderAtom(width = 22.dp, height = 4.dp)
}
Row(
modifier = Modifier
.height(25.dp),
verticalAlignment = Alignment.CenterVertically
) {
PlaceholderAtom(width = 70.dp, height = 6.dp)
Spacer(modifier = Modifier.width(6.dp))
PlaceholderAtom(width = 70.dp, height = 6.dp)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun RoomSummaryPlaceholderRowPreview() = ElementPreview {
RoomSummaryPlaceholderRow()
}

View file

@ -0,0 +1,407 @@
/*
* Copyright 2023, 2024 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.features.home.impl.components
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.model.RoomListRoomSummaryProvider
import io.element.android.features.home.impl.model.RoomSummaryDisplayType
import io.element.android.features.home.impl.roomlist.RoomListEvents
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.roomListRoomMessage
import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate
import io.element.android.libraries.designsystem.theme.roomListRoomName
import io.element.android.libraries.designsystem.theme.unreadIndicator
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.ui.components.InviteSenderView
import io.element.android.libraries.matrix.ui.model.InviteSender
import io.element.android.libraries.ui.strings.CommonStrings
import timber.log.Timber
internal val minHeight = 84.dp
@Composable
internal fun RoomSummaryRow(
room: RoomListRoomSummary,
hideInviteAvatars: Boolean,
isInviteSeen: Boolean,
onClick: (RoomListRoomSummary) -> Unit,
eventSink: (RoomListEvents) -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
when (room.displayType) {
RoomSummaryDisplayType.PLACEHOLDER -> {
RoomSummaryPlaceholderRow()
}
RoomSummaryDisplayType.INVITE -> {
RoomSummaryScaffoldRow(
room = room,
hideAvatarImage = hideInviteAvatars,
onClick = onClick,
onLongClick = {
Timber.d("Long click on invite room")
},
) {
InviteNameAndIndicatorRow(name = room.name, isInviteSeen = isInviteSeen)
InviteSubtitle(isDm = room.isDm, inviteSender = room.inviteSender)
if (!room.isDm && room.inviteSender != null) {
Spacer(modifier = Modifier.height(4.dp))
InviteSenderView(
modifier = Modifier.fillMaxWidth(),
inviteSender = room.inviteSender,
hideAvatarImage = hideInviteAvatars
)
}
Spacer(modifier = Modifier.height(12.dp))
InviteButtonsRow(
onAcceptClick = {
eventSink(RoomListEvents.AcceptInvite(room))
},
onDeclineClick = {
eventSink(RoomListEvents.ShowDeclineInviteMenu(room))
}
)
}
}
RoomSummaryDisplayType.ROOM -> {
RoomSummaryScaffoldRow(
room = room,
onClick = onClick,
onLongClick = {
eventSink(RoomListEvents.ShowContextMenu(room))
},
) {
NameAndTimestampRow(
name = room.name,
timestamp = room.timestamp,
isHighlighted = room.isHighlighted
)
MessagePreviewAndIndicatorRow(room = room)
}
}
RoomSummaryDisplayType.KNOCKED -> {
RoomSummaryScaffoldRow(
room = room,
onClick = onClick,
onLongClick = {
Timber.d("Long click on knocked room")
},
) {
NameAndTimestampRow(
name = room.name,
timestamp = null,
isHighlighted = room.isHighlighted
)
if (room.canonicalAlias != null) {
Text(
text = room.canonicalAlias.value,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
Spacer(modifier = Modifier.height(4.dp))
}
Text(
text = stringResource(id = R.string.screen_roomlist_knock_event_sent_description),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
}
}
}
@Composable
private fun RoomSummaryScaffoldRow(
room: RoomListRoomSummary,
onClick: (RoomListRoomSummary) -> Unit,
onLongClick: (RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier,
hideAvatarImage: Boolean = false,
content: @Composable ColumnScope.() -> Unit
) {
val clickModifier = Modifier.combinedClickable(
onClick = { onClick(room) },
onLongClick = { onLongClick(room) },
indication = ripple(),
interactionSource = remember { MutableInteractionSource() }
)
Row(
modifier = modifier
.fillMaxWidth()
.heightIn(min = minHeight)
.then(clickModifier)
.padding(horizontal = 16.dp, vertical = 11.dp)
.height(IntrinsicSize.Min),
) {
Avatar(
avatarData = room.avatarData,
avatarType = AvatarType.Room(
heroes = room.heroes,
isTombstoned = room.isTombstoned,
),
hideImage = hideAvatarImage,
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.fillMaxWidth(),
content = content,
)
}
}
@Composable
private fun NameAndTimestampRow(
name: String?,
timestamp: String?,
isHighlighted: Boolean,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(16.dp)
) {
// Name
Text(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgMedium,
text = name ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { name == null },
color = ElementTheme.roomListRoomName(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Timestamp
Text(
text = timestamp ?: "",
style = ElementTheme.typography.fontBodySmMedium,
color = if (isHighlighted) {
ElementTheme.colors.unreadIndicator
} else {
ElementTheme.roomListRoomMessageDate()
},
)
}
}
@Composable
private fun InviteSubtitle(
isDm: Boolean,
inviteSender: InviteSender?,
modifier: Modifier = Modifier
) {
val subtitle = if (isDm) {
inviteSender?.userId?.value
} else {
null
}
if (subtitle != null) {
Text(
text = subtitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.roomListRoomMessage(),
modifier = modifier,
)
}
}
@Composable
private fun MessagePreviewAndIndicatorRow(
room: RoomListRoomSummary,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(28.dp)
) {
val messagePreview = if (room.isTombstoned) {
stringResource(R.string.screen_roomlist_tombstoned_room_description)
} else {
room.lastMessage.orEmpty()
}
val annotatedMessagePreview = messagePreview as? AnnotatedString ?: AnnotatedString(text = messagePreview.toString())
Text(
modifier = Modifier.weight(1f),
text = annotatedMessagePreview,
color = ElementTheme.roomListRoomMessage(),
style = ElementTheme.typography.fontBodyMdRegular,
minLines = 2,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
// Call and unread
Row(
modifier = Modifier.height(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val tint = if (room.isHighlighted) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary
if (room.hasRoomCall) {
OnGoingCallIcon(
color = tint,
)
}
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
NotificationOffIndicatorAtom()
} else if (room.numberOfUnreadMentions > 0) {
MentionIndicatorAtom()
}
if (room.hasNewContent) {
UnreadIndicatorAtom(
color = tint
)
}
}
}
}
@Composable
private fun InviteNameAndIndicatorRow(
name: String?,
isInviteSeen: Boolean,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgMedium,
text = name ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { name == null },
color = ElementTheme.roomListRoomName(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!isInviteSeen) {
UnreadIndicatorAtom(
color = ElementTheme.colors.unreadIndicator
)
}
}
}
@Composable
private fun InviteButtonsRow(
onAcceptClick: () -> Unit,
onDeclineClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
horizontalArrangement = spacedBy(12.dp)
) {
OutlinedButton(
text = stringResource(CommonStrings.action_decline),
onClick = onDeclineClick,
size = ButtonSize.MediumLowPadding,
modifier = Modifier.weight(1f),
)
Button(
text = stringResource(CommonStrings.action_accept),
onClick = onAcceptClick,
size = ButtonSize.MediumLowPadding,
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun OnGoingCallIcon(
color: Color,
) {
Icon(
modifier = Modifier.size(16.dp),
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = null,
tint = color,
)
}
@Composable
private fun NotificationOffIndicatorAtom() {
Icon(
modifier = Modifier.size(16.dp),
contentDescription = null,
imageVector = CompoundIcons.NotificationsOffSolid(),
tint = ElementTheme.colors.iconQuaternary,
)
}
@Composable
private fun MentionIndicatorAtom() {
Icon(
modifier = Modifier.size(16.dp),
contentDescription = null,
imageVector = CompoundIcons.Mention(),
tint = ElementTheme.colors.unreadIndicator,
)
}
@PreviewsDayNight
@Composable
internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryProvider::class) data: RoomListRoomSummary) = ElementPreview {
RoomSummaryRow(
room = data,
hideInviteAvatars = false,
// Set isInviteSeen to true for the preview when the room has name "Bob"
isInviteSeen = data.name == "Bob",
onClick = {},
eventSink = {},
)
}

View file

@ -0,0 +1,44 @@
/*
* Copyright 2024 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.features.home.impl.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.home.impl.R
import io.element.android.libraries.designsystem.components.Announcement
import io.element.android.libraries.designsystem.components.AnnouncementType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@Composable
internal fun SetUpRecoveryKeyBanner(
onContinueClick: () -> Unit,
onDismissClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Announcement(
modifier = modifier.roomListBannerPadding(),
title = stringResource(R.string.banner_set_up_recovery_title),
description = stringResource(R.string.banner_set_up_recovery_content),
type = AnnouncementType.Actionable(
actionText = stringResource(R.string.banner_set_up_recovery_submit),
onActionClick = onContinueClick,
onDismissClick = onDismissClick,
),
)
}
@PreviewsDayNight
@Composable
internal fun SetUpRecoveryKeyBannerPreview() = ElementPreview {
SetUpRecoveryKeyBanner(
onContinueClick = {},
onDismissClick = {},
)
}

View file

@ -0,0 +1,127 @@
/*
* Copyright 2023, 2024 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.features.home.impl.datasource
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.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.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
class RoomListDataSource @Inject constructor(
private val roomListService: RoomListService,
private val roomListRoomSummaryFactory: RoomListRoomSummaryFactory,
private val coroutineDispatchers: CoroutineDispatchers,
private val notificationSettingsService: NotificationSettingsService,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val dateTimeObserver: DateTimeObserver,
) {
init {
observeNotificationSettings()
observeDateTimeChanges()
}
private val _allRooms = MutableSharedFlow<ImmutableList<RoomListRoomSummary>>(replay = 1)
private val lock = Mutex()
private val diffCache = MutableListDiffCache<RoomListRoomSummary>()
private val diffCacheUpdater = DiffCacheUpdater<RoomSummary, RoomListRoomSummary>(diffCache = diffCache, detectMoves = true) { old, new ->
old?.roomId == new?.roomId
}
val allRooms: Flow<ImmutableList<RoomListRoomSummary>> = _allRooms
val loadingState = roomListService.allRooms.loadingState
fun launchIn(coroutineScope: CoroutineScope) {
roomListService
.allRooms
.filteredSummaries
.onEach { roomSummaries ->
replaceWith(roomSummaries)
}
.launchIn(coroutineScope)
}
suspend fun subscribeToVisibleRooms(roomIds: List<RoomId>) {
roomListService.subscribeToVisibleRooms(roomIds)
}
@OptIn(FlowPreview::class)
private fun observeNotificationSettings() {
notificationSettingsService.notificationSettingsChangeFlow
.debounce(0.5.seconds)
.onEach {
roomListService.allRooms.rebuildSummaries()
}
.launchIn(sessionCoroutineScope)
}
private fun observeDateTimeChanges() {
dateTimeObserver.changes
.onEach { event ->
when (event) {
is DateTimeObserver.Event.TimeZoneChanged -> rebuildAllRoomSummaries()
is DateTimeObserver.Event.DateChanged -> rebuildAllRoomSummaries()
}
}
.launchIn(sessionCoroutineScope)
}
private suspend fun replaceWith(roomSummaries: List<RoomSummary>) = withContext(coroutineDispatchers.computation) {
lock.withLock {
diffCacheUpdater.updateWith(roomSummaries)
buildAndEmitAllRooms(roomSummaries)
}
}
private suspend fun buildAndEmitAllRooms(roomSummaries: List<RoomSummary>, useCache: Boolean = true) {
val roomListRoomSummaries = diffCache.indices().mapNotNull { index ->
if (useCache) {
diffCache.get(index) ?: buildAndCacheItem(roomSummaries, index)
} else {
buildAndCacheItem(roomSummaries, index)
}
}
_allRooms.emit(roomListRoomSummaries.toImmutableList())
}
private fun buildAndCacheItem(roomSummaries: List<RoomSummary>, index: Int): RoomListRoomSummary? {
val roomListSummary = roomSummaries.getOrNull(index)?.let { roomListRoomSummaryFactory.create(it) }
diffCache[index] = roomListSummary
return roomListSummary
}
private suspend fun rebuildAllRoomSummaries() {
lock.withLock {
roomListService.allRooms.summaries.replayCache.firstOrNull()?.let { roomSummaries ->
buildAndEmitAllRooms(roomSummaries, useCache = false)
}
}
}
}

View file

@ -0,0 +1,73 @@
/*
* Copyright 2024 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.features.home.impl.datasource
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.model.RoomSummaryDisplayType
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
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.RoomSummary
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.toInviteSender
import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject
class RoomListRoomSummaryFactory @Inject constructor(
private val dateFormatter: DateFormatter,
private val roomLastMessageFormatter: RoomLastMessageFormatter,
) {
fun create(roomSummary: RoomSummary): RoomListRoomSummary {
val roomInfo = roomSummary.info
val avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomListItem)
return RoomListRoomSummary(
id = roomSummary.roomId.value,
roomId = roomSummary.roomId,
name = roomInfo.name,
numberOfUnreadMessages = roomInfo.numUnreadMessages,
numberOfUnreadMentions = roomInfo.numUnreadMentions,
numberOfUnreadNotifications = roomInfo.numUnreadNotifications,
isMarkedUnread = roomInfo.isMarkedUnread,
timestamp = dateFormatter.format(
timestamp = roomSummary.lastMessageTimestamp,
mode = DateFormatterMode.TimeOrDate,
useRelative = true,
),
lastMessage = roomSummary.lastMessage?.let { message ->
roomLastMessageFormatter.format(message.event, roomInfo.isDm)
}.orEmpty(),
avatarData = avatarData,
userDefinedNotificationMode = roomInfo.userDefinedNotificationMode,
hasRoomCall = roomInfo.hasRoomCall,
isDirect = roomInfo.isDirect,
isFavorite = roomInfo.isFavorite,
inviteSender = roomInfo.inviter?.toInviteSender(),
isDm = roomInfo.isDm,
canonicalAlias = roomInfo.canonicalAlias,
displayType = when (roomInfo.currentUserMembership) {
CurrentUserMembership.INVITED -> {
RoomSummaryDisplayType.INVITE
}
CurrentUserMembership.KNOCKED -> {
RoomSummaryDisplayType.KNOCKED
}
else -> {
RoomSummaryDisplayType.ROOM
}
},
heroes = roomInfo.heroes.map { user ->
user.getAvatarData(size = AvatarSize.RoomListItem)
}.toImmutableList(),
isTombstoned = roomInfo.successorRoom != null,
)
}
}

View file

@ -0,0 +1,33 @@
/*
* Copyright 2024 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.features.home.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import io.element.android.features.home.impl.filters.RoomListFiltersPresenter
import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.roomlist.RoomListPresenter
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.search.RoomListSearchPresenter
import io.element.android.features.home.impl.search.RoomListSearchState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.SessionScope
@ContributesTo(SessionScope::class)
@Module
interface RoomListModule {
@Binds
fun bindRoomListPresenter(presenter: RoomListPresenter): Presenter<RoomListState>
@Binds
fun bindSearchPresenter(presenter: RoomListSearchPresenter): Presenter<RoomListSearchState>
@Binds
fun bindFiltersPresenter(presenter: RoomListFiltersPresenter): Presenter<RoomListFiltersState>
}

View file

@ -0,0 +1,31 @@
/*
* Copyright 2024 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.features.home.impl.filters
import io.element.android.features.home.impl.R
/**
* Enum class representing the different filters that can be applied to the room list.
* Order is important, it'll be used as initial order in the UI.
*/
enum class RoomListFilter(val stringResource: Int) {
Unread(R.string.screen_roomlist_filter_unreads),
People(R.string.screen_roomlist_filter_people),
Rooms(R.string.screen_roomlist_filter_rooms),
Favourites(R.string.screen_roomlist_filter_favourites),
Invites(R.string.screen_roomlist_filter_invites);
val incompatibleFilters: Set<RoomListFilter>
get() = when (this) {
Rooms -> setOf(People, Invites)
People -> setOf(Rooms, Invites)
Unread -> setOf(Invites)
Favourites -> setOf(Invites)
Invites -> setOf(Rooms, People, Unread, Favourites)
}
}

View file

@ -0,0 +1,60 @@
/*
* Copyright 2024 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.features.home.impl.filters
import androidx.annotation.StringRes
import io.element.android.features.home.impl.R
/**
* Holds the resources for the empty state when filters are applied to the room list.
* @param title the title of the empty state
* @param subtitle the subtitle of the empty state
*/
data class RoomListFiltersEmptyStateResources(
@StringRes val title: Int,
@StringRes val subtitle: Int,
) {
companion object {
/**
* Create a [RoomListFiltersEmptyStateResources] from a list of selected filters.
*/
fun fromSelectedFilters(selectedFilters: List<RoomListFilter>): RoomListFiltersEmptyStateResources? {
return when {
selectedFilters.isEmpty() -> null
selectedFilters.size == 1 -> {
when (selectedFilters.first()) {
RoomListFilter.Unread -> RoomListFiltersEmptyStateResources(
title = R.string.screen_roomlist_filter_unreads_empty_state_title,
subtitle = R.string.screen_roomlist_filter_mixed_empty_state_subtitle
)
RoomListFilter.People -> RoomListFiltersEmptyStateResources(
title = R.string.screen_roomlist_filter_people_empty_state_title,
subtitle = R.string.screen_roomlist_filter_mixed_empty_state_subtitle
)
RoomListFilter.Rooms -> RoomListFiltersEmptyStateResources(
title = R.string.screen_roomlist_filter_rooms_empty_state_title,
subtitle = R.string.screen_roomlist_filter_mixed_empty_state_subtitle
)
RoomListFilter.Favourites -> RoomListFiltersEmptyStateResources(
title = R.string.screen_roomlist_filter_favourites_empty_state_title,
subtitle = R.string.screen_roomlist_filter_favourites_empty_state_subtitle
)
RoomListFilter.Invites -> RoomListFiltersEmptyStateResources(
title = R.string.screen_roomlist_filter_invites_empty_state_title,
subtitle = R.string.screen_roomlist_filter_mixed_empty_state_subtitle
)
}
}
else -> RoomListFiltersEmptyStateResources(
title = R.string.screen_roomlist_filter_mixed_empty_state_title,
subtitle = R.string.screen_roomlist_filter_mixed_empty_state_subtitle
)
}
}
}
}

View file

@ -0,0 +1,13 @@
/*
* Copyright 2024 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.features.home.impl.filters
sealed interface RoomListFiltersEvents {
data class ToggleFilter(val filter: RoomListFilter) : RoomListFiltersEvents
data object ClearSelectedFilters : RoomListFiltersEvents
}

View file

@ -0,0 +1,68 @@
/*
* Copyright 2024 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.features.home.impl.filters
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
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.toPersistentList
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter
class RoomListFiltersPresenter @Inject constructor(
private val roomListService: RoomListService,
private val filterSelectionStrategy: FilterSelectionStrategy,
) : Presenter<RoomListFiltersState> {
private val initialFilters = filterSelectionStrategy.filterSelectionStates.value.toPersistentList()
@Composable
override fun present(): RoomListFiltersState {
fun handleEvents(event: RoomListFiltersEvents) {
when (event) {
RoomListFiltersEvents.ClearSelectedFilters -> {
filterSelectionStrategy.clear()
}
is RoomListFiltersEvents.ToggleFilter -> {
filterSelectionStrategy.toggle(event.filter)
}
}
}
val filters by produceState(initialValue = initialFilters) {
filterSelectionStrategy.filterSelectionStates
.map { filters ->
value = filters.toPersistentList()
filters.mapNotNull { filterState ->
if (!filterState.isSelected) {
return@mapNotNull null
}
when (filterState.filter) {
RoomListFilter.Rooms -> MatrixRoomListFilter.Category.Group
RoomListFilter.People -> MatrixRoomListFilter.Category.People
RoomListFilter.Unread -> MatrixRoomListFilter.Unread
RoomListFilter.Favourites -> MatrixRoomListFilter.Favorite
RoomListFilter.Invites -> MatrixRoomListFilter.Invite
}
}
}
.collect { filters ->
val result = MatrixRoomListFilter.All(filters)
roomListService.allRooms.updateFilter(result)
}
}
return RoomListFiltersState(
filterSelectionStates = filters,
eventSink = ::handleEvents
)
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright 2024 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.features.home.impl.filters
import io.element.android.features.home.impl.filters.selection.FilterSelectionState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
data class RoomListFiltersState(
val filterSelectionStates: ImmutableList<FilterSelectionState>,
val eventSink: (RoomListFiltersEvents) -> Unit,
) {
val hasAnyFilterSelected = filterSelectionStates.any { it.isSelected }
fun selectedFilters(): ImmutableList<RoomListFilter> {
return filterSelectionStates
.filter { it.isSelected }
.map { it.filter }
.toPersistentList()
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright 2024 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.features.home.impl.filters
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.home.impl.filters.selection.FilterSelectionState
import kotlinx.collections.immutable.toImmutableList
class RoomListFiltersStateProvider : PreviewParameterProvider<RoomListFiltersState> {
override val values: Sequence<RoomListFiltersState>
get() = sequenceOf(
aRoomListFiltersState(),
aRoomListFiltersState(
filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) }
),
)
}
fun aRoomListFiltersState(
filterSelectionStates: List<FilterSelectionState> = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = false) },
eventSink: (RoomListFiltersEvents) -> Unit = {},
) = RoomListFiltersState(
filterSelectionStates = filterSelectionStates.toImmutableList(),
eventSink = eventSink,
)

View file

@ -0,0 +1,197 @@
/*
* Copyright 2024 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.features.home.impl.filters
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.R
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
@Composable
fun RoomListFiltersView(
state: RoomListFiltersState,
modifier: Modifier = Modifier
) {
fun onClearFiltersClick() {
state.eventSink(RoomListFiltersEvents.ClearSelectedFilters)
}
fun onToggleFilter(filter: RoomListFilter) {
state.eventSink(RoomListFiltersEvents.ToggleFilter(filter))
}
var scrollToStart by remember { mutableIntStateOf(0) }
val lazyListState = rememberLazyListState()
LaunchedEffect(scrollToStart) {
// Scroll until the first item start to be displayed
// Since all items have different size, there is no way to compute the amount of
// pixel to scroll to go directly to the start of the row.
// But IRL it should only happen for one item.
while (lazyListState.firstVisibleItemIndex > 0) {
lazyListState.animateScrollBy(
value = -(lazyListState.firstVisibleItemScrollOffset + 1f),
animationSpec = spring(
stiffness = Spring.StiffnessMediumLow,
)
)
}
// Then scroll to the start of the list, a bit faster, to fully reveal the first
// item, which can be the close button to reset filter, or the first item
// if the user has scroll a bit before clicking on the close button.
lazyListState.animateScrollBy(
value = -lazyListState.firstVisibleItemScrollOffset.toFloat(),
animationSpec = spring(
stiffness = Spring.StiffnessMedium,
)
)
}
val previousFilters = remember { mutableStateOf(listOf<RoomListFilter>()) }
LazyRow(
contentPadding = PaddingValues(start = 8.dp, end = 16.dp),
modifier = modifier.fillMaxWidth(),
state = lazyListState,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
item("clear_filters") {
if (state.hasAnyFilterSelected) {
RoomListClearFiltersButton(
modifier = Modifier
.padding(start = 8.dp)
.testTag(TestTags.homeScreenClearFilters),
onClick = {
previousFilters.value = state.selectedFilters()
onClearFiltersClick()
// When clearing filter, we want to ensure that the list
// of filters is scrolled to the start.
scrollToStart++
}
)
}
}
state.filterSelectionStates.forEachIndexed { i, filterWithSelection ->
item(filterWithSelection.filter) {
val zIndex = (if (previousFilters.value.contains(filterWithSelection.filter)) state.filterSelectionStates.size else 0) - i.toFloat()
RoomListFilterView(
modifier = Modifier
.animateItem()
.zIndex(zIndex),
roomListFilter = filterWithSelection.filter,
selected = filterWithSelection.isSelected,
onClick = {
previousFilters.value = state.selectedFilters()
onToggleFilter(it)
// When selecting a filter, we want to scroll to the start of the list
if (filterWithSelection.isSelected.not()) {
scrollToStart++
}
},
)
}
}
}
}
@Composable
private fun RoomListClearFiltersButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.clip(CircleShape)
.background(ElementTheme.colors.bgActionPrimaryRest)
.clickable(onClick = onClick)
) {
Icon(
modifier = Modifier.align(Alignment.Center),
imageVector = CompoundIcons.Close(),
tint = ElementTheme.colors.iconOnSolidPrimary,
contentDescription = stringResource(id = R.string.screen_roomlist_clear_filters),
)
}
}
@Composable
private fun RoomListFilterView(
roomListFilter: RoomListFilter,
selected: Boolean,
onClick: (RoomListFilter) -> Unit,
modifier: Modifier = Modifier
) {
val background = animateColorAsState(
targetValue = if (selected) ElementTheme.colors.bgActionPrimaryRest else ElementTheme.colors.bgCanvasDefault,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
label = "chip background colour",
)
val textColour = animateColorAsState(
targetValue = if (selected) ElementTheme.colors.textOnSolidPrimary else ElementTheme.colors.textPrimary,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
label = "chip text colour",
)
FilterChip(
selected = selected,
onClick = { onClick(roomListFilter) },
modifier = modifier.height(36.dp),
shape = CircleShape,
colors = FilterChipDefaults.filterChipColors(
containerColor = background.value,
selectedContainerColor = background.value,
labelColor = textColour.value,
selectedLabelColor = textColour.value
),
label = {
Text(text = stringResource(id = roomListFilter.stringResource))
}
)
}
@PreviewsDayNight
@Composable
internal fun RoomListFiltersViewPreview(@PreviewParameter(RoomListFiltersStateProvider::class) state: RoomListFiltersState) = ElementPreview {
RoomListFiltersView(
state = state,
)
}

View file

@ -0,0 +1,57 @@
/*
* Copyright 2024 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.features.home.impl.filters.selection
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.home.impl.filters.RoomListFilter
import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultFilterSelectionStrategy @Inject constructor() : FilterSelectionStrategy {
private val selectedFilters = LinkedHashSet<RoomListFilter>()
override val filterSelectionStates = MutableStateFlow(buildFilters())
override fun select(filter: RoomListFilter) {
selectedFilters.add(filter)
filterSelectionStates.value = buildFilters()
}
override fun deselect(filter: RoomListFilter) {
selectedFilters.remove(filter)
filterSelectionStates.value = buildFilters()
}
override fun isSelected(filter: RoomListFilter): Boolean {
return selectedFilters.contains(filter)
}
override fun clear() {
selectedFilters.clear()
filterSelectionStates.value = buildFilters()
}
private fun buildFilters(): Set<FilterSelectionState> {
val selectedFilterStates = selectedFilters.map {
FilterSelectionState(
filter = it,
isSelected = true
)
}
val unselectedFilters = RoomListFilter.entries - selectedFilters - selectedFilters.flatMap { it.incompatibleFilters }.toSet()
val unselectedFilterStates = unselectedFilters.map {
FilterSelectionState(
filter = it,
isSelected = false
)
}
return (selectedFilterStates + unselectedFilterStates).toSet()
}
}

View file

@ -0,0 +1,15 @@
/*
* Copyright 2024 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.features.home.impl.filters.selection
import io.element.android.features.home.impl.filters.RoomListFilter
data class FilterSelectionState(
val filter: RoomListFilter,
val isSelected: Boolean,
)

View file

@ -0,0 +1,28 @@
/*
* Copyright 2024 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.features.home.impl.filters.selection
import io.element.android.features.home.impl.filters.RoomListFilter
import kotlinx.coroutines.flow.StateFlow
interface FilterSelectionStrategy {
val filterSelectionStates: StateFlow<Set<FilterSelectionState>>
fun select(filter: RoomListFilter)
fun deselect(filter: RoomListFilter)
fun isSelected(filter: RoomListFilter): Boolean
fun clear()
fun toggle(filter: RoomListFilter) {
if (isSelected(filter)) {
deselect(filter)
} else {
select(filter)
}
}
}

View file

@ -0,0 +1,56 @@
/*
* Copyright 2022-2024 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.features.home.impl.model
import androidx.compose.runtime.Immutable
import io.element.android.features.invite.api.InviteData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.ui.model.InviteSender
import kotlinx.collections.immutable.ImmutableList
@Immutable
data class RoomListRoomSummary(
val id: String,
val displayType: RoomSummaryDisplayType,
val roomId: RoomId,
val name: String?,
val canonicalAlias: RoomAlias?,
val numberOfUnreadMessages: Long,
val numberOfUnreadMentions: Long,
val numberOfUnreadNotifications: Long,
val isMarkedUnread: Boolean,
val timestamp: String?,
val lastMessage: CharSequence?,
val avatarData: AvatarData,
val userDefinedNotificationMode: RoomNotificationMode?,
val hasRoomCall: Boolean,
val isDirect: Boolean,
val isDm: Boolean,
val isFavorite: Boolean,
val inviteSender: InviteSender?,
val isTombstoned: Boolean,
val heroes: ImmutableList<AvatarData>,
) {
val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE &&
(numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) ||
isMarkedUnread
val hasNewContent = numberOfUnreadMessages > 0 ||
numberOfUnreadMentions > 0 ||
numberOfUnreadNotifications > 0 ||
isMarkedUnread
fun toInviteData() = InviteData(
roomId = roomId,
roomName = name ?: roomId.value,
isDm = isDm,
)
}

View file

@ -0,0 +1,175 @@
/*
* Copyright 2023, 2024 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.features.home.impl.model
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.ui.model.InviteSender
import kotlinx.collections.immutable.toImmutableList
open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSummary> {
override val values: Sequence<RoomListRoomSummary>
get() = sequenceOf(
listOf(
aRoomListRoomSummary(displayType = RoomSummaryDisplayType.PLACEHOLDER),
aRoomListRoomSummary(),
aRoomListRoomSummary(name = null),
aRoomListRoomSummary(lastMessage = null),
aRoomListRoomSummary(
name = "A very long room name that should be truncated",
lastMessage = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt" +
" ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea com" +
"modo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.",
timestamp = "yesterday",
numberOfUnreadMessages = 1,
),
),
listOf(false, true).map { hasCall ->
listOf(
RoomNotificationMode.ALL_MESSAGES,
RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
RoomNotificationMode.MUTE,
).map { roomNotificationMode ->
listOf(
aRoomListRoomSummary(
name = roomNotificationMode.name,
lastMessage = "No activity" + if (hasCall) ", call" else "",
notificationMode = roomNotificationMode,
numberOfUnreadMessages = 0,
numberOfUnreadMentions = 0,
hasRoomCall = hasCall,
),
aRoomListRoomSummary(
name = roomNotificationMode.name,
lastMessage = "New messages" + if (hasCall) ", call" else "",
notificationMode = roomNotificationMode,
numberOfUnreadMessages = 1,
numberOfUnreadMentions = 0,
hasRoomCall = hasCall,
),
aRoomListRoomSummary(
name = roomNotificationMode.name,
lastMessage = "New messages, mentions" + if (hasCall) ", call" else "",
notificationMode = roomNotificationMode,
numberOfUnreadMessages = 1,
numberOfUnreadMentions = 1,
hasRoomCall = hasCall,
),
aRoomListRoomSummary(
name = roomNotificationMode.name,
lastMessage = "New mentions" + if (hasCall) ", call" else "",
notificationMode = roomNotificationMode,
numberOfUnreadMessages = 0,
numberOfUnreadMentions = 1,
hasRoomCall = hasCall,
),
)
}.flatten()
}.flatten(),
listOf(
aRoomListRoomSummary(
displayType = RoomSummaryDisplayType.INVITE,
inviteSender = anInviteSender(
userId = UserId("@alice:matrix.org"),
displayName = "Alice",
),
canonicalAlias = RoomAlias("#alias:matrix.org"),
),
aRoomListRoomSummary(
name = "Bob",
displayType = RoomSummaryDisplayType.INVITE,
inviteSender = anInviteSender(
userId = UserId("@bob:matrix.org"),
displayName = "Bob",
),
isDm = true,
),
aRoomListRoomSummary(
name = null,
displayType = RoomSummaryDisplayType.INVITE,
inviteSender = anInviteSender(
userId = UserId("@bob:matrix.org"),
displayName = "Bob",
),
),
aRoomListRoomSummary(
name = "A knocked room",
displayType = RoomSummaryDisplayType.KNOCKED,
),
aRoomListRoomSummary(
name = "A knocked room with alias",
canonicalAlias = RoomAlias("#knockable:matrix.org"),
displayType = RoomSummaryDisplayType.KNOCKED,
),
aRoomListRoomSummary(
name = "A tombstoned room",
displayType = RoomSummaryDisplayType.ROOM,
isTombstoned = true,
)
),
).flatten()
}
internal fun anInviteSender(
userId: UserId = UserId("@bob:domain"),
displayName: String = "Bob",
avatarData: AvatarData = AvatarData(userId.value, displayName, size = AvatarSize.InviteSender),
) = InviteSender(
userId = userId,
displayName = displayName,
avatarData = avatarData,
membershipChangeReason = null,
)
internal fun aRoomListRoomSummary(
id: String = "!roomId:domain",
name: String? = "Room name",
numberOfUnreadMessages: Long = 0,
numberOfUnreadMentions: Long = 0,
numberOfUnreadNotifications: Long = 0,
isMarkedUnread: Boolean = false,
lastMessage: String? = "Last message",
timestamp: String? = lastMessage?.let { "88:88" },
notificationMode: RoomNotificationMode? = null,
hasRoomCall: Boolean = false,
avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem),
isDirect: Boolean = false,
isDm: Boolean = false,
isFavorite: Boolean = false,
inviteSender: InviteSender? = null,
displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM,
canonicalAlias: RoomAlias? = null,
heroes: List<AvatarData> = emptyList(),
isTombstoned: Boolean = false,
) = RoomListRoomSummary(
id = id,
roomId = RoomId(id),
name = name,
numberOfUnreadMessages = numberOfUnreadMessages,
numberOfUnreadMentions = numberOfUnreadMentions,
numberOfUnreadNotifications = numberOfUnreadNotifications,
isMarkedUnread = isMarkedUnread,
timestamp = timestamp,
lastMessage = lastMessage,
avatarData = avatarData,
userDefinedNotificationMode = notificationMode,
hasRoomCall = hasRoomCall,
isDirect = isDirect,
isDm = isDm,
isFavorite = isFavorite,
inviteSender = inviteSender,
displayType = displayType,
canonicalAlias = canonicalAlias,
heroes = heroes.toImmutableList(),
isTombstoned = isTombstoned,
)

View file

@ -0,0 +1,18 @@
/*
* Copyright 2024 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.features.home.impl.model
/**
* Represents the type of display for a room list item.
*/
enum class RoomSummaryDisplayType {
PLACEHOLDER,
ROOM,
INVITE,
KNOCKED,
}

View file

@ -0,0 +1,52 @@
/*
* Copyright 2023, 2024 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.features.home.impl.roomlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentSet
open class RoomListContentStateProvider : PreviewParameterProvider<RoomListContentState> {
override val values: Sequence<RoomListContentState>
get() = sequenceOf(
aRoomsContentState(),
aRoomsContentState(summaries = persistentListOf()),
aSkeletonContentState(),
anEmptyContentState(),
anEmptyContentState(securityBannerState = SecurityBannerState.SetUpRecovery),
)
}
internal fun aRoomsContentState(
securityBannerState: SecurityBannerState = SecurityBannerState.None,
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
batteryOptimizationState: BatteryOptimizationState = aBatteryOptimizationState(),
seenRoomInvites: Set<RoomId> = emptySet(),
) = RoomListContentState.Rooms(
securityBannerState = securityBannerState,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
batteryOptimizationState = batteryOptimizationState,
summaries = summaries,
seenRoomInvites = seenRoomInvites.toPersistentSet(),
)
internal fun aSkeletonContentState() = RoomListContentState.Skeleton(16)
internal fun anEmptyContentState(
securityBannerState: SecurityBannerState = SecurityBannerState.None,
) = RoomListContentState.Empty(
securityBannerState = securityBannerState,
)

View file

@ -0,0 +1,231 @@
/*
* Copyright 2023, 2024 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.features.home.impl.roomlist
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.R
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomListContextMenu(
contextMenu: RoomListState.ContextMenu.Shown,
canReportRoom: Boolean,
eventSink: (RoomListEvents.ContextMenuEvents) -> Unit,
onRoomSettingsClick: (roomId: RoomId) -> Unit,
onReportRoomClick: (roomId: RoomId) -> Unit
) {
ModalBottomSheet(
onDismissRequest = { eventSink(RoomListEvents.HideContextMenu) },
) {
RoomListModalBottomSheetContent(
contextMenu = contextMenu,
canReportRoom = canReportRoom,
onRoomMarkReadClick = {
eventSink(RoomListEvents.HideContextMenu)
eventSink(RoomListEvents.MarkAsRead(contextMenu.roomId))
},
onRoomMarkUnreadClick = {
eventSink(RoomListEvents.HideContextMenu)
eventSink(RoomListEvents.MarkAsUnread(contextMenu.roomId))
},
onRoomSettingsClick = {
eventSink(RoomListEvents.HideContextMenu)
onRoomSettingsClick(contextMenu.roomId)
},
onLeaveRoomClick = {
eventSink(RoomListEvents.HideContextMenu)
eventSink(RoomListEvents.LeaveRoom(contextMenu.roomId))
},
onFavoriteChange = { isFavorite ->
eventSink(RoomListEvents.SetRoomIsFavorite(contextMenu.roomId, isFavorite))
},
onClearCacheRoomClick = {
eventSink(RoomListEvents.HideContextMenu)
eventSink(RoomListEvents.ClearCacheOfRoom(contextMenu.roomId))
},
onReportRoomClick = {
eventSink(RoomListEvents.HideContextMenu)
onReportRoomClick(contextMenu.roomId)
},
)
}
}
@Composable
private fun RoomListModalBottomSheetContent(
contextMenu: RoomListState.ContextMenu.Shown,
canReportRoom: Boolean,
onRoomSettingsClick: () -> Unit,
onLeaveRoomClick: () -> Unit,
onFavoriteChange: (isFavorite: Boolean) -> Unit,
onRoomMarkReadClick: () -> Unit,
onRoomMarkUnreadClick: () -> Unit,
onClearCacheRoomClick: () -> Unit,
onReportRoomClick: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
ListItem(
headlineContent = {
Text(
text = contextMenu.roomName ?: stringResource(id = CommonStrings.common_no_room_name),
style = ElementTheme.typography.fontBodyLgMedium,
fontStyle = FontStyle.Italic.takeIf { contextMenu.roomName == null }
)
}
)
if (contextMenu.markAsUnreadFeatureFlagEnabled) {
if (contextMenu.hasNewContent) {
ListItem(
headlineContent = {
Text(
text = stringResource(id = R.string.screen_roomlist_mark_as_read),
style = MaterialTheme.typography.bodyLarge,
)
},
onClick = onRoomMarkReadClick,
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(CompoundIcons.MarkAsRead())
),
style = ListItemStyle.Primary,
)
} else {
ListItem(
headlineContent = {
Text(
text = stringResource(id = R.string.screen_roomlist_mark_as_unread),
style = MaterialTheme.typography.bodyLarge,
)
},
onClick = onRoomMarkUnreadClick,
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(CompoundIcons.MarkAsUnread())
),
style = ListItemStyle.Primary,
)
}
}
ListItem(
headlineContent = {
Text(
text = stringResource(id = CommonStrings.common_favourite),
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(
CompoundIcons.Favourite(),
)
),
trailingContent = ListItemContent.Switch(
checked = contextMenu.isFavorite,
),
onClick = {
onFavoriteChange(!contextMenu.isFavorite)
},
style = ListItemStyle.Primary,
)
ListItem(
headlineContent = {
Text(
text = stringResource(id = CommonStrings.common_settings),
style = MaterialTheme.typography.bodyLarge,
)
},
modifier = Modifier.clickable { onRoomSettingsClick() },
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(
CompoundIcons.Settings(),
)
),
style = ListItemStyle.Primary,
)
if (canReportRoom) {
ListItem(
headlineContent = {
Text(text = stringResource(CommonStrings.action_report_room))
},
modifier = Modifier.clickable { onReportRoomClick() },
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(
CompoundIcons.ChatProblem(),
contentDescription = stringResource(CommonStrings.action_report_room),
)
),
style = ListItemStyle.Destructive,
)
}
ListItem(
headlineContent = {
Text(text = stringResource(CommonStrings.action_leave_room))
},
modifier = Modifier.clickable { onLeaveRoomClick() },
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(
CompoundIcons.Leave(),
)
),
style = ListItemStyle.Destructive,
)
if (contextMenu.displayClearRoomCacheAction) {
ListItem(
headlineContent = {
Text(text = "Clear cache for this room")
},
modifier = Modifier.clickable { onClearCacheRoomClick() },
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(CompoundIcons.Delete())
),
style = ListItemStyle.Primary,
)
}
}
}
// TODO This component should be seen in [RoomListView] @Preview but it doesn't show up.
// see: https://issuetracker.google.com/issues/283843380
// Remove this preview when the issue is fixed.
@PreviewsDayNight
@Composable
internal fun RoomListModalBottomSheetContentPreview(
@PreviewParameter(RoomListStateContextMenuShownProvider::class) contextMenu: RoomListState.ContextMenu.Shown
) = ElementPreview {
RoomListModalBottomSheetContent(
contextMenu = contextMenu,
canReportRoom = true,
onRoomMarkReadClick = {},
onRoomMarkUnreadClick = {},
onRoomSettingsClick = {},
onLeaveRoomClick = {},
onFavoriteChange = {},
onClearCacheRoomClick = {},
onReportRoomClick = {},
)
}

View file

@ -0,0 +1,126 @@
/*
* Copyright 2023, 2024 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.features.home.impl.roomlist
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.model.RoomListRoomSummary
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.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomListDeclineInviteMenu(
menu: RoomListState.DeclineInviteMenu.Shown,
canReportRoom: Boolean,
onDeclineAndBlockClick: (RoomListRoomSummary) -> Unit,
eventSink: (RoomListEvents) -> Unit,
) {
ModalBottomSheet(
onDismissRequest = { eventSink(RoomListEvents.HideDeclineInviteMenu) },
) {
RoomListDeclineInviteMenuContent(
roomName = menu.roomSummary.name ?: menu.roomSummary.roomId.value,
onDeclineClick = {
eventSink(RoomListEvents.HideDeclineInviteMenu)
eventSink(RoomListEvents.DeclineInvite(menu.roomSummary, false))
},
onDeclineAndBlockClick = {
eventSink(RoomListEvents.HideDeclineInviteMenu)
if (canReportRoom) {
onDeclineAndBlockClick(menu.roomSummary)
} else {
eventSink(RoomListEvents.DeclineInvite(menu.roomSummary, true))
}
},
onCancelClick = {
eventSink(RoomListEvents.HideDeclineInviteMenu)
}
)
}
}
@Composable
private fun RoomListDeclineInviteMenuContent(
roomName: String,
onDeclineClick: () -> Unit,
onDeclineAndBlockClick: () -> Unit,
onCancelClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(all = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.screen_invites_decline_chat_title),
style = ElementTheme.typography.fontHeadingSmMedium,
color = ElementTheme.colors.textPrimary,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.screen_invites_decline_chat_message, roomName),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(Modifier.height(22.dp))
Button(
text = stringResource(CommonStrings.action_decline),
modifier = Modifier.fillMaxWidth(),
onClick = onDeclineClick,
)
Spacer(Modifier.height(16.dp))
OutlinedButton(
text = stringResource(CommonStrings.action_decline_and_block),
modifier = Modifier.fillMaxWidth(),
destructive = true,
onClick = onDeclineAndBlockClick
)
Spacer(Modifier.height(16.dp))
TextButton(
text = stringResource(CommonStrings.action_cancel),
modifier = Modifier.fillMaxWidth(),
onClick = onCancelClick
)
}
}
// TODO This component should be seen in [RoomListView] @Preview but it doesn't show up.
// see: https://issuetracker.google.com/issues/283843380
// Remove this preview when the issue is fixed.
@PreviewsDayNight
@Composable
internal fun RoomListDeclineInviteMenuContentPreview() = ElementPreview {
RoomListDeclineInviteMenuContent(
roomName = "Room name",
onCancelClick = {},
onDeclineClick = {},
onDeclineAndBlockClick = {},
)
}

View file

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

View file

@ -0,0 +1,330 @@
/*
* Copyright 2023, 2024 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.features.home.impl.roomlist
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.home.impl.datasource.RoomListDataSource
import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.search.RoomListSearchEvents
import io.element.android.features.home.impl.search.RoomListSearchState
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.AcceptInvite
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.DeclineInvite
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomEvent.ShowConfirmation
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toPersistentSet
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
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val EXTENDED_RANGE_SIZE = 40
private const val SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS = 300L
class RoomListPresenter @Inject constructor(
private val client: MatrixClient,
private val leaveRoomPresenter: Presenter<LeaveRoomState>,
private val roomListDataSource: RoomListDataSource,
private val featureFlagService: FeatureFlagService,
private val filtersPresenter: Presenter<RoomListFiltersState>,
private val searchPresenter: Presenter<RoomListSearchState>,
private val sessionPreferencesStore: SessionPreferencesStore,
private val analyticsService: AnalyticsService,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
private val fullScreenIntentPermissionsPresenter: Presenter<FullScreenIntentPermissionsState>,
private val batteryOptimizationPresenter: Presenter<BatteryOptimizationState>,
private val notificationCleaner: NotificationCleaner,
private val appPreferencesStore: AppPreferencesStore,
private val seenInvitesStore: SeenInvitesStore,
) : Presenter<RoomListState> {
private val encryptionService: EncryptionService = client.encryptionService()
@Composable
override fun present(): RoomListState {
val coroutineScope = rememberCoroutineScope()
val leaveRoomState = leaveRoomPresenter.present()
val filtersState = filtersPresenter.present()
val searchState = searchPresenter.present()
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
LaunchedEffect(Unit) {
roomListDataSource.launchIn(this)
}
var securityBannerDismissed by rememberSaveable { mutableStateOf(false) }
// Avatar indicator
val hideInvitesAvatar by remember {
client
.mediaPreviewService()
.mediaPreviewConfigFlow
.mapState { config -> config.hideInviteAvatar }
}.collectAsState()
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(RoomListState.DeclineInviteMenu.Hidden) }
fun handleEvents(event: RoomListEvents) {
when (event) {
is RoomListEvents.UpdateVisibleRange -> coroutineScope.launch {
updateVisibleRange(event.range)
}
RoomListEvents.DismissRequestVerificationPrompt -> securityBannerDismissed = true
RoomListEvents.DismissBanner -> securityBannerDismissed = true
RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
is RoomListEvents.ShowContextMenu -> {
coroutineScope.showContextMenu(event, contextMenu)
}
is RoomListEvents.HideContextMenu -> {
contextMenu.value = RoomListState.ContextMenu.Hidden
}
is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(ShowConfirmation(event.roomId))
is RoomListEvents.SetRoomIsFavorite -> coroutineScope.setRoomIsFavorite(event.roomId, event.isFavorite)
is RoomListEvents.MarkAsRead -> coroutineScope.markAsRead(event.roomId)
is RoomListEvents.MarkAsUnread -> coroutineScope.markAsUnread(event.roomId)
is RoomListEvents.AcceptInvite -> {
acceptDeclineInviteState.eventSink(
AcceptInvite(event.roomSummary.toInviteData())
)
}
is RoomListEvents.DeclineInvite -> {
acceptDeclineInviteState.eventSink(
DeclineInvite(event.roomSummary.toInviteData(), blockUser = event.blockUser, shouldConfirm = false)
)
}
is RoomListEvents.ShowDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Shown(event.roomSummary)
RoomListEvents.HideDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Hidden
is RoomListEvents.ClearCacheOfRoom -> coroutineScope.clearCacheOfRoom(event.roomId)
}
}
val contentState = roomListContentState(securityBannerDismissed)
val canReportRoom by produceState(false) { value = client.canReportRoom() }
return RoomListState(
contextMenu = contextMenu.value,
declineInviteMenu = declineInviteMenu.value,
leaveRoomState = leaveRoomState,
filtersState = filtersState,
searchState = searchState,
contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
hideInvitesAvatars = hideInvitesAvatar,
canReportRoom = canReportRoom,
eventSink = ::handleEvents,
)
}
@Composable
private fun rememberSecurityBannerState(
securityBannerDismissed: Boolean,
): State<SecurityBannerState> {
val currentSecurityBannerDismissed by rememberUpdatedState(securityBannerDismissed)
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
return remember {
derivedStateOf {
calculateBannerState(
securityBannerDismissed = currentSecurityBannerDismissed,
recoveryState = recoveryState,
)
}
}
}
private fun calculateBannerState(
securityBannerDismissed: Boolean,
recoveryState: RecoveryState,
): SecurityBannerState {
if (securityBannerDismissed) {
return SecurityBannerState.None
}
when (recoveryState) {
RecoveryState.DISABLED -> return SecurityBannerState.SetUpRecovery
RecoveryState.INCOMPLETE -> return SecurityBannerState.RecoveryKeyConfirmation
RecoveryState.UNKNOWN,
RecoveryState.WAITING_FOR_SYNC,
RecoveryState.ENABLED -> Unit
}
return SecurityBannerState.None
}
@Composable
private fun roomListContentState(
securityBannerDismissed: Boolean,
): RoomListContentState {
val roomSummaries by produceState(initialValue = AsyncData.Loading()) {
roomListDataSource.allRooms.collect { value = AsyncData.Success(it) }
}
val loadingState by roomListDataSource.loadingState.collectAsState()
val showEmpty by remember {
derivedStateOf {
(loadingState as? RoomList.LoadingState.Loaded)?.numberOfRooms == 0
}
}
val showSkeleton by remember {
derivedStateOf {
loadingState == RoomList.LoadingState.NotLoaded || roomSummaries is AsyncData.Loading
}
}
val seenRoomInvites by remember { seenInvitesStore.seenRoomIds() }.collectAsState(emptySet())
val securityBannerState by rememberSecurityBannerState(securityBannerDismissed)
return when {
showEmpty -> RoomListContentState.Empty(securityBannerState = securityBannerState)
showSkeleton -> RoomListContentState.Skeleton(count = 16)
else -> {
RoomListContentState.Rooms(
securityBannerState = securityBannerState,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
batteryOptimizationState = batteryOptimizationPresenter.present(),
summaries = roomSummaries.dataOrNull().orEmpty().toPersistentList(),
seenRoomInvites = seenRoomInvites.toPersistentSet(),
)
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun CoroutineScope.showContextMenu(event: RoomListEvents.ShowContextMenu, contextMenuState: MutableState<RoomListState.ContextMenu>) = launch {
val initialState = RoomListState.ContextMenu.Shown(
roomId = event.roomSummary.roomId,
roomName = event.roomSummary.name,
isDm = event.roomSummary.isDm,
isFavorite = event.roomSummary.isFavorite,
markAsUnreadFeatureFlagEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.MarkAsUnread),
hasNewContent = event.roomSummary.hasNewContent,
displayClearRoomCacheAction = appPreferencesStore.isDeveloperModeEnabledFlow().first(),
)
contextMenuState.value = initialState
client.getRoom(event.roomSummary.roomId)?.use { room ->
val isShowingContextMenuFlow = snapshotFlow { contextMenuState.value is RoomListState.ContextMenu.Shown }
.distinctUntilChanged()
val isFavoriteFlow = room.roomInfoFlow
.map { it.isFavorite }
.distinctUntilChanged()
isFavoriteFlow
.onEach { isFavorite ->
contextMenuState.value = initialState.copy(isFavorite = isFavorite)
}
.flatMapLatest { isShowingContextMenuFlow }
.takeWhile { isShowingContextMenu -> isShowingContextMenu }
.collect()
}
}
private fun CoroutineScope.setRoomIsFavorite(roomId: RoomId, isFavorite: Boolean) = launch {
client.getRoom(roomId)?.use { room ->
room.setIsFavorite(isFavorite)
.onSuccess {
analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuFavouriteToggle)
}
}
}
private fun CoroutineScope.markAsRead(roomId: RoomId) = launch {
notificationCleaner.clearMessagesForRoom(client.sessionId, roomId)
client.getRoom(roomId)?.use { room ->
room.setUnreadFlag(isUnread = false)
val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) {
ReceiptType.READ
} else {
ReceiptType.READ_PRIVATE
}
room.markAsRead(receiptType)
.onSuccess {
analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle)
}
}
}
private fun CoroutineScope.markAsUnread(roomId: RoomId) = launch {
client.getRoom(roomId)?.use { room ->
room.setUnreadFlag(isUnread = true)
.onSuccess {
analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle)
}
}
}
private fun CoroutineScope.clearCacheOfRoom(roomId: RoomId) = launch {
client.getRoom(roomId)?.use { room ->
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

@ -0,0 +1,76 @@
/*
* Copyright 2023, 2024 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.features.home.impl.roomlist
import androidx.compose.runtime.Immutable
import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.search.RoomListSearchState
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
@Immutable
data class RoomListState(
val contextMenu: ContextMenu,
val declineInviteMenu: DeclineInviteMenu,
val leaveRoomState: LeaveRoomState,
val filtersState: RoomListFiltersState,
val searchState: RoomListSearchState,
val contentState: RoomListContentState,
val acceptDeclineInviteState: AcceptDeclineInviteState,
val hideInvitesAvatars: Boolean,
val canReportRoom: Boolean,
val eventSink: (RoomListEvents) -> Unit,
) {
val displayFilters = contentState is RoomListContentState.Rooms
sealed interface ContextMenu {
data object Hidden : ContextMenu
data class Shown(
val roomId: RoomId,
val roomName: String?,
val isDm: Boolean,
val isFavorite: Boolean,
val markAsUnreadFeatureFlagEnabled: Boolean,
val hasNewContent: Boolean,
val displayClearRoomCacheAction: Boolean,
) : ContextMenu
}
sealed interface DeclineInviteMenu {
data object Hidden : DeclineInviteMenu
data class Shown(val roomSummary: RoomListRoomSummary) : DeclineInviteMenu
}
}
enum class SecurityBannerState {
None,
SetUpRecovery,
RecoveryKeyConfirmation,
}
@Immutable
sealed interface RoomListContentState {
data class Skeleton(val count: Int) : RoomListContentState
data class Empty(
val securityBannerState: SecurityBannerState,
) : RoomListContentState
data class Rooms(
val securityBannerState: SecurityBannerState,
val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState,
val batteryOptimizationState: BatteryOptimizationState,
val summaries: ImmutableList<RoomListRoomSummary>,
val seenRoomInvites: ImmutableSet<RoomId>,
) : RoomListContentState
}

View file

@ -0,0 +1,35 @@
/*
* Copyright 2023, 2024 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.features.home.impl.roomlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.RoomId
open class RoomListStateContextMenuShownProvider : PreviewParameterProvider<RoomListState.ContextMenu.Shown> {
override val values: Sequence<RoomListState.ContextMenu.Shown>
get() = sequenceOf(
aContextMenuShown(hasNewContent = true),
aContextMenuShown(isDm = true),
aContextMenuShown(roomName = null)
)
}
internal fun aContextMenuShown(
roomName: String? = "aRoom",
isDm: Boolean = false,
hasNewContent: Boolean = false,
isFavorite: Boolean = false,
) = RoomListState.ContextMenu.Shown(
roomId = RoomId("!aRoom:aDomain"),
roomName = roomName,
isDm = isDm,
markAsUnreadFeatureFlagEnabled = true,
hasNewContent = hasNewContent,
isFavorite = isFavorite,
displayClearRoomCacheAction = false,
)

View file

@ -0,0 +1,115 @@
/*
* Copyright 2023, 2024 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.features.home.impl.roomlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.filters.aRoomListFiltersState
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.model.RoomSummaryDisplayType
import io.element.android.features.home.impl.model.aRoomListRoomSummary
import io.element.android.features.home.impl.model.anInviteSender
import io.element.android.features.home.impl.search.RoomListSearchState
import io.element.android.features.home.impl.search.aRoomListSearchState
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
override val values: Sequence<RoomListState>
get() = sequenceOf(
aRoomListState(),
aRoomListState(contextMenu = aContextMenuShown(roomName = null)),
aRoomListState(contextMenu = aContextMenuShown(roomName = "A nice room name")),
aRoomListState(contextMenu = aContextMenuShown(isFavorite = true)),
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
aRoomListState(contentState = anEmptyContentState()),
aRoomListState(contentState = aSkeletonContentState()),
aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery)),
aRoomListState(contentState = aRoomsContentState(batteryOptimizationState = aBatteryOptimizationState(shouldDisplayBanner = true))),
)
}
internal fun aRoomListState(
contextMenu: RoomListState.ContextMenu = RoomListState.ContextMenu.Hidden,
declineInviteMenu: RoomListState.DeclineInviteMenu = RoomListState.DeclineInviteMenu.Hidden,
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
searchState: RoomListSearchState = aRoomListSearchState(),
filtersState: RoomListFiltersState = aRoomListFiltersState(),
contentState: RoomListContentState = aRoomsContentState(),
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
hideInvitesAvatars: Boolean = false,
canReportRoom: Boolean = true,
eventSink: (RoomListEvents) -> Unit = {}
) = RoomListState(
contextMenu = contextMenu,
declineInviteMenu = declineInviteMenu,
leaveRoomState = leaveRoomState,
filtersState = filtersState,
searchState = searchState,
contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
hideInvitesAvatars = hideInvitesAvatars,
canReportRoom = canReportRoom,
eventSink = eventSink,
)
internal fun anAcceptDeclineInviteState(
acceptAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
declineAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
eventSink: (AcceptDeclineInviteEvents) -> Unit = {}
) = AcceptDeclineInviteState(
acceptAction = acceptAction,
declineAction = declineAction,
eventSink = eventSink,
)
internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {
return persistentListOf(
aRoomListRoomSummary(
name = "Room Invited",
avatarData = AvatarData("!roomId", "Room with Alice and Bob", size = AvatarSize.RoomListItem),
id = "!roomId:domain",
inviteSender = anInviteSender(),
displayType = RoomSummaryDisplayType.INVITE,
),
aRoomListRoomSummary(
name = "Room",
numberOfUnreadMessages = 1,
timestamp = "14:18",
lastMessage = "A very very very very long message which suites on two lines",
avatarData = AvatarData("!id", "R", size = AvatarSize.RoomListItem),
id = "!roomId:domain",
),
aRoomListRoomSummary(
name = "Room#2",
numberOfUnreadMessages = 0,
timestamp = "14:16",
lastMessage = "A short message",
avatarData = AvatarData("!id", "Z", size = AvatarSize.RoomListItem),
id = "!roomId2:domain",
),
aRoomListRoomSummary(
id = "!roomId3:domain",
displayType = RoomSummaryDisplayType.PLACEHOLDER,
),
aRoomListRoomSummary(
id = "!roomId4:domain",
displayType = RoomSummaryDisplayType.PLACEHOLDER,
),
)
}

View file

@ -0,0 +1,62 @@
/*
* Copyright 2024 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.features.home.impl.search
import io.element.android.features.home.impl.datasource.RoomListRoomSummaryFactory
import io.element.android.features.home.impl.model.RoomListRoomSummary
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 kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import javax.inject.Inject
private const val PAGE_SIZE = 30
class RoomListSearchDataSource @Inject constructor(
roomListService: RoomListService,
coroutineDispatchers: CoroutineDispatchers,
private val roomSummaryFactory: RoomListRoomSummaryFactory,
) {
private val roomList = roomListService.createRoomList(
pageSize = PAGE_SIZE,
initialFilter = RoomListFilter.None,
source = RoomList.Source.All,
)
val roomSummaries: Flow<PersistentList<RoomListRoomSummary>> = roomList.filteredSummaries
.map { roomSummaries ->
roomSummaries
.map(roomSummaryFactory::create)
.toPersistentList()
}
.flowOn(coroutineDispatchers.computation)
suspend fun setIsActive(isActive: Boolean) = coroutineScope {
if (isActive) {
roomList.loadAllIncrementally(this)
} else {
roomList.reset()
}
}
suspend fun setSearchQuery(searchQuery: String) = coroutineScope {
val filter = if (searchQuery.isBlank()) {
RoomListFilter.None
} else {
RoomListFilter.NormalizedMatchRoomName(searchQuery)
}
roomList.updateFilter(filter)
}
}

View file

@ -0,0 +1,14 @@
/*
* Copyright 2024 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.features.home.impl.search
sealed interface RoomListSearchEvents {
data object ToggleSearchVisibility : RoomListSearchEvents
data class QueryChanged(val query: String) : RoomListSearchEvents
data object ClearQuery : RoomListSearchEvents
}

View file

@ -0,0 +1,66 @@
/*
* Copyright 2024 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.features.home.impl.search
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Presenter
import kotlinx.collections.immutable.persistentListOf
import javax.inject.Inject
class RoomListSearchPresenter @Inject constructor(
private val dataSource: RoomListSearchDataSource,
) : Presenter<RoomListSearchState> {
@Composable
override fun present(): RoomListSearchState {
// Do not use rememberSaveable so that search is not active when the user navigates back to the screen
var isSearchActive by remember {
mutableStateOf(false)
}
var searchQuery by remember {
mutableStateOf("")
}
LaunchedEffect(isSearchActive) {
dataSource.setIsActive(isSearchActive)
}
LaunchedEffect(searchQuery) {
dataSource.setSearchQuery(searchQuery)
}
fun handleEvents(event: RoomListSearchEvents) {
when (event) {
RoomListSearchEvents.ClearQuery -> {
searchQuery = ""
}
is RoomListSearchEvents.QueryChanged -> {
searchQuery = event.query
}
RoomListSearchEvents.ToggleSearchVisibility -> {
isSearchActive = !isSearchActive
searchQuery = ""
}
}
}
val searchResults by dataSource.roomSummaries.collectAsState(initial = persistentListOf())
return RoomListSearchState(
isSearchActive = isSearchActive,
query = searchQuery,
results = searchResults,
eventSink = ::handleEvents
)
}
}

View file

@ -0,0 +1,18 @@
/*
* Copyright 2024 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.features.home.impl.search
import io.element.android.features.home.impl.model.RoomListRoomSummary
import kotlinx.collections.immutable.ImmutableList
data class RoomListSearchState(
val isSearchActive: Boolean,
val query: String,
val results: ImmutableList<RoomListRoomSummary>,
val eventSink: (RoomListSearchEvents) -> Unit
)

View file

@ -0,0 +1,38 @@
/*
* Copyright 2024 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.features.home.impl.search
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.roomlist.aRoomListRoomSummaryList
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
class RoomListSearchStateProvider : PreviewParameterProvider<RoomListSearchState> {
override val values: Sequence<RoomListSearchState>
get() = sequenceOf(
aRoomListSearchState(),
aRoomListSearchState(
isSearchActive = true,
query = "Test",
results = aRoomListRoomSummaryList()
),
)
}
fun aRoomListSearchState(
isSearchActive: Boolean = false,
query: String = "",
results: ImmutableList<RoomListRoomSummary> = persistentListOf(),
eventSink: (RoomListSearchEvents) -> Unit = { },
) = RoomListSearchState(
isSearchActive = isSearchActive,
query = query,
results = results,
eventSink = eventSink,
)

View file

@ -0,0 +1,200 @@
/*
* Copyright 2023, 2024 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.features.home.impl.search
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
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.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.components.RoomSummaryRow
import io.element.android.features.home.impl.contentType
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.roomlist.RoomListEvents
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.modifiers.applyIf
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.FilledTextField
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.copy
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun RoomListSearchView(
state: RoomListSearchState,
hideInvitesAvatars: Boolean,
eventSink: (RoomListEvents) -> Unit,
onRoomClick: (RoomId) -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler(enabled = state.isSearchActive) {
state.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
}
AnimatedVisibility(
visible = state.isSearchActive,
enter = fadeIn(),
exit = fadeOut(),
) {
Column(
modifier = modifier
.applyIf(
condition = state.isSearchActive,
ifTrue = {
// Disable input interaction to underlying views
pointerInput(Unit) {}
}
)
) {
if (state.isSearchActive) {
RoomListSearchContent(
state = state,
hideInvitesAvatars = hideInvitesAvatars,
onRoomClick = onRoomClick,
eventSink = eventSink,
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoomListSearchContent(
state: RoomListSearchState,
hideInvitesAvatars: Boolean,
eventSink: (RoomListEvents) -> Unit,
onRoomClick: (RoomId) -> Unit,
) {
val borderColor = MaterialTheme.colorScheme.tertiary
val strokeWidth = 1.dp
fun onBackButtonClick() {
state.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
}
fun onRoomClick(room: RoomListRoomSummary) {
onRoomClick(room.roomId)
}
Scaffold(
topBar = {
TopAppBar(
modifier = Modifier.drawBehind {
drawLine(
color = borderColor,
start = Offset(0f, size.height),
end = Offset(size.width, size.height),
strokeWidth = strokeWidth.value
)
},
navigationIcon = { BackButton(onClick = ::onBackButtonClick) },
title = {
val filter = state.query
val focusRequester = FocusRequester()
FilledTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
value = filter,
singleLine = true,
onValueChange = { state.eventSink(RoomListSearchEvents.QueryChanged(it)) },
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
),
trailingIcon = {
if (filter.isNotEmpty()) {
IconButton(onClick = {
state.eventSink(RoomListSearchEvents.ClearQuery)
}) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_cancel)
)
}
}
}
)
LaunchedEffect(state.isSearchActive) {
if (state.isSearchActive) {
focusRequester.requestFocus()
}
}
},
windowInsets = TopAppBarDefaults.windowInsets.copy(top = 0)
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
) {
LazyColumn(
modifier = Modifier.weight(1f),
) {
items(
items = state.results,
contentType = { room -> room.contentType() },
) { room ->
RoomSummaryRow(
room = room,
hideInviteAvatars = hideInvitesAvatars,
// TODO
isInviteSeen = false,
onClick = ::onRoomClick,
eventSink = eventSink,
)
}
}
}
}
}
@PreviewsDayNight
@Composable
internal fun RoomListSearchContentPreview(@PreviewParameter(RoomListSearchStateProvider::class) state: RoomListSearchState) = ElementPreview {
RoomListSearchContent(
state = state,
hideInvitesAvatars = false,
onRoomClick = {},
eventSink = {},
)
}

View file

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_content">"Стварыце новы ключ аднаўлення, які можна выкарыстоўваць для аднаўлення зашыфраванай гісторыі паведамленняў у выпадку страты доступу да вашых прылад."</string>
<string name="banner_set_up_recovery_submit">"Наладзьце аднаўленне"</string>
<string name="banner_set_up_recovery_title">"Наладзіць аднаўленне"</string>
<string name="confirm_recovery_key_banner_message">"Пацвердзіце свой ключ аднаўлення, каб захаваць доступ да сховішча ключоў і гісторыі паведамленняў."</string>
<string name="confirm_recovery_key_banner_title">"Ваша сховішча ключоў не сінхранізавана"</string>
<string name="full_screen_intent_banner_message">"Каб не прапусціць важны званок, зменіце налады, каб дазволіць поўнаэкранныя апавяшчэнні, калі тэлефон заблакіраваны."</string>
<string name="full_screen_intent_banner_title">"Палепшыце якасць званкоў"</string>
<string name="screen_invites_decline_chat_message">"Вы ўпэўненыя, што хочаце адхіліць запрашэнне ў %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Адхіліць запрашэнне"</string>
<string name="screen_invites_decline_direct_chat_message">"Вы ўпэўненыя, што хочаце адмовіцца ад прыватных зносін з %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Адхіліць чат"</string>
<string name="screen_invites_empty_list">"Няма запрашэнняў"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) запрасіў(-ла) вас"</string>
<string name="screen_migration_message">"Гэта аднаразовы працэс, дзякуем за чаканне."</string>
<string name="screen_migration_title">"Налада ўліковага запісу."</string>
<string name="screen_roomlist_a11y_create_message">"Стварыце новую размову або пакой"</string>
<string name="screen_roomlist_empty_message">"Пачніце з паведамлення каму-небудзь."</string>
<string name="screen_roomlist_empty_title">"Пакуль няма чатаў."</string>
<string name="screen_roomlist_filter_favourites">"Абранае"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Дадаць чат у абранае можна ў наладах чата.
На дадзены момант вы можаце прыбраць фільтры, каб убачыць іншыя вашыя чаты."</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"У вас пакуль няма абраных чатаў"</string>
<string name="screen_roomlist_filter_invites">"Запрашэнні"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"У вас няма непрынятых запрашэнняў."</string>
<string name="screen_roomlist_filter_low_priority">"Нізкі прыярытэт"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Вы можаце прыбраць фільтры, каб убачыць іншыя вашыя чаты."</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"У вас няма чатаў для гэтай катэгорыі"</string>
<string name="screen_roomlist_filter_people">"Людзі"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"У вас пакуль няма асабістых паведамленняў"</string>
<string name="screen_roomlist_filter_rooms">"Пакоі"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Вас пакуль няма ў ніводным пакоі"</string>
<string name="screen_roomlist_filter_unreads">"Непрачытаныя"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Віншуем!
У вас няма непрачытаных паведамленняў!"</string>
<string name="screen_roomlist_main_space_title">"Усе чаты"</string>
<string name="screen_roomlist_mark_as_read">"Пазначыць як прачытанае"</string>
<string name="screen_roomlist_mark_as_unread">"Пазначыць як непрачытанае"</string>
<string name="session_verification_banner_message">"Здаецца, вы карыстаецеся новай прыладай. Праверце з дапамогай іншай прылады, каб атрымаць доступ да зашыфраваных паведамленняў."</string>
<string name="session_verification_banner_title">"Пацвердзіце, што гэта вы"</string>
</resources>

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="confirm_recovery_key_banner_message">"Потвърдете ключа си за възстановяване, за да запазите достъп до хранилището за ключове и историята на съобщенията си."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Въведете ключа си за възстановяване"</string>
<string name="confirm_recovery_key_banner_title">"Хранилището ви за ключове не е синхронизирано"</string>
<string name="screen_invites_decline_chat_message">"Сигурни ли сте, че искате да отхвърлите поканата за присъединяване в %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Отказване на покана"</string>
<string name="screen_invites_empty_list">"Няма покани"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) ви покани"</string>
<string name="screen_roomlist_a11y_create_message">"Създаване на нов разговор или стая"</string>
<string name="screen_roomlist_empty_message">"Започнете, като изпратите съобщение на някого."</string>
<string name="screen_roomlist_empty_title">"Все още няма чатове."</string>
<string name="screen_roomlist_filter_favourites">"Любими"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Можете да добавите чат към фаворизираните си в настройките на чата.
Засега можете да премахнете избора на филтрите, за да видите другите си чатове."</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Все още нямате фаворизирани чатове"</string>
<string name="screen_roomlist_filter_invites">"Покани"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Нямате чакащи покани."</string>
<string name="screen_roomlist_filter_low_priority">"Нисък приоритет"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Можете да премахнете избора на филтрите, за да видите другите си чатове"</string>
<string name="screen_roomlist_filter_people">"Хора"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Все още нямате директни съобщения"</string>
<string name="screen_roomlist_filter_rooms">"Стаи"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Все още не сте в никоя стая"</string>
<string name="screen_roomlist_filter_unreads">"Непрочетени"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Поздравления!
Нямате непрочетени съобщения!"</string>
<string name="screen_roomlist_main_space_title">"Всички чатове"</string>
<string name="screen_roomlist_mark_as_read">"Отбелязване като прочетено"</string>
<string name="screen_roomlist_mark_as_unread">"Отбелязване като непрочетено"</string>
<string name="session_verification_banner_message">"Изглежда, че използвате ново устройство. Потвърдете с друго устройство за достъп до вашите шифровани съобщения."</string>
<string name="session_verification_banner_title">"Потвърдете, че сте вие"</string>
</resources>

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_battery_optimization_content_android">"Zakažte optimalizaci baterie pro tuto aplikaci, abyste měli jistotu, že budou přijata všechna oznámení."</string>
<string name="banner_battery_optimization_submit_android">"Zakázat optimalizaci"</string>
<string name="banner_battery_optimization_title_android">"Nepřicházejí vám oznámení?"</string>
<string name="banner_set_up_recovery_content">"Vygenerujte nový klíč pro obnovení, který lze použít k obnovení historie šifrovaných zpráv v případě, že ztratíte přístup ke svým zařízením."</string>
<string name="banner_set_up_recovery_submit">"Nastavení obnovy"</string>
<string name="banner_set_up_recovery_title">"Nastavení obnovy"</string>
<string name="confirm_recovery_key_banner_message">"Potvrďte klíč pro obnovení, abyste zachovali přístup k úložišti klíčů a historii zpráv."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Zadejte klíč pro obnovení"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Zapomněli jste klíč pro obnovení?"</string>
<string name="confirm_recovery_key_banner_title">"Vaše úložiště klíčů není synchronizováno"</string>
<string name="full_screen_intent_banner_message">"Abyste nikdy nezmeškali důležitý hovor, změňte nastavení tak, abyste povolili oznámení na celé obrazovce, když je telefon uzamčen."</string>
<string name="full_screen_intent_banner_title">"Vylepšete si zážitek z volání"</string>
<string name="screen_invites_decline_chat_message">"Opravdu chcete odmítnout pozvánku do %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Odmítnout pozvání"</string>
<string name="screen_invites_decline_direct_chat_message">"Opravdu chcete odmítnout tuto soukromou konverzaci s %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Odmítnout chat"</string>
<string name="screen_invites_empty_list">"Žádné pozvánky"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) vás pozval(a)"</string>
<string name="screen_migration_message">"Jedná se o jednorázový proces, prosíme o strpení."</string>
<string name="screen_migration_title">"Nastavení vašeho účtu"</string>
<string name="screen_roomlist_a11y_create_message">"Vytvořte novou konverzaci nebo místnost"</string>
<string name="screen_roomlist_empty_message">"Začněte tím, že někomu pošnete zprávu."</string>
<string name="screen_roomlist_empty_title">"Zatím žádné konverzace."</string>
<string name="screen_roomlist_filter_favourites">"Oblíbené"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"V nastavení chatu můžete přidat chat k oblíbeným.
Prozatím můžete zrušit výběr filtrů, abyste viděli své další chaty"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Zatím nemáte oblíbené chaty"</string>
<string name="screen_roomlist_filter_invites">"Pozvánky"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Nemáte žádné nevyřízené pozvánky."</string>
<string name="screen_roomlist_filter_low_priority">"Nízká priorita"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Můžete zrušit výběr filtrů, abyste viděli své další chaty"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Nemáte chaty pro tento výběr"</string>
<string name="screen_roomlist_filter_people">"Lidé"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Zatím nemáte žádné přímé zprávy"</string>
<string name="screen_roomlist_filter_rooms">"Místnosti"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Ještě nejste v žádné místnosti"</string>
<string name="screen_roomlist_filter_unreads">"Nepřečtené"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Gratulujeme!
Nemáte žádné nepřečtené zprávy!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Žádost o vstup odeslána"</string>
<string name="screen_roomlist_main_space_title">"Všechny chaty"</string>
<string name="screen_roomlist_mark_as_read">"Označit jako přečtené"</string>
<string name="screen_roomlist_mark_as_unread">"Označit jako nepřečtené"</string>
<string name="screen_roomlist_tombstoned_room_description">"Tato místnost byla aktualizována"</string>
<string name="session_verification_banner_message">"Zdá se, že používáte nové zařízení. Ověřte přihlášení, abyste měli přístup k zašifrovaným zprávám."</string>
<string name="session_verification_banner_title">"Ověřte, že jste to vy"</string>
</resources>

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_content">"Adferwch eich hunaniaeth cryptograffig a hanes negeseuon gydag allwedd adfer os ydych wedi colli eich holl ddyfeisiau presennol."</string>
<string name="banner_set_up_recovery_submit">"Gosod adfer"</string>
<string name="banner_set_up_recovery_title">"Gosodwch adferiad i ddiogelu eich cyfrif"</string>
<string name="confirm_recovery_key_banner_message">"Cadarnhewch eich allwedd adfer i gynnal mynediad i\'ch storfa allweddi a\'ch hanes negeseuon."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Rhowch eich allwedd adfer"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Wedi anghofio\'ch allwedd adfer?"</string>
<string name="confirm_recovery_key_banner_title">"Dyw eich allwedd storfa heb ei gydweddu"</string>
<string name="full_screen_intent_banner_message">"Er mwyn sicrhau fyddwch chi ddim yn colli galwad bwysig, newidiwch eich gosodiadau i ganiatáu hysbysiadau sgrin lawn pan fydd eich ffôn wedi\'i gloi."</string>
<string name="full_screen_intent_banner_title">"Gwella profiad eich galwadau"</string>
<string name="screen_invites_decline_chat_message">"Ydych chi\'n siŵr eich bod am wrthod y gwahoddiad i ymuno â %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Gwrthod y gwahoddiad"</string>
<string name="screen_invites_decline_direct_chat_message">"Ydych chi\'n siŵr eich bod am wrthod y sgwrs breifat hon gyda %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Gwrthod sgwrs"</string>
<string name="screen_invites_empty_list">"Dim Gwahoddiadau"</string>
<string name="screen_invites_invited_you">"Mae %1$s (%2$s) wedi eich gwahodd"</string>
<string name="screen_migration_message">"Mae hon yn broses un tro, diolch am aros."</string>
<string name="screen_migration_title">"Creu eich cyfrif."</string>
<string name="screen_roomlist_a11y_create_message">"Crëwch sgwrs neu ystafell newydd"</string>
<string name="screen_roomlist_empty_message">"Cychwynnwch arni trwy anfon neges at rywun."</string>
<string name="screen_roomlist_empty_title">"Dim sgyrsiau eto."</string>
<string name="screen_roomlist_filter_favourites">"Ffefrynnau"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Gallwch ychwanegu sgwrs at eich ffefrynnau yn y gosodiadau sgwrsio.
Am y tro, gallwch ddad-ddewis hidlwyr er mwyn gweld eich sgyrsiau eraill"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Does gennych chi ddim hoff sgyrsiau eto"</string>
<string name="screen_roomlist_filter_invites">"Gwahoddiadau"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Does gennych chi ddim gwahoddiadau yn aros."</string>
<string name="screen_roomlist_filter_low_priority">"Blaenoriaeth Isel"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Gallwch ddad-ddewis hidlwyr er mwyn gweld eich sgyrsiau eraill"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Does gennych chi ddim sgyrsiau ar gyfer y dewis hwn"</string>
<string name="screen_roomlist_filter_people">"Pobl"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Does gennych chi ddim unrhyw DMs eto"</string>
<string name="screen_roomlist_filter_rooms">"Ystafelloedd"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Dydych chi ddim mewn unrhyw ystafell eto"</string>
<string name="screen_roomlist_filter_unreads">"Heb ei ddarllen"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Llongyfarchiadau!
Does gennych chi ddim negeseuon heb eu darllen!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Anfonwyd y cais i ymuno"</string>
<string name="screen_roomlist_main_space_title">"Sgyrsiau"</string>
<string name="screen_roomlist_mark_as_read">"Marcio fel wedi\'i ddarllen"</string>
<string name="screen_roomlist_mark_as_unread">"Marcio fel heb ei ddarllen"</string>
<string name="session_verification_banner_message">"Mae\'n debyg eich bod chi\'n defnyddio dyfais newydd. Dilyswch gyda dyfais arall i gael mynediad at eich negeseuon wedi\'u hamgryptio."</string>
<string name="session_verification_banner_title">"Gwiriwch mai chi sydd yna"</string>
</resources>

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_battery_optimization_content_android">"Deaktiver batterioptimering for denne app for at sikre, at alle notifikationer dukker op."</string>
<string name="banner_battery_optimization_submit_android">"Deaktivér optimering"</string>
<string name="banner_battery_optimization_title_android">"Modtager du ikke notifikationer?"</string>
<string name="banner_set_up_recovery_content">"Gendan din kryptografiske identitet og meddelelseshistorik med en gendannelsesnøgle, hvis du har mistet alle dine eksisterende enheder."</string>
<string name="banner_set_up_recovery_submit">"Opsæt gendannelse"</string>
<string name="banner_set_up_recovery_title">"Konfigurer gendannelse for at beskytte din konto"</string>
<string name="confirm_recovery_key_banner_message">"Bekræft din gendannelsesnøgle for at bevare adgangen til nøglelager og meddelelseshistorik."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Indtast din gendannelsesnøgle"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Har du glemt din gendannelsesnøgle?"</string>
<string name="confirm_recovery_key_banner_title">"Dit nøglelager er ikke synkroniseret"</string>
<string name="full_screen_intent_banner_message">"For at sikre, at du aldrig går glip af et vigtigt opkald, skal du ændre dine indstillinger til at tillade underretninger i fuld skærm, når din telefon er låst."</string>
<string name="full_screen_intent_banner_title">"Gør din opkaldsoplevelse bedre"</string>
<string name="screen_invites_decline_chat_message">"Er du sikker på, at du vil afvise invitationen til at deltage i %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Afvis invitation"</string>
<string name="screen_invites_decline_direct_chat_message">"Er du sikker på, at du vil afvise denne private samtale med %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Afvis samtale"</string>
<string name="screen_invites_empty_list">"Ingen invitationer"</string>
<string name="screen_invites_invited_you">"%1$s(%2$s ) inviterede dig"</string>
<string name="screen_migration_message">"Dette er en engangsproces, tak for din tålmodighed."</string>
<string name="screen_migration_title">"Sætter din konto op."</string>
<string name="screen_roomlist_a11y_create_message">"Opret en ny samtale eller et nyt rum"</string>
<string name="screen_roomlist_empty_message">"Kom i gang ved at sende en besked til nogen."</string>
<string name="screen_roomlist_empty_title">"Ingen samtaler endnu."</string>
<string name="screen_roomlist_filter_favourites">"Favoritter"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Du kan tilføje en samtale til dine favoritter i samtaleindstillingerne.
For nu kan du fravælge filtre for at se dine andre samtaler"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Du har endnu ingen foretrukne samtaler"</string>
<string name="screen_roomlist_filter_invites">"Invitationer"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Du har ingen afventende invitationer."</string>
<string name="screen_roomlist_filter_low_priority">"Lav prioritet"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Du kan fravælge filtre for at se dine andre samtaler"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Du har ingen samtaler til dette valg"</string>
<string name="screen_roomlist_filter_people">"Mennesker"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Du har ingen DM\'er endnu"</string>
<string name="screen_roomlist_filter_rooms">"Rum"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Du er ikke i noget rum endnu"</string>
<string name="screen_roomlist_filter_unreads">"Ulæste"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Tillykke!
Du har ingen ulæste beskeder!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Anmodning om at deltage sendt"</string>
<string name="screen_roomlist_main_space_title">"Samtaler"</string>
<string name="screen_roomlist_mark_as_read">"Marker som læst"</string>
<string name="screen_roomlist_mark_as_unread">"Marker som ulæst"</string>
<string name="screen_roomlist_tombstoned_room_description">"Dette rum er blevet opgraderet"</string>
<string name="session_verification_banner_message">"Det ser ud til, at du bruger en ny enhed. Bekræft med en anden enhed for at få adgang til dine krypterede meddelelser."</string>
<string name="session_verification_banner_title">"Bekræft, at det er dig"</string>
</resources>

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_content">"Falls Sie alle vorhandenen Geräte verloren haben, stellen Sie Ihre kryptografische Identität und Ihren Nachrichtenverlauf mit einem Wiederherstellungsschlüssel wieder her."</string>
<string name="banner_set_up_recovery_submit">"Wiederherstellung einrichten"</string>
<string name="banner_set_up_recovery_title">"Wiederherstellung einrichten"</string>
<string name="confirm_recovery_key_banner_message">"Bestätigen Sie die Validität Ihres Wiederherstellungsschlüssels, um weiterhin auf Ihren Schlüsselspeicher und den Nachrichtenverlauf zugreifen zu können."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Geben Sie Ihren Wiederherstellungsschlüssel ein"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Haben Sie Ihren Wiederherstellungsschlüssel vergessen?"</string>
<string name="confirm_recovery_key_banner_title">"Ihr Schlüsselspeicher ist nicht synchronisiert"</string>
<string name="full_screen_intent_banner_message">"Damit Sie keine wichtigen Anrufe verpassen, ändern Sie bitte Ihre Einstellungen, so dass das gesperrte Telefon auch Benachrichtigungen im Vollbildmodus erhalten darf."</string>
<string name="full_screen_intent_banner_title">"Verbessere dein Anruferlebnis"</string>
<string name="screen_invites_decline_chat_message">"Möchten Sie die Einladung zum Betreten von %1$s wirklich ablehnen?"</string>
<string name="screen_invites_decline_chat_title">"Einladung ablehnen"</string>
<string name="screen_invites_decline_direct_chat_message">"Möchten Sie diesen privaten Chat mit %1$s wirklich ablehnen?"</string>
<string name="screen_invites_decline_direct_chat_title">"Einladung ablehnen"</string>
<string name="screen_invites_empty_list">"Keine Einladungen"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) hat dich eingeladen"</string>
<string name="screen_migration_message">"Dies ist ein einmaliger Vorgang, danke fürs Warten."</string>
<string name="screen_migration_title">"Dein Konto wird eingerichtet."</string>
<string name="screen_roomlist_a11y_create_message">"Eine Unterthaltung oder Raum erstellen"</string>
<string name="screen_roomlist_empty_message">"Beginnen Sie, indem Sie jemandem eine Nachricht senden."</string>
<string name="screen_roomlist_empty_title">"Noch keine Chats."</string>
<string name="screen_roomlist_filter_favourites">"Favoriten"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"In den Chatroomeinstellungen können Sie einen Chatroom als Favorit markieren.
Deaktivieren Sie den entsprechenden Filter, um Ihre anderen Chatrooms zu sehen"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Sie haben noch keine Chatrooms als Favorit markiert"</string>
<string name="screen_roomlist_filter_invites">"Einladungen"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Sie haben keine ausstehenden Einladungen."</string>
<string name="screen_roomlist_filter_low_priority">"Niedrige Priorität"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Wähle Filter ab, um Deine Chats zu sehen."</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Diese Chats entsprechen diesen Kriterien nicht."</string>
<string name="screen_roomlist_filter_people">"Personen"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Sie haben noch keine Direktnachrichten"</string>
<string name="screen_roomlist_filter_rooms">"Räume"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Sie sind noch in keinem Raum"</string>
<string name="screen_roomlist_filter_unreads">"Ungelesen"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Glückwunsch!
Sie haben keine ungelesenen Nachrichten!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Beitrittsanfrage geschickt"</string>
<string name="screen_roomlist_main_space_title">"Chats"</string>
<string name="screen_roomlist_mark_as_read">"Als gelesen markieren"</string>
<string name="screen_roomlist_mark_as_unread">"Als ungelesen markieren"</string>
<string name="session_verification_banner_message">"Sie verwenden anscheinend ein neues Gerät. Verifizieren Sie es mit einem anderen Gerät, um Zugriff auf ihre verschlüsselten Nachrichten zu erhalten."</string>
<string name="session_verification_banner_title">"Bestätigen Sie ihre Identität"</string>
</resources>

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_battery_optimization_content_android">"Απενεργοποίησε τη βελτιστοποίηση μπαταρίας για αυτήν την εφαρμογή, για να βεβαιωθείς ότι λαμβάνονται όλες οι ειδοποιήσεις."</string>
<string name="banner_battery_optimization_submit_android">"Απενεργοποίηση βελτιστοποίησης"</string>
<string name="banner_battery_optimization_title_android">"Δεν φτάνουν οι ειδοποιήσεις;"</string>
<string name="banner_set_up_recovery_content">"Δημιούργησε ένα νέο κλειδί ανάκτησης που μπορεί να χρησιμοποιηθεί για την επαναφορά του ιστορικού των κρυπτογραφημένων μηνυμάτων σου σε περίπτωση που χάσεις την πρόσβαση στις συσκευές σου."</string>
<string name="banner_set_up_recovery_submit">"Ρύθμιση ανάκτησης"</string>
<string name="banner_set_up_recovery_title">"Ρύθμιση ανάκτησης"</string>
<string name="confirm_recovery_key_banner_message">"Επιβεβαίωσε το κλειδί ανάκτησης για να διατηρήσεις την πρόσβαση στο χώρο αποθήκευσης κλειδιών και στο ιστορικό μηνυμάτων."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Εισήγαγε το κλειδί ανάκτησης"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Ξέχασες το κλειδί ανάκτησης;"</string>
<string name="confirm_recovery_key_banner_title">"Ο χώρος αποθήκευσης κλειδιών σου δεν είναι συγχρονισμένος"</string>
<string name="full_screen_intent_banner_message">"Για να διασφαλίσετε ότι δεν θα χάσετε ποτέ μια σημαντική κλήση, αλλάξτε τις ρυθμίσεις σας ώστε να επιτρέπονται οι ειδοποιήσεις πλήρους οθόνης όταν το τηλέφωνό σας είναι κλειδωμένο."</string>
<string name="full_screen_intent_banner_title">"Βελτίωσε την εμπειρία κλήσεων"</string>
<string name="screen_invites_decline_chat_message">"Σίγουρα θες να απορρίψεις την πρόσκληση συμμετοχής στο %1$s;"</string>
<string name="screen_invites_decline_chat_title">"Απόρριψη πρόσκλησης"</string>
<string name="screen_invites_decline_direct_chat_message">"Σίγουρα θες να απορρίψεις την ιδιωτική συνομιλία με τον χρήστη %1$s;"</string>
<string name="screen_invites_decline_direct_chat_title">"Απόρριψη συνομιλίας"</string>
<string name="screen_invites_empty_list">"Χωρίς προσκλήσεις"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) σέ προσκάλεσε"</string>
<string name="screen_migration_message">"Αυτή είναι μια εφάπαξ διαδικασία, ευχαριστώ που περίμενες."</string>
<string name="screen_migration_title">"Ρύθμιση του λογαριασμού σου."</string>
<string name="screen_roomlist_a11y_create_message">"Δημιουργία νέας συνομιλίας ή αίθουσας"</string>
<string name="screen_roomlist_empty_message">"Ξεκίνησε στέλνοντας μηνύματα σε κάποιον."</string>
<string name="screen_roomlist_empty_title">"Δεν υπάρχουν συνομιλίες ακόμα."</string>
<string name="screen_roomlist_filter_favourites">"Αγαπημένα"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Μπορείς να προσθέσεις μια συνομιλία στα αγαπημένα σου στις ρυθμίσεις συνομιλίας.
Προς το παρόν, μπορείς να καταργήσεις την επιλογή φίλτρων για να δεις τις άλλες συνομιλίες σου"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Δεν έχεις ακόμα αγαπημένες συνομιλίες"</string>
<string name="screen_roomlist_filter_invites">"Προσκλήσεις"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Δεν έχεις εκκρεμείς προσκλήσεις."</string>
<string name="screen_roomlist_filter_low_priority">"Χαμηλής Προτεραιότητας"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Μπορείς να καταργήσεις την επιλογή φίλτρων για να δεις τις άλλες συνομιλίες σου"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Δεν έχεις συνομιλίες για αυτήν την επιλογή"</string>
<string name="screen_roomlist_filter_people">"Άτομα"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Δεν έχεις ακόμα ΠΜ"</string>
<string name="screen_roomlist_filter_rooms">"Αίθουσες"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Δεν είστε ακόμα σε κάποια αίθουσα"</string>
<string name="screen_roomlist_filter_unreads">"Μη αναγνωσμένα"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Συγχαρητήρια!
Δεν έχεις μη αναγνωσμένα μηνύματα!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Το αίτημα συμμετοχής στάλθηκε"</string>
<string name="screen_roomlist_main_space_title">"Συνομιλίες"</string>
<string name="screen_roomlist_mark_as_read">"Επισήμανση ως αναγνωσμένου"</string>
<string name="screen_roomlist_mark_as_unread">"Επισήμανση ως μη αναγνωσμένου"</string>
<string name="screen_roomlist_tombstoned_room_description">"Αυτή η αίθουσα έχει αναβαθμιστεί"</string>
<string name="session_verification_banner_message">"Φαίνεται ότι χρησιμοποιείς μια νέα συσκευή. Επαλήθευσε με άλλη συσκευή για πρόσβαση στα κρυπτογραφημένα σου μηνύματα."</string>
<string name="session_verification_banner_title">"Επαλήθευσε ότι είσαι εσύ"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_roomlist_filter_favourites">"Favorites"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"You can add a chat to your favorites in the chat settings.
For now, you can deselect filters in order to see your other chats"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"You dont have favorite chats yet"</string>
</resources>

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_content">"Recupera tu identidad criptográfica y tu historial de mensajes con una clave de recuperación si has perdido todos tus dispositivos actuales."</string>
<string name="banner_set_up_recovery_submit">"Configurar la recuperación"</string>
<string name="banner_set_up_recovery_title">"Configura la recuperación para proteger tu cuenta"</string>
<string name="confirm_recovery_key_banner_message">"Confirma tu clave de recuperación para mantener el acceso a tu almacén de claves y al historial de mensajes."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Introduce tu clave de recuperación"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"¿Olvidaste tu clave de recuperación?"</string>
<string name="confirm_recovery_key_banner_title">"Tu almacén de claves no está sincronizado"</string>
<string name="full_screen_intent_banner_message">"Para asegurarte de que nunca te pierdas una llamada importante, modifica tus ajustes para permitir notificaciones a pantalla completa cuando el teléfono esté bloqueado."</string>
<string name="full_screen_intent_banner_title">"Mejora tu experiencia de llamada"</string>
<string name="screen_invites_decline_chat_message">"¿Estás seguro de que quieres rechazar la invitación a unirte a %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Rechazar la invitación"</string>
<string name="screen_invites_decline_direct_chat_message">"¿Estás seguro de que quieres rechazar este chat privado con %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Rechazar el chat"</string>
<string name="screen_invites_empty_list">"Sin invitaciones"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) te invitó"</string>
<string name="screen_migration_message">"Este proceso solo se hace una vez, gracias por esperar."</string>
<string name="screen_migration_title">"Configura tu cuenta"</string>
<string name="screen_roomlist_a11y_create_message">"Crear una nueva conversación o sala"</string>
<string name="screen_roomlist_empty_message">"Empieza enviando un mensaje a alguien."</string>
<string name="screen_roomlist_empty_title">"Aún no hay chats."</string>
<string name="screen_roomlist_filter_favourites">"Favoritos"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Puedes añadir un chat a tus favoritos en la configuración del chat.
Por ahora, puedes deseleccionar los filtros para ver tus otros chats"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Aún no tienes chats favoritos"</string>
<string name="screen_roomlist_filter_invites">"Invitaciones"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"No tienes ninguna invitación pendiente."</string>
<string name="screen_roomlist_filter_low_priority">"Prioridad baja"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Puedes deseleccionar filtros para ver tus otros chats."</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"No tienes chats para esta selección"</string>
<string name="screen_roomlist_filter_people">"Personas"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Todavía no tienes ningún mensaje directo"</string>
<string name="screen_roomlist_filter_rooms">"Salas"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Todavía no estás en ninguna sala"</string>
<string name="screen_roomlist_filter_unreads">"No leídos"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"¡Felicidades!
¡No tienes ningún mensaje sin leer!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Solicitud de unión enviada"</string>
<string name="screen_roomlist_main_space_title">"Chats"</string>
<string name="screen_roomlist_mark_as_read">"Marcar como leído"</string>
<string name="screen_roomlist_mark_as_unread">"Marcar como no leído"</string>
<string name="session_verification_banner_message">"Parece que estás usando un nuevo dispositivo. Verifica que eres tú para acceder a tus mensajes cifrados."</string>
<string name="session_verification_banner_title">"Verifica que eres tú"</string>
</resources>

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_battery_optimization_content_android">"Kui tahad olla kindel, et näed õigel ajal kõiki teavitusi, siis palun lülita akukasutuse optimeerimine välja."</string>
<string name="banner_battery_optimization_submit_android">"Lülita akukasutuse optimeerimine välja"</string>
<string name="banner_battery_optimization_title_android">"Sa ei näe kõiki teavitusi?"</string>
<string name="banner_set_up_recovery_content">"Loo uus taastevõti, mida saad kasutada oma krüptitud sõnumite ajaloo taastamisel olukorras, kus kaotad ligipääsu oma seadmetele."</string>
<string name="banner_set_up_recovery_submit">"Seadista andmete taastamine"</string>
<string name="banner_set_up_recovery_title">"Seadista taastamine"</string>
<string name="confirm_recovery_key_banner_message">"Säilitamaks ligipääsu vestluste ja krüptovõtmete varukoopiale, palun sisesta kinnituseks oma taastevõti."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Sisesta oma taastevõti"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Kas unustasid oma taastevõtme?"</string>
<string name="confirm_recovery_key_banner_title">"Sinu võtmehoidla pole sünkroonis"</string>
<string name="full_screen_intent_banner_message">"Selleks, et sul ainsamgi tähtis kõne ei jääks märkamata, siis palun muuda oma nutiseadme seadistusi nii, et lukustusvaates oleksid täisekraani mõõtu teavitused."</string>
<string name="full_screen_intent_banner_title">"Sinu tõhusad telefonikõned"</string>
<string name="screen_invites_decline_chat_message">"Kas sa oled kindel, et soovid keelduda liitumiskutsest: %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Lükka kutse tagasi"</string>
<string name="screen_invites_decline_direct_chat_message">"Kas sa oled kindel, et soovid keelduda privaatsest vestlusest kasutajaga %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Keeldu vestlusest"</string>
<string name="screen_invites_empty_list">"Kutseid pole"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) saatis sulle kutse"</string>
<string name="screen_migration_message">"Tänud, et ootad - seda toimingut on vaja teha vaid üks kord."</string>
<string name="screen_migration_title">"Seadistame sinu kasutajakontot."</string>
<string name="screen_roomlist_a11y_create_message">"Loo uus vestlus või jututuba"</string>
<string name="screen_roomlist_clear_filters">"Tühjenda filtrid"</string>
<string name="screen_roomlist_empty_message">"Alustamiseks saada kellelegi sõnum."</string>
<string name="screen_roomlist_empty_title">"Veel pole vestlusi."</string>
<string name="screen_roomlist_filter_favourites">"Lemmikud"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Vestluse seadistusest saad ta määrata lemmikuks.
Aga seni… oma teiste vestluste nägemiseks pead eemaldama filtrid"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Sul veel pole lemmikvestlusi"</string>
<string name="screen_roomlist_filter_invites">"Kutsed"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Sul pole ootel kutseid."</string>
<string name="screen_roomlist_filter_low_priority">"Vähetähtis"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Oma teiste vestluste nägemiseks sa pead filtrid eemaldama"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Selle valiku jaoks sul veel pole vestlusi"</string>
<string name="screen_roomlist_filter_people">"Inimesed"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Sul pole veel otsevestlusi"</string>
<string name="screen_roomlist_filter_rooms">"Jututoad"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Sa veel ei osale mitte üheski jututoas"</string>
<string name="screen_roomlist_filter_unreads">"Lugemata"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Õnnitleme!
Sul pole ühtegi lugemata sõnumit!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Liitumispalve on saadetud"</string>
<string name="screen_roomlist_main_space_title">"Vestlused"</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="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

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_submit">"Konfiguratu berreskurapena"</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Sartu zure berreskuratze-gakoa"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Berreskuratze-gakoa ahaztu al duzu?"</string>
<string name="full_screen_intent_banner_message">"Dei garrantzitsurik galduko ez duzula ziurtatzeko, aldatu ezarpenak telefonoa blokeatuta dagoenean pantaila osoko jakinarazpenak baimentzeko."</string>
<string name="full_screen_intent_banner_title">"Hobetu deien esperientzia"</string>
<string name="screen_invites_decline_chat_message">"Ziur %1$s(e)ra batzeko gonbidapena baztertu nahi duzula?"</string>
<string name="screen_invites_decline_chat_title">"Baztertu gonbidapena"</string>
<string name="screen_invites_decline_direct_chat_message">"Ziur %1$s(r)en txat pribatua baztertu nahi duzula?"</string>
<string name="screen_invites_decline_direct_chat_title">"Baztertu txata"</string>
<string name="screen_invites_empty_list">"Ez dago gonbidapenik"</string>
<string name="screen_invites_invited_you">"%1$s(e)k (%2$s) gonbidatu zaitu"</string>
<string name="screen_migration_message">"Behin egin beharreko prozesua da; eskerrik asko itxaroteagatik."</string>
<string name="screen_migration_title">"Zure kontua konfiguratzen."</string>
<string name="screen_roomlist_a11y_create_message">"Sortu elkarrizketa edo gela berria"</string>
<string name="screen_roomlist_empty_message">"Hasi norbaiti mezuak bidaltzen."</string>
<string name="screen_roomlist_empty_title">"Oraindik ez dago txatik."</string>
<string name="screen_roomlist_filter_favourites">"Gogokoak"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Txatak gogokoetara gehi dezakezu txaten ezarpenetan.
Oraingoz, iragazkiak desautatu ditzakezu zure gainerako txatak ikusteko"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Oraindik ez duzu gogoko txatik"</string>
<string name="screen_roomlist_filter_invites">"Gonbidapenak"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Ez duzu gonbidapenik zain."</string>
<string name="screen_roomlist_filter_low_priority">"Lehentasun baxua"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Iragazkiak desautatu ditzakezu gainerako txatak ikusteko"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Ez duzu hautaketa betetzen duen txatik"</string>
<string name="screen_roomlist_filter_people">"Jendea"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Oraindik ez duzu Mezu Pribaturik"</string>
<string name="screen_roomlist_filter_rooms">"Gelak"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Oraindik ez zaude inolako gelatan"</string>
<string name="screen_roomlist_filter_unreads">"Irakurri gabeak"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Bejondeizula!
Ez duzu irakurri gabeko mezurik!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Sartzeko eskaera bidali da"</string>
<string name="screen_roomlist_main_space_title">"Txatak"</string>
<string name="screen_roomlist_mark_as_read">"Markatu irakurritzat"</string>
<string name="screen_roomlist_mark_as_unread">"Markatu irakurri gabetzat"</string>
<string name="session_verification_banner_message">"Gailu berri bat erabiltzen ari zarela dirudi. Egiaztatu beste gailu batekin enkriptatutako mezuak atzitzeko."</string>
<string name="session_verification_banner_title">"Egiaztatu zu zarela"</string>
</resources>

View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_submit">"برپایی بازیابی"</string>
<string name="banner_set_up_recovery_title">"برپایی بازیابی"</string>
<string name="confirm_recovery_key_banner_primary_button_title">"ورود کلید بازیابیتان"</string>
<string name="confirm_recovery_key_banner_title">"ذخیره‌ساز کلیدتان از هم‌گام بودن در آمده"</string>
<string name="full_screen_intent_banner_title">"بهبود تجریهٔ تماستان"</string>
<string name="screen_invites_decline_chat_message">"مطمئنید که می‌خواهید دعوت پیوستن به %1$s را رد کنید؟"</string>
<string name="screen_invites_decline_chat_title">"رد دعوت"</string>
<string name="screen_invites_decline_direct_chat_message">"مطمئنید که می‌خواهید این گپ خصوصی با %1$s را رد کنید؟"</string>
<string name="screen_invites_decline_direct_chat_title">"رد گپ"</string>
<string name="screen_invites_empty_list">"بدون دعوت"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) دعوتتان کرد"</string>
<string name="screen_migration_message">"فرایندی یک باره است. ممنون از شکیباییتان."</string>
<string name="screen_migration_title">"برپایی حسابتان."</string>
<string name="screen_roomlist_a11y_create_message">"ایجاد اتاق یا گفت‌وگویی جدید"</string>
<string name="screen_roomlist_empty_message">"آغاز با پیام دادن به کسی."</string>
<string name="screen_roomlist_empty_title">"هنوز گپی وجود ندارد."</string>
<string name="screen_roomlist_filter_favourites">"علاقه‌مندی‌ها"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"هنوز هیچ گپ مورد علاقه‌ای ندارید"</string>
<string name="screen_roomlist_filter_invites">"دعوت‌ها"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"هیچ دعوت منتظری ندارید."</string>
<string name="screen_roomlist_filter_low_priority">"اولویت کم"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"می توانید پالایه‌ها را برای دیدن دیگر گپ‌هایتان بردارید"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"هیچ گپی برای این گزینش ندارید"</string>
<string name="screen_roomlist_filter_people">"افراد"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"هنوز هیچ پیام مستقیمی ندارید"</string>
<string name="screen_roomlist_filter_rooms">"اتاق‌ها"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"هنوز در هیچ اتاقی نیستید"</string>
<string name="screen_roomlist_filter_unreads">"نخوانده‌ها"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"تبریک!
هیچ پیام نخوانده‌ای ندارید!"</string>
<string name="screen_roomlist_knock_event_sent_description">"درخواست پیوستن فرستاده شد"</string>
<string name="screen_roomlist_main_space_title">"گپ‌ها"</string>
<string name="screen_roomlist_mark_as_read">"علامت‌گذاری به عنوان خوانده شده"</string>
<string name="screen_roomlist_mark_as_unread">"نشان به ناخوانده"</string>
<string name="session_verification_banner_title">"تأیید کنید که خودتانید"</string>
</resources>

View file

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_content">"Palauta kryptografinen identiteettisi ja viestihistoriasi palautusavaimella, mikäli menetät pääsyn kaikkiin laitteisiisi."</string>
<string name="banner_set_up_recovery_submit">"Ota palautus käyttöön"</string>
<string name="banner_set_up_recovery_title">"Ota palautus käyttöön tilisi suojaamiseksi"</string>
<string name="confirm_recovery_key_banner_message">"Vahvista palautusavaimesi, jotta pääset edelleen käyttämään avainten säilytystä ja viestihistoriaa."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Syötä palautusavaimesi"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Unohditko palautusavaimesi?"</string>
<string name="confirm_recovery_key_banner_title">"Avainten säilytys ei ole synkronoitu"</string>
<string name="full_screen_intent_banner_message">"Salli koko näytön ilmoitukset, kun laite on lukittu, jos et halua koskaan missata tärkeää puhelua."</string>
<string name="full_screen_intent_banner_title">"Paranna puhelukokemustasi"</string>
<string name="screen_invites_decline_chat_message">"Haluatko varmasti hylätä kutsun liittyä %1$s -huoneeseen?"</string>
<string name="screen_invites_decline_chat_title">"Hylkää kutsu"</string>
<string name="screen_invites_decline_direct_chat_message">"Haluatko varmasti hylätä kutsun yksityiseen keskusteluun käyttäjän %1$s kanssa?"</string>
<string name="screen_invites_decline_direct_chat_title">"Hylkää keskustelu"</string>
<string name="screen_invites_empty_list">"Ei kutsuja"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) kutsui sinut"</string>
<string name="screen_migration_message">"Tämä on kertaluonteinen prosessi, kiitos odottamisesta."</string>
<string name="screen_migration_title">"Tiliä määritetään."</string>
<string name="screen_roomlist_a11y_create_message">"Luo uusi keskustelu tai huone"</string>
<string name="screen_roomlist_empty_message">"Aloita lähettämällä viesti jollekin."</string>
<string name="screen_roomlist_empty_title">"Sinulla ei ole vielä keskusteluja."</string>
<string name="screen_roomlist_filter_favourites">"Suosikit"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Voit lisätä keskustelun suosikkeihisi keskustelun asetuksissa.
Toistaiseksi voit poistaa suodattimien valinnan, jotta näet muut keskustelut."</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Sinulla ei ole vielä suosikkikeskusteluja"</string>
<string name="screen_roomlist_filter_invites">"Kutsut"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Sinulla ei ole yhtään odottavaa kutsua."</string>
<string name="screen_roomlist_filter_low_priority">"Matala prioriteetti"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Voit poistaa suodattimien valinnan nähdäksesi muut keskustelusi."</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Sinulla ei ole sopivia keskusteluja tähän valintaan"</string>
<string name="screen_roomlist_filter_people">"Ihmiset"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Sinulla ei ole vielä yhtään yksityisviestiä"</string>
<string name="screen_roomlist_filter_rooms">"Huoneet"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Et ole vielä missään huoneessa"</string>
<string name="screen_roomlist_filter_unreads">"Lukemattomat"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Onnittelut!
Sinulla ei ole lukemattomia viestejä!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Liittymispyyntö lähetetty"</string>
<string name="screen_roomlist_main_space_title">"Keskustelut"</string>
<string name="screen_roomlist_mark_as_read">"Merkitse luetuksi"</string>
<string name="screen_roomlist_mark_as_unread">"Merkitse lukemattomaksi"</string>
<string name="screen_roomlist_tombstoned_room_description">"Tämä huone on päivitetty"</string>
<string name="session_verification_banner_message">"Vaikuttaisi siltä, että käytät uutta laitetta. Vahvista toisella laitteella nähdäksesi salatut viestit."</string>
<string name="session_verification_banner_title">"Vahvista, että se olet sinä"</string>
</resources>

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_battery_optimization_content_android">"Désactivez loptimisation de la batterie pour cette application afin de vous assurer que toutes les notifications sont reçues."</string>
<string name="banner_battery_optimization_submit_android">"Désactiver loptimisation"</string>
<string name="banner_battery_optimization_title_android">"Ils vous manque des notifications?"</string>
<string name="banner_set_up_recovery_content">"Générez une nouvelle clé de récupération qui peut être utilisée pour restaurer lhistorique de vos messages chiffrés au cas où vous perdriez laccès à vos appareils."</string>
<string name="banner_set_up_recovery_submit">"Configurer la sauvegarde"</string>
<string name="banner_set_up_recovery_title">"Configurer la récupération"</string>
<string name="confirm_recovery_key_banner_message">"Confirmez votre clé de récupération pour conserver laccès à votre stockage de clés et à lhistorique des messages."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Saisissez votre clé de récupération"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Clé de récupération oubliée ?"</string>
<string name="confirm_recovery_key_banner_title">"Le stockage de vos clés nest pas synchronisé"</string>
<string name="full_screen_intent_banner_message">"Afin de ne jamais manquer un appel important, veuillez modifier vos paramètres pour autoriser les notifications en plein écran lorsque votre appareil est verrouillé."</string>
<string name="full_screen_intent_banner_title">"Améliorez votre expérience dappel"</string>
<string name="screen_invites_decline_chat_message">"Êtes-vous sûr de vouloir décliner linvitation à rejoindre %1$s ?"</string>
<string name="screen_invites_decline_chat_title">"Refuser linvitation"</string>
<string name="screen_invites_decline_direct_chat_message">"Êtes-vous sûr de vouloir refuser cette discussion privée avec %1$s ?"</string>
<string name="screen_invites_decline_direct_chat_title">"Refuser linvitation"</string>
<string name="screen_invites_empty_list">"Aucune invitation"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) vous a invité(e)"</string>
<string name="screen_migration_message">"Il sagit dune opération ponctuelle, merci dattendre quelques instants."</string>
<string name="screen_migration_title">"Configuration de votre compte."</string>
<string name="screen_roomlist_a11y_create_message">"Créer une nouvelle discussion ou un nouveau salon"</string>
<string name="screen_roomlist_clear_filters">"Supprimer les filtres"</string>
<string name="screen_roomlist_empty_message">"Commencez par envoyer un message à quelquun."</string>
<string name="screen_roomlist_empty_title">"Aucune discussion pour le moment."</string>
<string name="screen_roomlist_filter_favourites">"Favoris"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Vous pouvez ajouter une discussion aux favoris depuis les paramètres de la discussion.
En attendant, vous pouvez désélectionner des filtres pour voir vos autres salons."</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Vous navez pas encore de discussions favorites"</string>
<string name="screen_roomlist_filter_invites">"Invitations"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Vous navez aucune invitation en attente."</string>
<string name="screen_roomlist_filter_low_priority">"Priorité basse"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Veuillez désélectionner des filtres pour voir vos discussions"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Vous navez pas de discussions pour cette sélection"</string>
<string name="screen_roomlist_filter_people">"Personnes"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Vous navez pas encore de discussions"</string>
<string name="screen_roomlist_filter_rooms">"Salons"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Vous nêtes membre daucun salon"</string>
<string name="screen_roomlist_filter_unreads">"Non-lus"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Félicitations !
Vous navez plus de messages non-lus !"</string>
<string name="screen_roomlist_knock_event_sent_description">"Demande de rejoindre le salon envoyée"</string>
<string name="screen_roomlist_main_space_title">"Conversations"</string>
<string name="screen_roomlist_mark_as_read">"Marquer comme lu"</string>
<string name="screen_roomlist_mark_as_unread">"Marquer comme non lu"</string>
<string name="screen_roomlist_tombstoned_room_description">"Ce salon a été mis à niveau."</string>
<string name="session_verification_banner_message">"Il semblerait que vous utilisiez un nouvel appareil. Vérifiez la session avec un autre de vos appareils pour accéder à vos messages chiffrés."</string>
<string name="session_verification_banner_title">"Vérifier que cest bien vous"</string>
</resources>

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_battery_optimization_content_android">"Kapcsolja ki az alkalmazás akkumulátor-optimalizálását, hogy biztosan megkapja az összes értesítést."</string>
<string name="banner_battery_optimization_submit_android">"Optimalizálás letiltása"</string>
<string name="banner_battery_optimization_title_android">"Nem érkeznek meg az értesítések?"</string>
<string name="banner_set_up_recovery_content">"Hozzon létre egy új helyreállítási kulcsot, amellyel visszaállíthatja a titkosított üzenetek előzményeit, ha elveszíti az eszközökhöz való hozzáférést."</string>
<string name="banner_set_up_recovery_submit">"Helyreállítás beállítása"</string>
<string name="banner_set_up_recovery_title">"Helyreállítás beállítása a fiókja védelméhez"</string>
<string name="confirm_recovery_key_banner_message">"Erősítse meg a helyreállítási kulcsát, hogy továbbra is hozzáférjen a kulcstárolójához és az üzenetelőzményekhez."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Adja meg a helyreállítási kulcsot"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Elfelejtette a helyreállítási kulcsot?"</string>
<string name="confirm_recovery_key_banner_title">"A kulcstároló nincs szinkronizálva"</string>
<string name="full_screen_intent_banner_message">"Hogy sose maradjon le egyetlen fontos hívásról sem, a beállításokban engedélyezze a teljes képernyős értesítéseket, amikor a telefon zárolva van."</string>
<string name="full_screen_intent_banner_title">"Fokozza a hívásélményét"</string>
<string name="screen_invites_decline_chat_message">"Biztos, hogy elutasítja a meghívást, hogy csatlakozzon ehhez: %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Meghívás elutasítása"</string>
<string name="screen_invites_decline_direct_chat_message">"Biztos, hogy elutasítja ezt a privát csevegést vele: %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Csevegés elutasítása"</string>
<string name="screen_invites_empty_list">"Nincsenek meghívások"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) meghívta"</string>
<string name="screen_migration_message">"Ez egy egyszeri folyamat, köszönjük a türelmét."</string>
<string name="screen_migration_title">"A fiók beállítása."</string>
<string name="screen_roomlist_a11y_create_message">"Új beszélgetés vagy szoba létrehozása"</string>
<string name="screen_roomlist_clear_filters">"Szűrők törlése"</string>
<string name="screen_roomlist_empty_message">"Kezdje azzal, hogy üzenetet küld valakinek."</string>
<string name="screen_roomlist_empty_title">"Még nincsenek csevegések."</string>
<string name="screen_roomlist_filter_favourites">"Kedvencek"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"A csevegési beállításokban csevegéseket adhat hozzá a kedvencekhez.
Egyelőre törölheti a szűrőket a többi csevegés megtekintéséhez."</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Még nincsenek kedvenc csevegései"</string>
<string name="screen_roomlist_filter_invites">"Meghívások"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Nincsenek függőben lévő meghívásai."</string>
<string name="screen_roomlist_filter_low_priority">"Alacsony prioritás"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Kikapcsolhatja a szűrőket a többi csevegés megtekintéséhez"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Ehhez a kiválasztáshoz nem tartoznak csevegések"</string>
<string name="screen_roomlist_filter_people">"Emberek"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Még nincsenek privát üzenetei"</string>
<string name="screen_roomlist_filter_rooms">"Szobák"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Még nincs egy szobában sem"</string>
<string name="screen_roomlist_filter_unreads">"Olvasatlan"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Gratulálunk!
Nincs olvasatlan üzenete!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Csatlakozási kérés elküldve"</string>
<string name="screen_roomlist_main_space_title">"Összes csevegés"</string>
<string name="screen_roomlist_mark_as_read">"Megjelölés olvasottként"</string>
<string name="screen_roomlist_mark_as_unread">"Megjelölés olvasatlanként"</string>
<string name="screen_roomlist_tombstoned_room_description">"A szoba verzióját frissítették"</string>
<string name="session_verification_banner_message">"Úgy tűnik, hogy új eszközt használ. Ellenőrizze egy másik eszközzel, hogy a továbbiakban elérje a titkosított üzeneteket."</string>
<string name="session_verification_banner_title">"Ellenőrizze, hogy Ön az"</string>
</resources>

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_content">"Buat kunci pemulihan baru yang dapat digunakan untuk memulihkan riwayat pesan terenkripsi Anda jika Anda kehilangan akses ke perangkat Anda."</string>
<string name="banner_set_up_recovery_submit">"Siapkan pemulihan"</string>
<string name="banner_set_up_recovery_title">"Siapkan pemulihan"</string>
<string name="confirm_recovery_key_banner_message">"Konfirmasikan kunci pemulihan Anda untuk mempertahankan akses ke penyimpanan kunci dan riwayat pesan Anda."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Masukkan kunci pemulihan Anda"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Lupa kunci pemulihan Anda?"</string>
<string name="confirm_recovery_key_banner_title">"Penyimpanan kunci Anda tidak sinkron"</string>
<string name="full_screen_intent_banner_message">"Untuk memastikan Anda tidak melewatkan panggilan penting, silakan ubah pengaturan Anda untuk memperbolehkan notifikasi layar penuh ketika ponsel Anda terkunci."</string>
<string name="full_screen_intent_banner_title">"Tingkatkan pengalaman panggilan Anda"</string>
<string name="screen_invites_decline_chat_message">"Apakah Anda yakin ingin menolak undangan untuk bergabung ke %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Tolak undangan"</string>
<string name="screen_invites_decline_direct_chat_message">"Apakah Anda yakin ingin menolak obrolan pribadi dengan %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Tolak obrolan"</string>
<string name="screen_invites_empty_list">"Tidak ada undangan"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) mengundang Anda"</string>
<string name="screen_migration_message">"Ini adalah proses satu kali, terima kasih telah menunggu."</string>
<string name="screen_migration_title">"Menyiapkan akun Anda."</string>
<string name="screen_roomlist_a11y_create_message">"Buat percakapan atau ruangan baru"</string>
<string name="screen_roomlist_empty_message">"Mulailah dengan mengirim pesan kepada seseorang."</string>
<string name="screen_roomlist_empty_title">"Belum ada obrolan."</string>
<string name="screen_roomlist_filter_favourites">"Favorit"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Anda dapat menambahkan percakapan ke favorit Anda dalam pengaturan percakapan.
Untuk sementara, Anda dapat membatalkan pilihan saringan untuk melihat percakapan Anda yang lain"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Anda belum memiliki percakapan favorit"</string>
<string name="screen_roomlist_filter_invites">"Undangan"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Anda tidak memiliki undangan yang tertunda."</string>
<string name="screen_roomlist_filter_low_priority">"Prioritas Rendah"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Anda dapat membatalkan pilihan saringan untuk melihat percakapan Anda yang lain"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Anda tidak memiliki percakapan untuk pemilihan ini"</string>
<string name="screen_roomlist_filter_people">"Orang"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Anda belum memiliki percakapan langsung"</string>
<string name="screen_roomlist_filter_rooms">"Ruangan"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Anda belum berada dalam ruangan"</string>
<string name="screen_roomlist_filter_unreads">"Belum dibaca"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Selamat!
Anda tidak memiliki pesan yang belum dibaca!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Permintaan untuk bergabung dikirim"</string>
<string name="screen_roomlist_main_space_title">"Semua Obrolan"</string>
<string name="screen_roomlist_mark_as_read">"Tandai sebagai dibaca"</string>
<string name="screen_roomlist_mark_as_unread">"Tandai sebagai belum dibaca"</string>
<string name="session_verification_banner_message">"Sepertinya Anda menggunakan perangkat baru. Verifikasi dengan perangkat lain untuk mengakses pesan terenkripsi Anda selanjutnya."</string>
<string name="session_verification_banner_title">"Verifikasi bahwa ini Anda"</string>
</resources>

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_content">"Genera una nuova chiave di recupero che può essere usata per ripristinare la cronologia dei messaggi crittografati nel caso in cui tu perda l\'accesso ai tuoi dispositivi."</string>
<string name="banner_set_up_recovery_submit">"Configura il recupero"</string>
<string name="banner_set_up_recovery_title">"Configura il ripristino"</string>
<string name="confirm_recovery_key_banner_message">"Conferma la chiave di recupero per mantenere l\'accesso all\'archiviazione delle chiavi e alla cronologia dei messaggi."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Inserisci la tua chiave di recupero"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Hai dimenticato la chiave di recupero?"</string>
<string name="confirm_recovery_key_banner_title">"L\'archiviazione delle chiavi non è sincronizzata"</string>
<string name="full_screen_intent_banner_message">"Per non perdere mai una chiamata importante, modifica le impostazioni per consentire le notifiche a schermo intero quando il telefono è bloccato."</string>
<string name="full_screen_intent_banner_title">"Migliora la tua esperienza di chiamata"</string>
<string name="screen_invites_decline_chat_message">"Vuoi davvero rifiutare l\'invito ad entrare in %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Rifiuta l\'invito"</string>
<string name="screen_invites_decline_direct_chat_message">"Vuoi davvero rifiutare questa conversazione privata con %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Rifiuta l\'invito alla conversazione"</string>
<string name="screen_invites_empty_list">"Nessun invito"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) ti ha invitato"</string>
<string name="screen_migration_message">"Si tratta di una procedura che si effettua una sola volta, grazie per l\'attesa."</string>
<string name="screen_migration_title">"Configurazione del tuo account."</string>
<string name="screen_roomlist_a11y_create_message">"Crea una nuova conversazione o stanza"</string>
<string name="screen_roomlist_empty_message">"Inizia inviando un messaggio a qualcuno."</string>
<string name="screen_roomlist_empty_title">"Ancora nessuna conversazione."</string>
<string name="screen_roomlist_filter_favourites">"Preferiti"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Puoi aggiungere una conversazione ai tuoi preferiti nelle impostazioni della stessa.
Per il momento, puoi deselezionare i filtri per vedere le altre conversazioni."</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Non hai ancora conversazioni preferite"</string>
<string name="screen_roomlist_filter_invites">"Inviti"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Non hai nessun invito in sospeso."</string>
<string name="screen_roomlist_filter_low_priority">"Bassa priorità"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Puoi deselezionare i filtri per vedere le altre conversazioni."</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Non hai conversazioni per questa selezione"</string>
<string name="screen_roomlist_filter_people">"Persone"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Non hai ancora nessuna conversazione diretta"</string>
<string name="screen_roomlist_filter_rooms">"Stanze"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Non sei ancora in nessuna stanza"</string>
<string name="screen_roomlist_filter_unreads">"Non letti"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Congratulazioni!
Non hai messaggi non letti!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Richiesta di accesso inviata"</string>
<string name="screen_roomlist_main_space_title">"Tutte le conversazioni"</string>
<string name="screen_roomlist_mark_as_read">"Segna come letto"</string>
<string name="screen_roomlist_mark_as_unread">"Segna come non letto"</string>
<string name="session_verification_banner_message">"Sembra che tu stia usando un nuovo dispositivo. Verificati con un altro dispositivo per accedere ai tuoi messaggi cifrati."</string>
<string name="session_verification_banner_title">"Verifica che sei tu"</string>
</resources>

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_submit">"აღდგენის დაყენება"</string>
<string name="confirm_recovery_key_banner_message">"დაადასტურეთ თქვენი აღდგენის გასაღები რათა გქონდეთ წვდომა გასაღებების დამგროვებელთან და შეტყობინებების ისტორიასთან."</string>
<string name="confirm_recovery_key_banner_title">"თქვენი გასაღების დამგროვებელი არაა სინქრონიზებული"</string>
<string name="screen_invites_decline_chat_message">"დარწმუნებული ხართ, რომ გსურთ, უარი თქვათ მოწვევაზე %1$s-ში?"</string>
<string name="screen_invites_decline_chat_title">"მოწვევაზე უარის თქმა"</string>
<string name="screen_invites_decline_direct_chat_message">"დარწმუნებული ხართ, რომ გსურთ, უარი თქვათ ჩატზე %1$s-თან?"</string>
<string name="screen_invites_decline_direct_chat_title">"ჩატზე უარის თქვა"</string>
<string name="screen_invites_empty_list">"მოწვევები არ არის"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) მოგიწვიათ"</string>
<string name="screen_migration_message">"ეს არის ერთჯერადი პროცესი, მადლობა ლოდინისთვის."</string>
<string name="screen_migration_title">"თქვენი ანგარიშის კონფიგურაცია"</string>
<string name="screen_roomlist_a11y_create_message">"ახალი საუბრისა ან ოთახის შექმნა"</string>
<string name="screen_roomlist_empty_message">"დაიწყეთ ვინმესთვის შეტყობინების გაგზავნით."</string>
<string name="screen_roomlist_empty_title">"არც ერთი ჩატი ჯერ არაა."</string>
<string name="screen_roomlist_filter_favourites">"რჩეულები"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"თქვენ შეგიძლიათ ოთახის რჩეულებში დამატება ოთახების პარამეტრებში.
ახლა კი შეგიძლიათ ფილტრების მოხსნა სხვა ოთახების გამოსაჩენად"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"თქვენ ჯერ არ გაქვთ რჩეული ჩატები"</string>
<string name="screen_roomlist_filter_invites">"მოწვევები"</string>
<string name="screen_roomlist_filter_low_priority">"დაბალი პრიორიტეტი"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"თქვენ შეგიძლიათ წაშალოთ ფილტრები სხვა ჩეთების გამოსაჩენად"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"თქვენ არ გაქვთ ოთახები ამ არჩევნისთვის"</string>
<string name="screen_roomlist_filter_people">"ხალხი"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"თქვენ ჯერ არ გაქვთ პირადი შეტყობინებები"</string>
<string name="screen_roomlist_filter_rooms">"ოთახები"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"თქვენ ჯერ არც ერთ ოთახში არ ხართ"</string>
<string name="screen_roomlist_filter_unreads">"წაუკითხავი"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"გილოცავთ!
თქვენ არ გაქვთ წაუკითხავი შეტყობინებები!"</string>
<string name="screen_roomlist_main_space_title">"ჩატები"</string>
<string name="screen_roomlist_mark_as_read">"წაკითხულად მონიშვნა"</string>
<string name="screen_roomlist_mark_as_unread">"წაუკითხავად მონიშვნა"</string>
<string name="session_verification_banner_message">"როგორც ჩანს, ახალ მოწყობილობას იყენებთ. დაადასტურეთ სხვა მოწყობილობით თქვენს დაშიფრულ შეტყობინებებზე წვდომისთვის."</string>
<string name="session_verification_banner_title">"დაადასტურეთ, რომ ეს თქვენ ხართ"</string>
</resources>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_decline_chat_message">"Ar tikrai norite atmesti kvietimą prisijungti prie %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Atmesti kvietimą"</string>
<string name="screen_invites_decline_direct_chat_message">"Ar tikrai norite atmesti šį privatų pokalbį su %1$s ?"</string>
<string name="screen_invites_decline_direct_chat_title">"Atmesti pokalbį"</string>
<string name="screen_invites_empty_list">"Jokių kvietimų"</string>
<string name="screen_invites_invited_you">"%1$s(%2$s) pakvietė Jus"</string>
<string name="screen_roomlist_a11y_create_message">"Sukurti naują pokalbį arba kambarį"</string>
<string name="screen_roomlist_filter_invites">"Kvietimai"</string>
<string name="screen_roomlist_filter_people">"Žmonės"</string>
<string name="screen_roomlist_main_space_title">"Pokalbiai"</string>
<string name="session_verification_banner_message">"Panašu, kad naudojate naują įrenginį. Patvirtinkite naudodami kitą įrenginį, kad galėtumėte pasiekti savo šifruotas žinutes."</string>
<string name="session_verification_banner_title">"Patvirtinkite, kad tai Jūs"</string>
</resources>

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_battery_optimization_content_android">"Deaktiver batterioptimalisering for denne appen for å sikre at alle varsler mottas."</string>
<string name="banner_battery_optimization_submit_android">"Deaktiver optimalisering"</string>
<string name="banner_battery_optimization_title_android">"Kommer ikke varslene frem?"</string>
<string name="banner_set_up_recovery_content">"Gjenopprett din kryptografiske identitet og meldingshistorikk med en gjenopprettingsnøkkel hvis du har mistet alle dine brukte enheter."</string>
<string name="banner_set_up_recovery_submit">"Konfigurer gjenoppretting"</string>
<string name="banner_set_up_recovery_title">"Konfigurer gjenoppretting for å beskytte kontoen din"</string>
<string name="confirm_recovery_key_banner_message">"Verifiser gjenopprettingsnøkkelen for å opprettholde tilgangen til nøkkellageret og meldingshistorikken."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Skriv inn gjenopprettingsnøkkelen din"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Har du glemt din gjenopprettingsnøkkel?"</string>
<string name="confirm_recovery_key_banner_title">"Nøkkellagringen din er ikke synkronisert"</string>
<string name="full_screen_intent_banner_message">"For å sikre at du aldri går glipp av en viktig samtale, må du endre innstillingene dine for å tillate fullskjermvarsler når telefonen er låst."</string>
<string name="full_screen_intent_banner_title">"Forbedre samtaleopplevelsen din"</string>
<string name="screen_invites_decline_chat_message">"Er du sikker på at du vil takke nei til invitasjonen til å bli med i %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Avvis invitasjon"</string>
<string name="screen_invites_decline_direct_chat_message">"Er du sikker på at du vil avslå denne private chatten med %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Avslå chat"</string>
<string name="screen_invites_empty_list">"Ingen invitasjoner"</string>
<string name="screen_invites_invited_you">"%1$s(%2$s) inviterte deg"</string>
<string name="screen_migration_message">"Dette er en engangsprosess, takk for at du venter."</string>
<string name="screen_migration_title">"Setter opp kontoen din."</string>
<string name="screen_roomlist_a11y_create_message">"Opprett en ny samtale eller et nytt rom"</string>
<string name="screen_roomlist_empty_message">"Kom i gang med å sende meldinger til noen."</string>
<string name="screen_roomlist_empty_title">"Ingen chatter ennå."</string>
<string name="screen_roomlist_filter_favourites">"Favoritter"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Du kan legge til en chat blant favorittene dine i chat-innstillingene.
Inntil videre kan du velge bort filtre for å se de andre chattene dine"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Du har ikke favorittchatter ennå"</string>
<string name="screen_roomlist_filter_invites">"Invitasjoner"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Du har ingen ventende invitasjoner."</string>
<string name="screen_roomlist_filter_low_priority">"Lav prioritet"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Du kan velge bort filtre for å se de andre chattene dine"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Du har ikke chatter for dette utvalget"</string>
<string name="screen_roomlist_filter_people">"Personer"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Du har ingen DM-er ennå"</string>
<string name="screen_roomlist_filter_rooms">"Rom"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Du er ikke i noe rom ennå"</string>
<string name="screen_roomlist_filter_unreads">"Uleste"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Gratulerer!
Du har ingen uleste meldinger!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Forespørsel om å bli med sendt"</string>
<string name="screen_roomlist_main_space_title">"Chatter"</string>
<string name="screen_roomlist_mark_as_read">"Marker som lest"</string>
<string name="screen_roomlist_mark_as_unread">"Merk som ulest"</string>
<string name="screen_roomlist_tombstoned_room_description">"Dette rommet har blitt oppgradert"</string>
<string name="session_verification_banner_message">"Det ser ut til at du bruker en ny enhet. Bekreft med en annen enhet for å få tilgang til de krypterte meldingene dine."</string>
<string name="session_verification_banner_title">"Bekreft at det er deg"</string>
</resources>

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_content">"Herstel je cryptografische identiteit en berichtengeschiedenis met een herstelsleutel voor als je al je bestaande apparaten kwijt bent."</string>
<string name="banner_set_up_recovery_submit">"Herstelmogelijkheid instellen"</string>
<string name="banner_set_up_recovery_title">"Herstel instellen om je account te beschermen"</string>
<string name="confirm_recovery_key_banner_message">"Bevestig je herstelsleutel om toegang te houden tot je sleutelopslag en berichtengeschiedenis."</string>
<string name="confirm_recovery_key_banner_title">"Je sleutelopslag is niet gesynchroniseerd"</string>
<string name="full_screen_intent_banner_message">"Pas je instellingen aan om meldingen op het volledige scherm toe te staan wanneer de telefoon is vergrendeld. Zo mis je nooit een belangrijk gesprek."</string>
<string name="full_screen_intent_banner_title">"Verbeter je gesprekservaring"</string>
<string name="screen_invites_decline_chat_message">"Weet je zeker dat je de uitnodiging om toe te treden tot %1$s wilt weigeren?"</string>
<string name="screen_invites_decline_chat_title">"Uitnodiging weigeren"</string>
<string name="screen_invites_decline_direct_chat_message">"Weet je zeker dat je deze privéchat met %1$s wilt weigeren?"</string>
<string name="screen_invites_decline_direct_chat_title">"Chat weigeren"</string>
<string name="screen_invites_empty_list">"Geen uitnodigingen"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) heeft je uitgenodigd"</string>
<string name="screen_migration_message">"Dit is een eenmalig proces, bedankt voor het wachten."</string>
<string name="screen_migration_title">"Je account instellen."</string>
<string name="screen_roomlist_a11y_create_message">"Begin een nieuw gesprek of maak een nieuwe kamer"</string>
<string name="screen_roomlist_empty_message">"Ga aan de slag door iemand een bericht te sturen."</string>
<string name="screen_roomlist_empty_title">"Nog geen chats."</string>
<string name="screen_roomlist_filter_favourites">"Favorieten"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Je kunt een chat toevoegen aan je favorieten in de chatinstellingen.
Voor nu kun je filters deselecteren om je andere chats te zien"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Je hebt nog geen favoriete chats"</string>
<string name="screen_roomlist_filter_invites">"Uitnodigingen"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Je hebt geen openstaande uitnodigingen."</string>
<string name="screen_roomlist_filter_low_priority">"Lage prioriteit"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Je kunt filters deselecteren om je andere chats te zien"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Je hebt geen chats voor deze selectie"</string>
<string name="screen_roomlist_filter_people">"Personen"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Je hebt nog geen directe chats"</string>
<string name="screen_roomlist_filter_rooms">"Kamers"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Je zit nog niet in een kamer"</string>
<string name="screen_roomlist_filter_unreads">"Ongelezen"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Gefeliciteerd!
Je hebt geen ongelezen berichten!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Verzoek om toe te treden verzonden"</string>
<string name="screen_roomlist_main_space_title">"Chats"</string>
<string name="screen_roomlist_mark_as_read">"Markeren als gelezen"</string>
<string name="screen_roomlist_mark_as_unread">"Markeren als ongelezen"</string>
<string name="session_verification_banner_message">"Het lijkt erop dat je een nieuw apparaat gebruikt. Verifieer met een ander apparaat om toegang te krijgen tot je versleutelde berichten."</string>
<string name="session_verification_banner_title">"Verifieer dat jij het bent"</string>
</resources>

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_content">"Wygeneruj nowy klucz przywracania, którego można użyć do przywrócenia historii wiadomości szyfrowanych w przypadku utraty dostępu do swoich urządzeń."</string>
<string name="banner_set_up_recovery_submit">"Skonfiguruj przywracanie"</string>
<string name="banner_set_up_recovery_title">"Skonfiguruj przywracanie"</string>
<string name="confirm_recovery_key_banner_message">"Potwierdź klucz przywracania, aby zachować dostęp do magazynu kluczy i historii wiadomości."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Wprowadź klucz przywracania"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Zapomniałeś klucza przywracania?"</string>
<string name="confirm_recovery_key_banner_title">"Magazyn kluczy nie jest zsynchronizowany"</string>
<string name="full_screen_intent_banner_message">"Upewnij się, że nie pominiesz żadnego połączenia. Zmień swoje ustawienia i zezwól na powiadomienia na blokadzie ekranu."</string>
<string name="full_screen_intent_banner_title">"Popraw jakość swoich rozmów"</string>
<string name="screen_invites_decline_chat_message">"Czy na pewno chcesz odrzucić zaproszenie dołączenia do %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Odrzuć zaproszenie"</string>
<string name="screen_invites_decline_direct_chat_message">"Czy na pewno chcesz odrzucić rozmowę prywatną z %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Odrzuć czat"</string>
<string name="screen_invites_empty_list">"Brak zaproszeń"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) zaprosił Cię"</string>
<string name="screen_migration_message">"Jest to jednorazowy proces, dziękujemy za czekanie."</string>
<string name="screen_migration_title">"Konfigurowanie Twojego konta."</string>
<string name="screen_roomlist_a11y_create_message">"Utwórz nową rozmowę lub pokój"</string>
<string name="screen_roomlist_empty_message">"Wyślij komuś wiadomość, aby rozpocząć."</string>
<string name="screen_roomlist_empty_title">"Brak czatów."</string>
<string name="screen_roomlist_filter_favourites">"Ulubione"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Możesz dodać czat do ulubionych w ustawieniach czatu.
Na razie możesz wyczyścić filtry, aby zobaczyć pozostałe czaty"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Nie masz jeszcze ulubionych czatów"</string>
<string name="screen_roomlist_filter_invites">"Zaproszenia"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Nie masz żadnych oczekujących zaproszeń."</string>
<string name="screen_roomlist_filter_low_priority">"Niski priorytet"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Wyczyść filtry, aby zobaczyć pozostałe czaty"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Brak czatów dla podanych kryteriów"</string>
<string name="screen_roomlist_filter_people">"Osoby"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Nie masz jeszcze żadnych PW"</string>
<string name="screen_roomlist_filter_rooms">"Pokoje"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Nie jesteś jeszcze w żadnym pokoju"</string>
<string name="screen_roomlist_filter_unreads">"Nieprzeczytane"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Gratulacje!
Nie masz żadnych nieprzeczytanych wiadomości!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Wysłano prośbę o dołączenie"</string>
<string name="screen_roomlist_main_space_title">"Wszystkie czaty"</string>
<string name="screen_roomlist_mark_as_read">"Oznacz jako przeczytane"</string>
<string name="screen_roomlist_mark_as_unread">"Oznacz jako nieprzeczytane"</string>
<string name="session_verification_banner_message">"Wygląda na to, że używasz nowego urządzenia. Zweryfikuj się innym urządzeniem, aby uzyskać dostęp do zaszyfrowanych wiadomości."</string>
<string name="session_verification_banner_title">"Potwierdź, że to Ty"</string>
</resources>

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_content">"Recupere sua identidade criptográfica e o histórico de mensagens com uma chave de recuperação se você tiver perdido todos os dispositivos existentes."</string>
<string name="banner_set_up_recovery_submit">"Configurar a recuperação"</string>
<string name="banner_set_up_recovery_title">"Configure a recuperação para proteger sua conta"</string>
<string name="confirm_recovery_key_banner_message">"Confirme sua chave de recuperação para manter o acesso ao seu armazenamento de chaves e histórico de mensagens."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Digite sua chave de recuperação"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Esqueceu sua chave de recuperação?"</string>
<string name="confirm_recovery_key_banner_title">"Seu armazenamento de chaves está fora de sincronia"</string>
<string name="full_screen_intent_banner_message">"Para garantir que você nunca perca uma chamada importante, por favor altere as suas configurações para permitir notificações em tela cheia enquanto o seu celular estiver bloqueado."</string>
<string name="full_screen_intent_banner_title">"Melhore a sua experiência de chamadas"</string>
<string name="screen_invites_decline_chat_message">"Tem certeza de que deseja recusar o convite para ingressar em %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Recusar convite"</string>
<string name="screen_invites_decline_direct_chat_message">"Tem certeza de que deseja recusar esse chat privado com %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Recusar chat"</string>
<string name="screen_invites_empty_list">"Sem convites"</string>
<string name="screen_invites_invited_you">"%1$s(%2$s) convidou você"</string>
<string name="screen_migration_message">"Este é um processo único, obrigado por esperar."</string>
<string name="screen_migration_title">"Configurando sua conta."</string>
<string name="screen_roomlist_a11y_create_message">"Criar uma nova conversa ou sala"</string>
<string name="screen_roomlist_empty_message">"Comece enviando uma mensagem para alguém."</string>
<string name="screen_roomlist_empty_title">"Ainda não há conversas."</string>
<string name="screen_roomlist_filter_favourites">"Favoritos"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Você pode adicionar um bate-papo aos seus favoritos nas configurações de bate-papo.
Por enquanto, você pode desmarcar os filtros para ver seus outros bate-papos"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Você não tem nenhuma conversa favorita ainda"</string>
<string name="screen_roomlist_filter_invites">"Convites"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Você não tem nenhum convite pendente."</string>
<string name="screen_roomlist_filter_low_priority">"Baixa prioridade"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Você pode desmarcar filtros para ver suas outras conversas"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Você não tem conversas para esta seleção"</string>
<string name="screen_roomlist_filter_people">"Pessoas"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Você não tem nenhum conversa privada ainda"</string>
<string name="screen_roomlist_filter_rooms">"Salas"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Você não está em nenhuma sala ainda"</string>
<string name="screen_roomlist_filter_unreads">"Não lidos"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Parabéns!
Você não tem nenhuma mensagem não lida!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Pedido de adesão enviado"</string>
<string name="screen_roomlist_main_space_title">"Conversas"</string>
<string name="screen_roomlist_mark_as_read">"Marcar como lido"</string>
<string name="screen_roomlist_mark_as_unread">"Marcar como não lido"</string>
<string name="session_verification_banner_message">"Parece que você está usando um novo dispositivo. Verifique com outro dispositivo para acessar suas mensagens criptografadas."</string>
<string name="session_verification_banner_title">"Verifique se é você"</string>
</resources>

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_battery_optimization_content_android">"Desativa as otimizações de bateria para esta aplicação, de modo a garantir que todas as notificações chegam."</string>
<string name="banner_battery_optimization_submit_android">"Desativar otimizações"</string>
<string name="banner_battery_optimization_title_android">"As notificações não chegam?"</string>
<string name="banner_set_up_recovery_content">"Recupera a tua identidade criptográfica e o histórico de mensagens com uma chave de recuperação se tiveres perdido todos os teus dispositivos existentes."</string>
<string name="banner_set_up_recovery_submit">"Configurar recuperação"</string>
<string name="banner_set_up_recovery_title">"Configurar a recuperação"</string>
<string name="confirm_recovery_key_banner_message">"Confirma a tua chave de recuperação para manteres o acesso ao teu armazenamento de chaves e ao histórico de mensagens."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Introduz a tua chave de recuperação"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Esqueceste-te da tua chave de recuperação?"</string>
<string name="confirm_recovery_key_banner_title">"O teu armazenamento de chaves não está sincronizado"</string>
<string name="full_screen_intent_banner_message">"Para garantir que nunca perdes uma chamada importante, altera as configurações para permitir notificações em ecrã inteiro quando o telemóvel está bloqueado."</string>
<string name="full_screen_intent_banner_title">"Melhora a tua experiência de chamada"</string>
<string name="screen_invites_decline_chat_message">"Tens a certeza que queres rejeitar o convite para entra em %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Rejeitar convite"</string>
<string name="screen_invites_decline_direct_chat_message">"Tem a certeza que queres rejeitar esta conversa privada com %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Rejeitar conversa"</string>
<string name="screen_invites_empty_list">"Sem convites"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) convidou-te"</string>
<string name="screen_migration_message">"Este processo só acontece uma única vez, obrigado por esperares."</string>
<string name="screen_migration_title">"A configurar a tua conta…"</string>
<string name="screen_roomlist_a11y_create_message">"Criar uma nova conversa ou sala"</string>
<string name="screen_roomlist_clear_filters">"Limpar filtros"</string>
<string name="screen_roomlist_empty_message">"Começa por enviar uma mensagem a alguém."</string>
<string name="screen_roomlist_empty_title">"Ainda não tens conversas."</string>
<string name="screen_roomlist_filter_favourites">"Favoritas"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Podes adicionar uma conversa às tuas favoritas nas suas configurações.
Por enquanto, podes anular a seleção dos filtros para veres as tuas outras conversas"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Ainda não tens nenhuma conversa favorita"</string>
<string name="screen_roomlist_filter_invites">"Convites"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Não tens nenhum convite pendente."</string>
<string name="screen_roomlist_filter_low_priority">"Prioridade baixa"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Podes anular a seleção dos filtros para veres as tuas outras conversas"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Não tens nenhuma conversa selecionada"</string>
<string name="screen_roomlist_filter_people">"Pessoas"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Ainda não tens nenhuma MD (mensagem direta)"</string>
<string name="screen_roomlist_filter_rooms">"Salas"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Ainda não estás em nenhuma sala"</string>
<string name="screen_roomlist_filter_unreads">"Por ler"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Parabéns!
Não tens nenhuma mensagem por ler!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Pedido de adesão enviado"</string>
<string name="screen_roomlist_main_space_title">"Conversas"</string>
<string name="screen_roomlist_mark_as_read">"Marcar como lida"</string>
<string name="screen_roomlist_mark_as_unread">"Marcar como não lida"</string>
<string name="screen_roomlist_tombstoned_room_description">"Esta sala foi atualizada"</string>
<string name="session_verification_banner_message">"Parece que estás a utilizar um novo dispositivo. Verifica-o com um outro para poderes aceder às tuas mensagens cifradas."</string>
<string name="session_verification_banner_title">"Verifica que és tu"</string>
</resources>

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_content">"Recuperați-vă identitatea criptografică și istoricul mesajelor cu o cheie de recuperare dacă ați pierdut toate dispozitivele existente."</string>
<string name="banner_set_up_recovery_submit">"Configurați recuperarea"</string>
<string name="banner_set_up_recovery_title">"Configurați recuperarea pentru a vă proteja contul"</string>
<string name="confirm_recovery_key_banner_message">"Backup-ul pentru chat nu este sincronizat în prezent. Trebuie să confirmați cheia de recuperare pentru a menține accesul la backup."</string>
<string name="confirm_recovery_key_banner_title">"Backup-ul nu este sincronizat"</string>
<string name="full_screen_intent_banner_message">"Pentru a vă asigura că nu pierdeți niciodată un apel important, vă rugăm să modificați setările pentru a permite notificări fullscreen atunci când telefonul este blocat."</string>
<string name="full_screen_intent_banner_title">"Îmbunătățiți-vă experiența in timpul unui apel"</string>
<string name="screen_invites_decline_chat_message">"Sigur doriți să refuzați alăturarea la %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Refuzați invitația"</string>
<string name="screen_invites_decline_direct_chat_message">"Sigur doriți să refuzați conversațiile cu %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Refuzați conversația"</string>
<string name="screen_invites_empty_list">"Nicio invitație"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) v-a invitat."</string>
<string name="screen_migration_message">"Acesta este un proces care se desfășoară o singură dată, vă mulțumim pentru așteptare."</string>
<string name="screen_migration_title">"Contul dumneavoastră se configurează"</string>
<string name="screen_roomlist_a11y_create_message">"Creați o conversație sau o cameră nouă"</string>
<string name="screen_roomlist_empty_message">"Începeți prin a trimite mesaje cuiva."</string>
<string name="screen_roomlist_empty_title">"Nu există încă discuții."</string>
<string name="screen_roomlist_filter_favourites">"Favorite"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Puteți adăuga un chat la preferințele dvs. în setările de chat.
Deocamdată, puteți deselecta filtrele pentru a vedea celelalte chat-uri"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Încă nu aveți conversații preferate"</string>
<string name="screen_roomlist_filter_invites">"Invitații"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Nu aveți invitații în așteptare."</string>
<string name="screen_roomlist_filter_low_priority">"Prioritate scăzută"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Puteți deselecta filtrele pentru a vedea celelalte chat-uri"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Nu aveți chat-uri pentru această selecție"</string>
<string name="screen_roomlist_filter_people">"Persoane"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Încă nu aveți DM-uri"</string>
<string name="screen_roomlist_filter_rooms">"Camere"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Nu sunteți încă în nicio cameră"</string>
<string name="screen_roomlist_filter_unreads">"Necitite"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Felicitari!
Nu aveți mesaje necitite!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Cererea de alăturare a fost trimisă"</string>
<string name="screen_roomlist_main_space_title">"Toate conversatiile"</string>
<string name="screen_roomlist_mark_as_read">"Marcați ca citită"</string>
<string name="screen_roomlist_mark_as_unread">"Marcați ca necitită"</string>
<string name="session_verification_banner_message">"Se pare că folosiți un dispozitiv nou. Verificați-vă identitatea cu un alt dispozitiv pentru a accesa mesajele dumneavoastră criptate."</string>
<string name="session_verification_banner_title">"Verificați că sunteți dumneavoastră"</string>
</resources>

View file

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_content">"Создайте новый ключ восстановления, который можно использовать для восстановления зашифрованной истории сообщений в случае потери доступа к своим устройствам."</string>
<string name="banner_set_up_recovery_submit">"Настроить восстановление"</string>
<string name="banner_set_up_recovery_title">"Для защиты вашего аккаунта рекомендуется настроить восстановление"</string>
<string name="confirm_recovery_key_banner_message">"Подтвердите ключ восстановления, чтобы сохранить доступ к хранилищу ключей и истории сообщений."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Введите ключ восстановления"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Забыли ключ восстановления?"</string>
<string name="confirm_recovery_key_banner_title">"Хранилище ключей не синхронизировано"</string>
<string name="full_screen_intent_banner_message">"Чтобы больше не пропускать важные звонки, разрешите приложению показывать полноэкранные уведомления на заблокированном экране телефона."</string>
<string name="full_screen_intent_banner_title">"Улучшите качество звонков"</string>
<string name="screen_invites_decline_chat_message">"Вы уверены, что хотите отклонить приглашение в %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Отклонить приглашение"</string>
<string name="screen_invites_decline_direct_chat_message">"Вы уверены, что хотите отказаться от личного общения с %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Отклонить чат"</string>
<string name="screen_invites_empty_list">"Нет приглашений"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) пригласил вас"</string>
<string name="screen_migration_message">"Это одноразовый процесс, спасибо, что подождали."</string>
<string name="screen_migration_title">"Настройка учетной записи."</string>
<string name="screen_roomlist_a11y_create_message">"Создайте новую беседу или комнату"</string>
<string name="screen_roomlist_empty_message">"Начните переписку с отправки сообщения."</string>
<string name="screen_roomlist_empty_title">"Пока нет доступных чатов."</string>
<string name="screen_roomlist_filter_favourites">"Избранное"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Добавить чат в избранное можно в настройках чата.
На данный момент вы можете убрать фильтры, чтобы увидеть другие ваши чаты."</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"У вас пока нет избранных чатов"</string>
<string name="screen_roomlist_filter_invites">"Приглашения"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"У вас нет отложенных приглашений."</string>
<string name="screen_roomlist_filter_low_priority">"Низкий приоритет"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Вы можете убрать фильтры, чтобы увидеть другие ваши чаты."</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"У вас нет чатов для этой подборки"</string>
<string name="screen_roomlist_filter_people">"Пользователи"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"У вас пока нет личных сообщений"</string>
<string name="screen_roomlist_filter_rooms">"Комнаты"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Вас пока нет ни в одной комнате"</string>
<string name="screen_roomlist_filter_unreads">"Непрочитанные"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Поздравляем!
У вас нет непрочитанных сообщений!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Запрос на присоединение отправлен"</string>
<string name="screen_roomlist_main_space_title">"Все чаты"</string>
<string name="screen_roomlist_mark_as_read">"Пометить как прочитанное"</string>
<string name="screen_roomlist_mark_as_unread">"Отметить как непрочитанное"</string>
<string name="screen_roomlist_tombstoned_room_description">"Эта комната была обновлена"</string>
<string name="session_verification_banner_message">"Похоже, вы используете новое устройство. Чтобы получить доступ к зашифрованным сообщениям пройдите подтверждение с другим устройством."</string>
<string name="session_verification_banner_title">"Подтвердите, что это вы"</string>
</resources>

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_battery_optimization_content_android">"Vypnite optimalizáciu batérie pre túto aplikáciu, aby ste sa uistili, že sú prijaté všetky upozornenia."</string>
<string name="banner_battery_optimization_submit_android">"Zakázať optimalizáciu"</string>
<string name="banner_battery_optimization_title_android">"Oznámenia neprichádzajú?"</string>
<string name="banner_set_up_recovery_content">"Vytvorte nový kľúč na obnovenie, ktorý môžete použiť na obnovenie vašej histórie šifrovaných správ v prípade straty prístupu k vašim zariadeniam."</string>
<string name="banner_set_up_recovery_submit">"Nastaviť obnovenie"</string>
<string name="banner_set_up_recovery_title">"Nastaviť obnovenie"</string>
<string name="confirm_recovery_key_banner_message">"Potvrďte svoj kľúč na obnovenie, aby ste zachovali prístup k úložisku kľúčov a histórii správ."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Zadajte kľúč na obnovenie"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Zabudli ste svoj kľúč na obnovenie?"</string>
<string name="confirm_recovery_key_banner_title">"Vaše úložisko kľúčov nie je synchronizované"</string>
<string name="full_screen_intent_banner_message">"Aby ste už nikdy nezmeškali dôležitý hovor, zmeňte svoje nastavenia a povoľte upozornenia na celú obrazovku, keď je váš telefón uzamknutý."</string>
<string name="full_screen_intent_banner_title">"Vylepšite svoj zážitok z hovoru"</string>
<string name="screen_invites_decline_chat_message">"Naozaj chcete odmietnuť pozvánku na pripojenie do %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Odmietnuť pozvanie"</string>
<string name="screen_invites_decline_direct_chat_message">"Naozaj chcete odmietnuť túto súkromnú konverzáciu s %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Odmietnuť konverzáciu"</string>
<string name="screen_invites_empty_list">"Žiadne pozvánky"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) vás pozval/a"</string>
<string name="screen_migration_message">"Ide o jednorazový proces, ďakujeme za trpezlivosť."</string>
<string name="screen_migration_title">"Nastavenie vášho účtu."</string>
<string name="screen_roomlist_a11y_create_message">"Vytvorte novú konverzáciu alebo miestnosť"</string>
<string name="screen_roomlist_clear_filters">"Vyčistiť filtre"</string>
<string name="screen_roomlist_empty_message">"Začnite tým, že niekomu pošlete správu."</string>
<string name="screen_roomlist_empty_title">"Zatiaľ žiadne konverzácie."</string>
<string name="screen_roomlist_filter_favourites">"Obľúbené"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Môžete pridať konverzáciu medzi obľúbené v nastaveniach konverzácie.
Zatiaľ môžete zrušiť výber filtrov, aby ste videli ostatné konverzácie"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Zatiaľ nemáte obľúbené konverzácie"</string>
<string name="screen_roomlist_filter_invites">"Pozvánky"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Nemáte žiadne čakajúce pozvánky."</string>
<string name="screen_roomlist_filter_low_priority">"Nízka priorita"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Môžete zrušiť výber filtrov, aby ste videli svoje ostatné konverzácie"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Nemáte konverzácie pre tento výber"</string>
<string name="screen_roomlist_filter_people">"Ľudia"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Zatiaľ nemáte žiadne priame správy"</string>
<string name="screen_roomlist_filter_rooms">"Miestnosti"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Zatiaľ ešte nie ste v žiadnej miestnosti"</string>
<string name="screen_roomlist_filter_unreads">"Neprečítané"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Gratulujeme!
Nemáte žiadne neprečítané správy!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Žiadosť o pripojenie bola odoslaná"</string>
<string name="screen_roomlist_main_space_title">"Všetky konverzácie"</string>
<string name="screen_roomlist_mark_as_read">"Označiť ako prečítané"</string>
<string name="screen_roomlist_mark_as_unread">"Označiť ako neprečítané"</string>
<string name="screen_roomlist_tombstoned_room_description">"Táto miestnosť bola aktualizovaná"</string>
<string name="session_verification_banner_message">"Vyzerá to tak, že používate nové zariadenie. Overte svoj prístup k zašifrovaným správam pomocou vášho druhého zariadenia."</string>
<string name="session_verification_banner_title">"Overte, že ste to vy"</string>
</resources>

View file

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_content">"Skapa en ny återställningsnyckel som kan användas för att återställa din krypterade meddelandehistorik om du förlorar åtkomst till dina enheter."</string>
<string name="banner_set_up_recovery_submit">"Ställ in återställning"</string>
<string name="banner_set_up_recovery_title">"Ställ in återställning"</string>
<string name="confirm_recovery_key_banner_message">"Bekräfta din återställningsnyckel för att behålla åtkomsten till din nyckellagring och meddelandehistorik."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Ange din återställningsnyckel"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Glömt din återställningsnyckel?"</string>
<string name="confirm_recovery_key_banner_title">"Din nyckellagring är inte synkroniserad"</string>
<string name="full_screen_intent_banner_message">"För att säkerställa att du aldrig missar ett viktigt samtal, ändra dina inställningar för att tillåta helskärmsmeddelanden när telefonen är låst."</string>
<string name="full_screen_intent_banner_title">"Förbättra din samtalsupplevelse"</string>
<string name="screen_invites_decline_chat_message">"Är du säker på att du vill tacka nej till inbjudan att gå med%1$s?"</string>
<string name="screen_invites_decline_chat_title">"Avböj inbjudan"</string>
<string name="screen_invites_decline_direct_chat_message">"Är du säker på att du vill avböja denna privata chatt med %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Avböj chatt"</string>
<string name="screen_invites_empty_list">"Inga inbjudningar"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) bjöd in dig"</string>
<string name="screen_migration_message">"Detta är en engångsprocess, tack för att du väntar."</string>
<string name="screen_migration_title">"Konfigurerar ditt konto"</string>
<string name="screen_roomlist_a11y_create_message">"Skapa en ny konversation eller ett nytt rum"</string>
<string name="screen_roomlist_empty_message">"Kom igång genom att skicka meddelanden till någon."</string>
<string name="screen_roomlist_empty_title">"Inga chattar än."</string>
<string name="screen_roomlist_filter_favourites">"Favoriter"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Du kan lägga till en chatt till dina favoriter i chattinställningarna.
För tillfället kan du avmarkera filter för att se dina andra chattar"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Du har inga favoritchattar än"</string>
<string name="screen_roomlist_filter_invites">"Inbjudningar"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Du har inga väntande inbjudningar."</string>
<string name="screen_roomlist_filter_low_priority">"Låg prioritet"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Du kan avmarkera filter för att se dina andra chattar"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Du har inga chattar för det här valet"</string>
<string name="screen_roomlist_filter_people">"Personer"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Du har inga DM:er än"</string>
<string name="screen_roomlist_filter_rooms">"Rum"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Du är inte i något rum än"</string>
<string name="screen_roomlist_filter_unreads">"Olästa"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Grattis!
Du har inga olästa meddelanden!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Begäran om att gå med skickad"</string>
<string name="screen_roomlist_main_space_title">"Alla chattar"</string>
<string name="screen_roomlist_mark_as_read">"Markera som läst"</string>
<string name="screen_roomlist_mark_as_unread">"Markera som oläst"</string>
<string name="screen_roomlist_tombstoned_room_description">"Det här rummet har uppgraderats"</string>
<string name="session_verification_banner_message">"Det verkar som om du använder en ny enhet. Verifiera med en annan enhet för att komma åt dina krypterade meddelanden."</string>
<string name="session_verification_banner_title">"Verifiera att det är du"</string>
</resources>

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_content">"Mevcut tüm cihazlarınızı kaybettiyseniz şifreleme kimliğinizi ve mesaj geçmişinizi bir kurtarma anahtarıyla kurtarın."</string>
<string name="banner_set_up_recovery_submit">"Kurtarmayı ayarlayın"</string>
<string name="banner_set_up_recovery_title">"Hesabınızı korumak için kurtarmayı ayarlayın"</string>
<string name="confirm_recovery_key_banner_message">"Anahtar depolama alanınıza ve mesaj geçmişinize erişimi sürdürmek için kurtarma anahtarınızı onaylayın."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Kurtarma anahtarınızı girin"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Kurtarma anahtarınızı mı unuttunuz?"</string>
<string name="confirm_recovery_key_banner_title">"Anahtar depolama alanınız senkronize değil"</string>
<string name="full_screen_intent_banner_message">"Önemli bir aramayı asla kaçırmamak için, telefonunuz kilitliyken tam ekran bildirimlere izin vermek üzere ayarlarınızı değiştirin."</string>
<string name="full_screen_intent_banner_title">"Arama deneyiminizi geliştirin"</string>
<string name="screen_invites_decline_chat_message">"%1$s katılma davetini reddetmek istediğinizden emin misiniz?"</string>
<string name="screen_invites_decline_chat_title">"Daveti reddet"</string>
<string name="screen_invites_decline_direct_chat_message">"%1$s ile bu özel sohbeti reddetmek istediğinizden emin misiniz?"</string>
<string name="screen_invites_decline_direct_chat_title">"Sohbeti reddet"</string>
<string name="screen_invites_empty_list">"Davet Yok"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) sizi davet etti"</string>
<string name="screen_migration_message">"Bu tek seferlik bir işlemdir, beklediğiniz için teşekkürler."</string>
<string name="screen_migration_title">"Hesabınızı ayarlanıyor."</string>
<string name="screen_roomlist_a11y_create_message">"Yeni bir sohbet veya oda oluşturun"</string>
<string name="screen_roomlist_empty_message">"Birine mesaj göndererek başla."</string>
<string name="screen_roomlist_empty_title">"Henüz sohbet yok."</string>
<string name="screen_roomlist_filter_favourites">"Favoriler"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Sohbet ayarlarından bir sohbeti favorilerinize ekleyebilirsiniz.
Şimdilik, diğer sohbetlerinizi görmek için filtrelerin seçimini kaldırabilirsiniz"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Henüz favori sohbetleriniz yok"</string>
<string name="screen_roomlist_filter_invites">"Davetiyeler"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Bekleyen davetiniz yok."</string>
<string name="screen_roomlist_filter_low_priority">"Düşük Öncelikli"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Diğer sohbetlerinizi görmek için filtrelerin seçimini kaldırabilirsiniz"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Bu seçim için sohbetiniz yok"</string>
<string name="screen_roomlist_filter_people">"Kişiler"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Henüz hiç DM\'niz yok"</string>
<string name="screen_roomlist_filter_rooms">"Odalar"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Henüz herhangi bir odada değilsiniz"</string>
<string name="screen_roomlist_filter_unreads">"Okunmamış"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Tebrikler!
Okunmamış mesajınız yok!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Katılma isteği gönderildi"</string>
<string name="screen_roomlist_main_space_title">"Sohbetler"</string>
<string name="screen_roomlist_mark_as_read">"Okundu olarak işaretle"</string>
<string name="screen_roomlist_mark_as_unread">"Okunmamış olarak işaretle"</string>
<string name="session_verification_banner_message">"Görünüşe göre yeni bir cihaz kullanıyorsunuz. Şifrelenmiş mesajlarınıza erişmek için başka bir cihazla doğrulayın."</string>
<string name="session_verification_banner_title">"Siz olduğunuzu doğrulayın"</string>
</resources>

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_battery_optimization_content_android">"Вимкніть оптимізацію акумулятора для цього застосунку, щоб надходили всі сповіщення."</string>
<string name="banner_battery_optimization_submit_android">"Вимкнути оптимізацію"</string>
<string name="banner_battery_optimization_title_android">"Не надходять сповіщення?"</string>
<string name="banner_set_up_recovery_content">"Відновіть свою криптографічну ідентичність та історію повідомлень за допомогою ключа відновлення, якщо ви втратили всі наявні пристрої."</string>
<string name="banner_set_up_recovery_submit">"Налаштувати відновлення"</string>
<string name="banner_set_up_recovery_title">"Налаштуйте відновлення для захисту свого облікового запису"</string>
<string name="confirm_recovery_key_banner_message">"Підтвердіть свій ключ відновлення, щоб мати доступ до сховища ключів та історії повідомлень."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Введіть ключ відновлення"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Забули ключ відновлення?"</string>
<string name="confirm_recovery_key_banner_title">"Ваше сховище ключів не синхронізовано"</string>
<string name="full_screen_intent_banner_message">"Щоб ніколи не пропустити важливий виклик, змініть налаштування, щоб увімкнути повноекранні сповіщення, коли телефон заблоковано."</string>
<string name="full_screen_intent_banner_title">"Покращуйте досвід дзвінків"</string>
<string name="screen_invites_decline_chat_message">"Ви впевнені, що хочете відхилити запрошення приєднатися до %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Відхилити запрошення"</string>
<string name="screen_invites_decline_direct_chat_message">"Ви дійсно хочете відмовитися від приватної бесіди з %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Відхилити бесіду"</string>
<string name="screen_invites_empty_list">"Немає запрошень"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) запрошує вас"</string>
<string name="screen_migration_message">"Це одноразовий процес, дякую за очікування."</string>
<string name="screen_migration_title">"Налаштування облікового запису."</string>
<string name="screen_roomlist_a11y_create_message">"Створити нову розмову або кімнату"</string>
<string name="screen_roomlist_empty_message">"Почніть з обміну повідомленнями з кимось."</string>
<string name="screen_roomlist_empty_title">"Ще немає бесід."</string>
<string name="screen_roomlist_filter_favourites">"Обране"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Ви можете додати бесіду до обраних у налаштуваннях бесіди.
Наразі ви можете зняти фільтри, щоб побачити інші ваші бесіди"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Ви ще не маєте обраних бесід"</string>
<string name="screen_roomlist_filter_invites">"Запрошення"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"У вас немає запрошень, що очікують на розгляд."</string>
<string name="screen_roomlist_filter_low_priority">"Низький пріоритет"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Ви можете зняти фільтри, щоб побачити інші ваші бесіди"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Ви не маєте бесід для цієї категорії"</string>
<string name="screen_roomlist_filter_people">"Люди"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Ви ще не маєте жодної особистої бесіди"</string>
<string name="screen_roomlist_filter_rooms">"Кімнати"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Ви ще не учасник жодної кімнати"</string>
<string name="screen_roomlist_filter_unreads">"Непрочитані"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Вітаємо!
У вас немає непрочитаних повідомлень!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Запит на приєднання надіслано"</string>
<string name="screen_roomlist_main_space_title">"Бесіди"</string>
<string name="screen_roomlist_mark_as_read">"Позначити прочитаним"</string>
<string name="screen_roomlist_mark_as_unread">"Позначити непрочитаним"</string>
<string name="screen_roomlist_tombstoned_room_description">"Цю кімнату оновлено"</string>
<string name="session_verification_banner_message">"Схоже, ви використовуєте новий пристрій. Щоб отримати доступ до зашифрованих повідомлень, підтвердьте особу за допомогою іншого пристрою."</string>
<string name="session_verification_banner_title">"Підтвердьте, що це ви"</string>
</resources>

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_submit">"بازیابی مرتب کریں"</string>
<string name="confirm_recovery_key_banner_message">"اپنے کلید کے ذخیرہ اور پیغام کی سرگزشت تک رسائی کو برقرار رکھنے کیلئے اپنی بازیابی کلید کی تصدیق کریں۔"</string>
<string name="confirm_recovery_key_banner_title">"آپ کا کلید کا ذخیرہ غیر ہم وقت ساز ہے۔"</string>
<string name="full_screen_intent_banner_message">"اس بات کو یقینی بنانے کے لیے کہ آپ کبھی بھی اہم مکالمہ سے محروم نہ ہوں، براہ کرم اپنی ترتیبات تبدیل کریں تاکہ آپ کا ہاتف مقفل ہونے پر مکمل پردۂ نمائش اطلاعات کی اجازت دی جا سکے۔"</string>
<string name="full_screen_intent_banner_title">"اپنے مکالمتی تجربے کو احسن کریں"</string>
<string name="screen_invites_decline_chat_message">"کیا آپکو یقین ہے کہ آپ %1$s میں شامل ہونے کی درخواست مسترد کرنا چاہتے ہیں؟"</string>
<string name="screen_invites_decline_chat_title">"دعوت مسترد کریں"</string>
<string name="screen_invites_decline_direct_chat_message">"کیا آپکو یقین ہے کہ آپ %1$s کیساتھ نجی گفتگو مسترد کرنا چاہتے ہیں؟"</string>
<string name="screen_invites_decline_direct_chat_title">"گفتگو مسترد کریں"</string>
<string name="screen_invites_empty_list">"کوئی دعوت نامے نہیں"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) نے آپ کو مدعو کیا"</string>
<string name="screen_migration_message">"یہ ایک بار کا عمل ہے، انتظار کرنے کا شکریہ۔"</string>
<string name="screen_migration_title">"آپکا کھاتہ مرتب کر رہا ہے"</string>
<string name="screen_roomlist_a11y_create_message">"ایک نئی گفتگو یا کمرہ تخلیق کریں"</string>
<string name="screen_roomlist_empty_message">"کسی کو پیغام بھیج کر شروع کریں۔"</string>
<string name="screen_roomlist_empty_title">"ابھی تک کوئی گفتگوئیں نہیں ہیں۔"</string>
<string name="screen_roomlist_filter_favourites">"پسندیدگان"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"آپ گفتگو کی ترتیبات میں اپنے پسندیدہ میں گفتگو شامل کر سکتے ہیں۔
ابھی کے لیے، آپ اپنی دوسری گفتگوئیں دیکھنے کے لیے مرشحات کو غیر منتخب کر سکتے ہیں۔"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"آپ کے پاس ابھی تک پسندیدہ گفتگوئیں نہیں ہیں"</string>
<string name="screen_roomlist_filter_invites">"دعوت نامے"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"آپ کے پاس کوئی زیر التوا دعوتیں نہیں ہیں۔"</string>
<string name="screen_roomlist_filter_low_priority">"کم ترجیحی"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"آپ اپنی دیگر گفتگئہں دیکھنے کیلئے مرشحات کو غیر منتخب کرسکتے ہیں"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"آپ کے پاس اس انتخاب کے لیے گفتگو ئیں نہیں ہیں۔"</string>
<string name="screen_roomlist_filter_people">"لوگ"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"آپ کے پاس ابھی تک کوئی براہ راست پیغامات نہیں ہے۔"</string>
<string name="screen_roomlist_filter_rooms">"کمرے"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"آپ ابھی تک کسی کمرے میں نہیں ہیں"</string>
<string name="screen_roomlist_filter_unreads">"غیر مقروءہ"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"مبارک ہو!
آپ کے پاس کوئی غیر مقروءہ پیغامات نہیں!"</string>
<string name="screen_roomlist_main_space_title">"گفتگوئیں"</string>
<string name="screen_roomlist_mark_as_read">"بطور مقروءہ نشانزد کریں"</string>
<string name="screen_roomlist_mark_as_unread">"بطور غیر مقروءہ نشانزد کریں"</string>
<string name="session_verification_banner_message">"ایسا لگتا ہے کہ آپ ایک نیا آلہ استعمال کر رہے ہیں۔ اپنے مرموزکردہ پیغامات تک رسائی کیلئے کسی دوسرے آلے سے توثیق کریں۔"</string>
<string name="session_verification_banner_title">"تصدیق کریں کہ آپ ہی ہیں"</string>
</resources>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_submit">"Qayta tiklashni sozlang"</string>
<string name="screen_invites_decline_chat_message">"Haqiqatan ham qo\'shilish taklifini rad qilmoqchimisiz%1$s ?"</string>
<string name="screen_invites_decline_chat_title">"Taklifni rad etish"</string>
<string name="screen_invites_decline_direct_chat_message">"Haqiqatan ham bu shaxsiy chatni rad qilmoqchimisiz%1$s ?"</string>
<string name="screen_invites_decline_direct_chat_title">"Chatni rad etish"</string>
<string name="screen_invites_empty_list">"Takliflar yo\'q"</string>
<string name="screen_invites_invited_you">"%1$s(%2$s ) sizni taklif qildi"</string>
<string name="screen_migration_message">"Bu bir martalik jarayon, kutganingiz uchun rahmat."</string>
<string name="screen_migration_title">"Hisobingiz sozlanmoqda."</string>
<string name="screen_roomlist_a11y_create_message">"Yangi suhbat yoki xona yarating"</string>
<string name="screen_roomlist_empty_message">"Kimgadir xabar yuborishdan boshlang."</string>
<string name="screen_roomlist_empty_title">"Hozircha chatlar yoq."</string>
<string name="screen_roomlist_filter_invites">"Takliflar"</string>
<string name="screen_roomlist_filter_people">"Odamlar"</string>
<string name="screen_roomlist_main_space_title">"Suhbatlar"</string>
<string name="session_verification_banner_message">"Siz yangi qurilmadan foydalanayotganga oxshaysiz. Shifrlangan xabarlaringizga kirish uchun boshqa qurilma bilan tasdiqlang."</string>
<string name="session_verification_banner_title">"Siz ekanligingizni tasdiqlang"</string>
</resources>

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_content">"若您遺失了所有現有裝置,則請使用復原金鑰以救援您的密碼學身份與訊息歷史紀錄。"</string>
<string name="banner_set_up_recovery_submit">"設定復原"</string>
<string name="banner_set_up_recovery_title">"設定備援以保護您的帳號"</string>
<string name="confirm_recovery_key_banner_message">"確認您的復原金鑰以維持對金鑰儲存空間與訊息歷史紀錄的存取權。"</string>
<string name="confirm_recovery_key_banner_primary_button_title">"輸入您的復原金鑰"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"忘記了您的復原金鑰?"</string>
<string name="confirm_recovery_key_banner_title">"您的金鑰儲存空間並未同步"</string>
<string name="full_screen_intent_banner_message">"為確保您永遠不會錯過重要通話,請變更設定以允許在手機鎖定時允許全螢幕通知。"</string>
<string name="full_screen_intent_banner_title">"提升您的通話體驗"</string>
<string name="screen_invites_decline_chat_message">"您確定您想要拒絕加入 %1$s 的邀請嗎?"</string>
<string name="screen_invites_decline_chat_title">"拒絕邀請"</string>
<string name="screen_invites_decline_direct_chat_message">"您確定您要拒絕此與 %1$s 的私人聊天嗎?"</string>
<string name="screen_invites_decline_direct_chat_title">"拒絕聊天"</string>
<string name="screen_invites_empty_list">"沒有邀請"</string>
<string name="screen_invites_invited_you">"%1$s%2$s邀請您"</string>
<string name="screen_migration_message">"這是一次性的程序,感謝您耐心等候。"</string>
<string name="screen_migration_title">"正在設定您的帳號。"</string>
<string name="screen_roomlist_a11y_create_message">"建立新的對話或聊天室"</string>
<string name="screen_roomlist_empty_message">"從向某人傳送訊息開始。"</string>
<string name="screen_roomlist_empty_title">"尚無聊天室。"</string>
<string name="screen_roomlist_filter_favourites">"我的最愛"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"您可以在聊天設定中將聊天新增至收藏。
目前,您可以取消選取篩選條件以檢視其他聊天"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"您尚無收藏聊天"</string>
<string name="screen_roomlist_filter_invites">"邀請"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"您沒有任何擱置中的邀請。"</string>
<string name="screen_roomlist_filter_low_priority">"低優先度"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"您可以取消選取篩選條件以檢視其他聊天"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"您並無此選擇的聊天"</string>
<string name="screen_roomlist_filter_people">"夥伴"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"您尚無任何私人訊息"</string>
<string name="screen_roomlist_filter_rooms">"聊天室"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"您尚未進入任何聊天室"</string>
<string name="screen_roomlist_filter_unreads">"未讀"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"恭喜!
您沒有任何未讀的訊息!"</string>
<string name="screen_roomlist_knock_event_sent_description">"已傳送加入請求"</string>
<string name="screen_roomlist_main_space_title">"所有聊天室"</string>
<string name="screen_roomlist_mark_as_read">"標為已讀"</string>
<string name="screen_roomlist_mark_as_unread">"標為未讀"</string>
<string name="session_verification_banner_message">"您似乎正在使用新的裝置。請使用另一個裝置進行驗證,以存取您的加密訊息。"</string>
<string name="session_verification_banner_title">"驗證這是您本人"</string>
</resources>

View file

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_battery_optimization_submit_android">"禁用优化"</string>
<string name="banner_battery_optimization_title_android">"通知未送达?"</string>
<string name="banner_set_up_recovery_content">"生成新的恢复密钥,该密钥可用于在您无法访问设备时恢复加密的消息历史记录。"</string>
<string name="banner_set_up_recovery_submit">"设置恢复"</string>
<string name="banner_set_up_recovery_title">"设置恢复"</string>
<string name="confirm_recovery_key_banner_message">"确认恢复密钥,以保持对密钥存储和消息历史的访问。"</string>
<string name="confirm_recovery_key_banner_primary_button_title">"输入恢复密钥"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"忘记了恢复密钥?"</string>
<string name="confirm_recovery_key_banner_title">"你的密钥存储已不同步"</string>
<string name="full_screen_intent_banner_message">"为确保您不会错过重要来电,请更改设置以允许锁屏时的全屏通知。"</string>
<string name="full_screen_intent_banner_title">"提升通话体验"</string>
<string name="screen_invites_decline_chat_message">"您确定要拒绝加入 %1$s 的邀请吗?"</string>
<string name="screen_invites_decline_chat_title">"拒绝邀请"</string>
<string name="screen_invites_decline_direct_chat_message">"您确定要拒绝与 %1$s 开始私聊吗?"</string>
<string name="screen_invites_decline_direct_chat_title">"拒绝聊天"</string>
<string name="screen_invites_empty_list">"没有邀请"</string>
<string name="screen_invites_invited_you">"%1$s %2$s邀请了你"</string>
<string name="screen_migration_message">"这是一个一次性的过程,感谢您的等待。"</string>
<string name="screen_migration_title">"设置您的账户。"</string>
<string name="screen_roomlist_a11y_create_message">"创建新的对话或聊天室"</string>
<string name="screen_roomlist_empty_message">"通过向某人发送消息来开始。"</string>
<string name="screen_roomlist_empty_title">"还没有聊天。"</string>
<string name="screen_roomlist_filter_favourites">"收藏夹"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"可以在聊天设置里将聊天添加到收藏夹中。
现在,可以取消选择过滤器以查看其他对话。"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"您未收藏任何聊天"</string>
<string name="screen_roomlist_filter_invites">"邀请"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"没有待处理的邀请。"</string>
<string name="screen_roomlist_filter_low_priority">"低优先级"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"您可以取消选择过滤器以查看其他对话"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"您没有关于此选项的聊天"</string>
<string name="screen_roomlist_filter_people">"用户"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"目前您还没有私信"</string>
<string name="screen_roomlist_filter_rooms">"聊天室"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"您尚未进入任何聊天室"</string>
<string name="screen_roomlist_filter_unreads">"未读"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"恭喜!
没有任何未读消息!"</string>
<string name="screen_roomlist_knock_event_sent_description">"加入请求已发送"</string>
<string name="screen_roomlist_main_space_title">"全部聊天"</string>
<string name="screen_roomlist_mark_as_read">"标记为已读"</string>
<string name="screen_roomlist_mark_as_unread">"标记为未读"</string>
<string name="session_verification_banner_message">"您似乎正在使用新设备。使用另一台设备进行验证以访问您的加密消息。"</string>
<string name="session_verification_banner_title">"验证是你本人"</string>
</resources>

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_battery_optimization_content_android">"Disable battery optimization for this app, to make sure all notifications are received."</string>
<string name="banner_battery_optimization_submit_android">"Disable optimization"</string>
<string name="banner_battery_optimization_title_android">"Notifications not arriving?"</string>
<string name="banner_set_up_recovery_content">"Recover your cryptographic identity and message history with a recovery key if you have lost all your existing devices."</string>
<string name="banner_set_up_recovery_submit">"Set up recovery"</string>
<string name="banner_set_up_recovery_title">"Set up recovery to protect your account"</string>
<string name="confirm_recovery_key_banner_message">"Confirm your recovery key to maintain access to your key storage and message history."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Enter your recovery key"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Forgot your recovery key?"</string>
<string name="confirm_recovery_key_banner_title">"Your key storage is out of sync"</string>
<string name="full_screen_intent_banner_message">"To ensure you never miss an important call, please change your settings to allow full-screen notifications when your phone is locked."</string>
<string name="full_screen_intent_banner_title">"Enhance your call experience"</string>
<string name="screen_invites_decline_chat_message">"Are you sure you want to decline the invitation to join %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Decline invite"</string>
<string name="screen_invites_decline_direct_chat_message">"Are you sure you want to decline this private chat with %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Decline chat"</string>
<string name="screen_invites_empty_list">"No Invites"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) invited you"</string>
<string name="screen_migration_message">"This is a one time process, thanks for waiting."</string>
<string name="screen_migration_title">"Setting up your account."</string>
<string name="screen_roomlist_a11y_create_message">"Create a new conversation or room"</string>
<string name="screen_roomlist_clear_filters">"Clear filters"</string>
<string name="screen_roomlist_empty_message">"Get started by messaging someone."</string>
<string name="screen_roomlist_empty_title">"No chats yet."</string>
<string name="screen_roomlist_filter_favourites">"Favourites"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"You can add a chat to your favourites in the chat settings.
For now, you can deselect filters in order to see your other chats"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"You dont have favourite chats yet"</string>
<string name="screen_roomlist_filter_invites">"Invites"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"You don\'t have any pending invites."</string>
<string name="screen_roomlist_filter_low_priority">"Low Priority"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"You can deselect filters in order to see your other chats"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"You dont have chats for this selection"</string>
<string name="screen_roomlist_filter_people">"People"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"You dont have any DMs yet"</string>
<string name="screen_roomlist_filter_rooms">"Rooms"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Youre not in any room yet"</string>
<string name="screen_roomlist_filter_unreads">"Unreads"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Congrats!
You dont have any unread messages!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Request to join sent"</string>
<string name="screen_roomlist_main_space_title">"Chats"</string>
<string name="screen_roomlist_mark_as_read">"Mark as read"</string>
<string name="screen_roomlist_mark_as_unread">"Mark as unread"</string>
<string name="screen_roomlist_tombstoned_room_description">"This room has been upgraded"</string>
<string name="session_verification_banner_message">"Looks like youre using a new device. Verify with another device to access your encrypted messages."</string>
<string name="session_verification_banner_title">"Verify its you"</string>
</resources>

View file

@ -0,0 +1,19 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl
import io.element.android.libraries.androidutils.system.DateTimeObserver
import kotlinx.coroutines.flow.MutableSharedFlow
class FakeDateTimeObserver : DateTimeObserver {
override val changes = MutableSharedFlow<DateTimeObserver.Event>(extraBufferCapacity = 1)
fun given(event: DateTimeObserver.Event) {
changes.tryEmit(event)
}
}

View file

@ -0,0 +1,113 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.impl.roomlist.aRoomListState
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.indicator.test.FakeIndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class HomePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - should start with no user and then load user with success`() = runTest {
val matrixClient = FakeMatrixClient(
userDisplayName = null,
userAvatarUrl = null,
)
matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.success(MatrixUser(matrixClient.sessionId, A_USER_NAME, AN_AVATAR_URL)))
val presenter = createHomePresenter(
client = matrixClient,
rageshakeFeatureAvailability = { false },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.matrixUser).isEqualTo(MatrixUser(A_USER_ID))
assertThat(initialState.canReportBug).isFalse()
val withUserState = awaitItem()
assertThat(withUserState.matrixUser.userId).isEqualTo(A_USER_ID)
assertThat(withUserState.matrixUser.displayName).isEqualTo(A_USER_NAME)
assertThat(withUserState.matrixUser.avatarUrl).isEqualTo(AN_AVATAR_URL)
assertThat(withUserState.showAvatarIndicator).isFalse()
}
}
@Test
fun `present - show avatar indicator`() = runTest {
val indicatorService = FakeIndicatorService()
val presenter = createHomePresenter(
indicatorService = indicatorService,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.showAvatarIndicator).isFalse()
indicatorService.setShowRoomListTopBarIndicator(true)
val finalState = awaitItem()
assertThat(finalState.showAvatarIndicator).isTrue()
}
}
@Test
fun `present - should start with no user and then load user with error`() = runTest {
val matrixClient = FakeMatrixClient(
userDisplayName = null,
userAvatarUrl = null,
)
matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.failure(AN_EXCEPTION))
val presenter = createHomePresenter(client = matrixClient)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.matrixUser).isEqualTo(MatrixUser(matrixClient.sessionId))
// No new state is coming
}
}
private fun TestScope.createHomePresenter(
client: MatrixClient = FakeMatrixClient(),
syncService: SyncService = FakeSyncService(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { true },
indicatorService: IndicatorService = FakeIndicatorService(),
) = HomePresenter(
client = client,
syncService = syncService,
snackbarDispatcher = snackbarDispatcher,
indicatorService = indicatorService,
logoutPresenter = { aDirectLogoutState() },
roomListPresenter = { aRoomListState() },
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
)
}

View file

@ -0,0 +1,106 @@
/*
* Copyright 2024 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.features.home.impl.datasource
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.impl.FakeDateTimeObserver
import io.element.android.libraries.androidutils.system.DateTimeObserver
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.FakeRoomListService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.time.Instant
class RoomListDataSourceTest {
@Test
fun `when DateTimeObserver gets a date change, the room summaries are refreshed`() = runTest {
val roomListService = FakeRoomListService().apply {
postState(RoomListService.State.Running)
postAllRooms(listOf(aRoomSummary()))
}
val dateTimeObserver = FakeDateTimeObserver()
var dateFormatterResult = "Today"
val dateFormatter = FakeDateFormatter({ _, _, _ -> dateFormatterResult })
val roomListDataSource = createRoomListDataSource(
roomListService = roomListService,
roomListRoomSummaryFactory = aRoomListRoomSummaryFactory(
dateFormatter = dateFormatter,
),
dateTimeObserver = dateTimeObserver,
)
roomListDataSource.allRooms.test {
// Observe room list items changes
roomListDataSource.launchIn(backgroundScope)
// Get the initial room list
val initialRoomList = awaitItem()
assertThat(initialRoomList).isNotEmpty()
assertThat(initialRoomList.first().timestamp).isEqualTo("Today")
dateFormatterResult = "Yesterday"
// Trigger a date change
dateTimeObserver.given(DateTimeObserver.Event.DateChanged(Instant.MIN, Instant.now()))
// Check there is a new list and it's not the same as the previous one
val newRoomList = awaitItem()
assertThat(newRoomList).isNotSameInstanceAs(initialRoomList)
assertThat(newRoomList.first().timestamp).isEqualTo("Yesterday")
}
}
@Test
fun `when DateTimeObserver gets a time zone change, the room summaries are refreshed`() = runTest {
val roomListService = FakeRoomListService().apply {
postState(RoomListService.State.Running)
postAllRooms(listOf(aRoomSummary()))
}
val dateTimeObserver = FakeDateTimeObserver()
var dateFormatterResult = "Today"
val dateFormatter = FakeDateFormatter({ _, _, _ -> dateFormatterResult })
val roomListDataSource = createRoomListDataSource(
roomListService = roomListService,
roomListRoomSummaryFactory = aRoomListRoomSummaryFactory(
dateFormatter = dateFormatter,
),
dateTimeObserver = dateTimeObserver,
)
roomListDataSource.allRooms.test {
// Observe room list items changes
roomListDataSource.launchIn(backgroundScope)
// Get the initial room list
val initialRoomList = awaitItem()
assertThat(initialRoomList).isNotEmpty()
assertThat(initialRoomList.first().timestamp).isEqualTo("Today")
dateFormatterResult = "Yesterday"
// Trigger a timezone change
dateTimeObserver.given(DateTimeObserver.Event.TimeZoneChanged)
// Check there is a new list and it's not the same as the previous one
val newRoomList = awaitItem()
assertThat(newRoomList).isNotSameInstanceAs(initialRoomList)
assertThat(newRoomList.first().timestamp).isEqualTo("Yesterday")
}
}
private fun TestScope.createRoomListDataSource(
roomListService: FakeRoomListService = FakeRoomListService(),
roomListRoomSummaryFactory: RoomListRoomSummaryFactory = aRoomListRoomSummaryFactory(),
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
dateTimeObserver: FakeDateTimeObserver = FakeDateTimeObserver(),
) = RoomListDataSource(
roomListService = roomListService,
roomListRoomSummaryFactory = roomListRoomSummaryFactory,
coroutineDispatchers = testCoroutineDispatchers(),
notificationSettingsService = notificationSettingsService,
sessionCoroutineScope = backgroundScope,
dateTimeObserver = dateTimeObserver,
)
}

View file

@ -0,0 +1,20 @@
/*
* Copyright 2024 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.features.home.impl.datasource
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
fun aRoomListRoomSummaryFactory(
dateFormatter: DateFormatter = FakeDateFormatter { _, _, _ -> "Today" },
roomLastMessageFormatter: RoomLastMessageFormatter = RoomLastMessageFormatter { _, _ -> "Hey" }
) = RoomListRoomSummaryFactory(
dateFormatter = dateFormatter,
roomLastMessageFormatter = roomLastMessageFormatter
)

View file

@ -0,0 +1,75 @@
/*
* Copyright 2024 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.features.home.impl.filters
import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.impl.R
import org.junit.Test
class RoomListFiltersEmptyStateResourcesTest {
@Test
fun `fromSelectedFilters should return null when selectedFilters is empty`() {
val selectedFilters = emptyList<RoomListFilter>()
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
assertThat(result).isNull()
}
@Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only unread filter`() {
val selectedFilters = listOf(RoomListFilter.Unread)
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_unreads_empty_state_title)
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
}
@Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only people filter`() {
val selectedFilters = listOf(RoomListFilter.People)
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_people_empty_state_title)
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
}
@Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only rooms filter`() {
val selectedFilters = listOf(RoomListFilter.Rooms)
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_rooms_empty_state_title)
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
}
@Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only favourites filter`() {
val selectedFilters = listOf(RoomListFilter.Favourites)
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_favourites_empty_state_title)
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_favourites_empty_state_subtitle)
}
@Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only invites filter`() {
val selectedFilters = listOf(RoomListFilter.Invites)
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_invites_empty_state_title)
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
}
@Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has multiple filters`() {
val selectedFilters = listOf(RoomListFilter.Unread, RoomListFilter.People, RoomListFilter.Rooms, RoomListFilter.Favourites)
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_title)
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
}
}

View file

@ -0,0 +1,117 @@
/*
* Copyright 2024 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.features.home.impl.filters
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.impl.filters.selection.DefaultFilterSelectionStrategy
import io.element.android.features.home.impl.filters.selection.FilterSelectionState
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.tests.testutils.awaitLastSequentialItem
import kotlinx.coroutines.test.runTest
import org.junit.Test
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter
class RoomListFiltersPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createRoomListFiltersPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().let { state ->
assertThat(state.hasAnyFilterSelected).isFalse()
assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.Unread, false),
filterSelectionState(RoomListFilter.People, false),
filterSelectionState(RoomListFilter.Rooms, false),
filterSelectionState(RoomListFilter.Favourites, false),
filterSelectionState(RoomListFilter.Invites, false),
)
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - toggle rooms filter`() = runTest {
val roomListService = FakeRoomListService()
val presenter = createRoomListFiltersPresenter(roomListService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms))
awaitLastSequentialItem().let { state ->
assertThat(state.hasAnyFilterSelected).isTrue()
assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.Rooms, true),
filterSelectionState(RoomListFilter.Unread, false),
filterSelectionState(RoomListFilter.Favourites, false),
).inOrder()
assertThat(state.selectedFilters()).containsExactly(
RoomListFilter.Rooms,
)
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
assertThat(roomListCurrentFilter.filters).containsExactly(
MatrixRoomListFilter.Category.Group,
)
state.eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms))
}
awaitLastSequentialItem().let { state ->
assertThat(state.hasAnyFilterSelected).isFalse()
assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.Unread, false),
filterSelectionState(RoomListFilter.People, false),
filterSelectionState(RoomListFilter.Rooms, false),
filterSelectionState(RoomListFilter.Favourites, false),
filterSelectionState(RoomListFilter.Invites, false),
).inOrder()
assertThat(state.selectedFilters()).isEmpty()
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
assertThat(roomListCurrentFilter.filters).isEmpty()
}
}
}
@Test
fun `present - clear filters event`() = runTest {
val roomListService = FakeRoomListService()
val presenter = createRoomListFiltersPresenter(roomListService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms))
awaitLastSequentialItem().let { state ->
assertThat(state.hasAnyFilterSelected).isTrue()
state.eventSink.invoke(RoomListFiltersEvents.ClearSelectedFilters)
}
awaitLastSequentialItem().let { state ->
assertThat(state.hasAnyFilterSelected).isFalse()
}
}
}
}
private fun filterSelectionState(filter: RoomListFilter, selected: Boolean) = FilterSelectionState(
filter = filter,
isSelected = selected,
)
private fun createRoomListFiltersPresenter(
roomListService: RoomListService = FakeRoomListService(),
): RoomListFiltersPresenter {
return RoomListFiltersPresenter(
roomListService = roomListService,
filterSelectionStrategy = DefaultFilterSelectionStrategy(),
)
}

View file

@ -0,0 +1,61 @@
/*
* Copyright 2024 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.features.home.impl.filters
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.filters.selection.FilterSelectionState
import io.element.android.libraries.testtags.TestTags
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressTag
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RoomListFiltersViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on filters generates expected Event`() {
val eventsRecorder = EventsRecorder<RoomListFiltersEvents>()
rule.setContent {
RoomListFiltersView(
state = aRoomListFiltersState(eventSink = eventsRecorder),
)
}
rule.clickOn(R.string.screen_roomlist_filter_rooms)
eventsRecorder.assertList(
listOf(
RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms),
)
)
}
@Test
fun `clicking on clear filters generates expected Event`() {
val eventsRecorder = EventsRecorder<RoomListFiltersEvents>()
rule.setContent {
RoomListFiltersView(
state = aRoomListFiltersState(
filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) },
eventSink = eventsRecorder
),
)
}
rule.pressTag(TestTags.homeScreenClearFilters.value)
eventsRecorder.assertList(
listOf(
RoomListFiltersEvents.ClearSelectedFilters,
)
)
}
}

View file

@ -0,0 +1,109 @@
/*
* Copyright 2024 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.features.home.impl.model
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import kotlinx.collections.immutable.toPersistentList
import org.junit.Test
class RoomListBaseRoomSummaryTest {
@Test
fun `test default value`() {
val sut = createRoomListRoomSummary(
isMarkedUnread = false,
)
assertThat(sut.isHighlighted).isFalse()
assertThat(sut.hasNewContent).isFalse()
}
@Test
fun `test muted room`() {
val sut = createRoomListRoomSummary(
userDefinedNotificationMode = RoomNotificationMode.MUTE,
)
assertThat(sut.isHighlighted).isFalse()
assertThat(sut.hasNewContent).isFalse()
}
@Test
fun `test muted room isMarkedUnread set to true`() {
val sut = createRoomListRoomSummary(
isMarkedUnread = true,
userDefinedNotificationMode = RoomNotificationMode.MUTE,
)
assertThat(sut.isHighlighted).isTrue()
assertThat(sut.hasNewContent).isTrue()
}
@Test
fun `test muted room with unread message`() {
val sut = createRoomListRoomSummary(
numberOfUnreadNotifications = 1,
userDefinedNotificationMode = RoomNotificationMode.MUTE,
)
assertThat(sut.isHighlighted).isFalse()
assertThat(sut.hasNewContent).isTrue()
}
@Test
fun `test isMarkedUnread set to true`() {
val sut = createRoomListRoomSummary(
isMarkedUnread = true,
)
assertThat(sut.isHighlighted).isTrue()
assertThat(sut.hasNewContent).isTrue()
}
@Test
fun `when display type is invite then isHighlighted and hasNewContent are false`() {
val sut = createRoomListRoomSummary(
displayType = RoomSummaryDisplayType.INVITE,
)
assertThat(sut.isHighlighted).isFalse()
assertThat(sut.hasNewContent).isFalse()
}
}
internal fun createRoomListRoomSummary(
numberOfUnreadMentions: Long = 0,
numberOfUnreadMessages: Long = 0,
numberOfUnreadNotifications: Long = 0,
isMarkedUnread: Boolean = false,
userDefinedNotificationMode: RoomNotificationMode? = null,
isFavorite: Boolean = false,
displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM,
heroes: List<AvatarData> = emptyList(),
timestamp: String? = null,
isTombstoned: Boolean = false,
) = RoomListRoomSummary(
id = A_ROOM_ID.value,
roomId = A_ROOM_ID,
name = A_ROOM_NAME,
numberOfUnreadMentions = numberOfUnreadMentions,
numberOfUnreadMessages = numberOfUnreadMessages,
numberOfUnreadNotifications = numberOfUnreadNotifications,
isMarkedUnread = isMarkedUnread,
timestamp = timestamp,
lastMessage = "",
avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME, size = AvatarSize.RoomListItem),
displayType = displayType,
userDefinedNotificationMode = userDefinedNotificationMode,
hasRoomCall = false,
isDirect = false,
isFavorite = isFavorite,
canonicalAlias = null,
inviteSender = null,
isDm = false,
heroes = heroes.toPersistentList(),
isTombstoned = isTombstoned,
)

View file

@ -0,0 +1,148 @@
/*
* Copyright 2024 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.features.home.impl.roomlist
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.home.impl.R
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureCalledOnceWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.setSafeContent
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RoomListContextMenuTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on Mark as read generates expected Events`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val contextMenu = aContextMenuShown(hasNewContent = true)
rule.setRoomListContextMenu(
contextMenu = contextMenu,
eventSink = eventsRecorder,
)
rule.clickOn(R.string.screen_roomlist_mark_as_read)
eventsRecorder.assertList(
listOf(
RoomListEvents.HideContextMenu,
RoomListEvents.MarkAsRead(contextMenu.roomId),
)
)
}
@Test
fun `clicking on Mark as unread generates expected Events`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val contextMenu = aContextMenuShown(hasNewContent = false)
rule.setRoomListContextMenu(
contextMenu = contextMenu,
eventSink = eventsRecorder,
)
rule.clickOn(R.string.screen_roomlist_mark_as_unread)
eventsRecorder.assertList(
listOf(
RoomListEvents.HideContextMenu,
RoomListEvents.MarkAsUnread(contextMenu.roomId),
)
)
}
@Test
fun `clicking on Leave room generates expected Events`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val contextMenu = aContextMenuShown(isDm = false)
rule.setRoomListContextMenu(
contextMenu = contextMenu,
eventSink = eventsRecorder,
)
rule.clickOn(CommonStrings.action_leave_room)
eventsRecorder.assertList(
listOf(
RoomListEvents.HideContextMenu,
RoomListEvents.LeaveRoom(contextMenu.roomId),
)
)
}
@Test
fun `clicking on Report room invokes the expected callback and generates expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val contextMenu = aContextMenuShown()
val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit)
rule.setRoomListContextMenu(
contextMenu = contextMenu,
canReportRoom = true,
eventSink = eventsRecorder,
onRoomSettingsClick = EnsureNeverCalledWithParam(),
onReportRoomClick = callback,
)
rule.clickOn(CommonStrings.action_report_room)
eventsRecorder.assertSingle(RoomListEvents.HideContextMenu)
callback.assertSuccess()
}
@Test
fun `clicking on Settings invokes the expected callback and generates expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val contextMenu = aContextMenuShown()
val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit)
rule.setRoomListContextMenu(
contextMenu = contextMenu,
eventSink = eventsRecorder,
onRoomSettingsClick = callback,
)
rule.clickOn(CommonStrings.common_settings)
eventsRecorder.assertSingle(RoomListEvents.HideContextMenu)
callback.assertSuccess()
}
@Test
fun `clicking on Favourites generates expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val contextMenu = aContextMenuShown(isDm = false, isFavorite = false)
val callback = EnsureNeverCalledWithParam<RoomId>()
rule.setRoomListContextMenu(
contextMenu = contextMenu,
eventSink = eventsRecorder,
onRoomSettingsClick = callback,
)
rule.clickOn(CommonStrings.common_favourite)
eventsRecorder.assertList(
listOf(
RoomListEvents.SetRoomIsFavorite(contextMenu.roomId, true),
)
)
}
private fun AndroidComposeTestRule<*, *>.setRoomListContextMenu(
contextMenu: RoomListState.ContextMenu.Shown,
canReportRoom: Boolean = false,
eventSink: (RoomListEvents) -> Unit,
onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
) {
setSafeContent {
RoomListContextMenu(
contextMenu = contextMenu,
canReportRoom = canReportRoom,
onRoomSettingsClick = onRoomSettingsClick,
onReportRoomClick = onReportRoomClick,
eventSink = eventSink,
)
}
}
}

View file

@ -0,0 +1,101 @@
/*
* Copyright 2024 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.features.home.impl.roomlist
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.home.impl.model.aRoomListRoomSummary
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureCalledOnceWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.setSafeContent
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RoomListDeclineInviteMenuTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on decline emits the expected Events`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setSafeContent {
RoomListDeclineInviteMenu(
menu = menu,
canReportRoom = false,
onDeclineAndBlockClick = EnsureNeverCalledWithParam(),
eventSink = eventsRecorder,
)
}
rule.clickOn(CommonStrings.action_decline)
eventsRecorder.assertList(
listOf(
RoomListEvents.HideDeclineInviteMenu,
RoomListEvents.DeclineInvite(menu.roomSummary, blockUser = false),
)
)
}
@Test
fun `clicking on decline and block when canReportRoom=true, it emits the expected Events and callback`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setSafeContent {
RoomListDeclineInviteMenu(
menu = menu,
canReportRoom = true,
onDeclineAndBlockClick = EnsureCalledOnceWithParam(menu.roomSummary, Unit),
eventSink = eventsRecorder,
)
}
rule.clickOn(CommonStrings.action_decline_and_block)
val expectedEvents = listOf(RoomListEvents.HideDeclineInviteMenu)
eventsRecorder.assertList(expectedEvents)
}
@Test
fun `clicking on decline and block when canReportRoom=false, it emits the expected Events`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setSafeContent {
RoomListDeclineInviteMenu(
menu = menu,
canReportRoom = false,
onDeclineAndBlockClick = EnsureNeverCalledWithParam(),
eventSink = eventsRecorder,
)
}
rule.clickOn(CommonStrings.action_decline_and_block)
val expectedEvents = listOf(
RoomListEvents.HideDeclineInviteMenu,
RoomListEvents.DeclineInvite(menu.roomSummary, blockUser = true),
)
eventsRecorder.assertList(expectedEvents)
}
@Test
fun `clicking on cancel emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setSafeContent {
RoomListDeclineInviteMenu(
menu = menu,
canReportRoom = false,
onDeclineAndBlockClick = EnsureNeverCalledWithParam(),
eventSink = eventsRecorder,
)
}
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertList(listOf(RoomListEvents.HideDeclineInviteMenu))
}
}

View file

@ -0,0 +1,642 @@
/*
* Copyright 2023, 2024 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.features.home.impl.roomlist
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.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.RoomListFiltersState
import io.element.android.features.home.impl.filters.aRoomListFiltersState
import io.element.android.features.home.impl.model.createRoomListRoomSummary
import io.element.android.features.home.impl.search.RoomListSearchEvents
import io.element.android.features.home.impl.search.RoomListSearchState
import io.element.android.features.home.impl.search.aRoomListSearchState
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID_3
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.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
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.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.seconds
class RoomListPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - load 1 room with success`() = runTest {
val roomListService = FakeRoomListService()
val matrixClient = FakeMatrixClient(
roomListService = roomListService
)
val presenter = createRoomListPresenter(
client = matrixClient,
seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)),
)
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(
listOf(
aRoomSummary(
numUnreadMentions = 1,
numUnreadMessages = 2,
)
)
)
val withRoomsState =
consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Rooms && state.contentAsRooms().summaries.isNotEmpty() }.last()
assertThat(withRoomsState.contentAsRooms().summaries).hasSize(1)
assertThat(withRoomsState.contentAsRooms().summaries.first()).isEqualTo(
createRoomListRoomSummary(
numberOfUnreadMentions = 1,
numberOfUnreadMessages = 2,
timestamp = "0 TimeOrDate true",
)
)
assertThat(withRoomsState.contentAsRooms().seenRoomInvites).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - handle DismissRequestVerificationPrompt`() = runTest {
val roomListService = FakeRoomListService().apply {
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
}
val encryptionService = FakeEncryptionService().apply {
emitRecoveryState(RecoveryState.INCOMPLETE)
}
val syncService = FakeSyncService(initialSyncState = SyncState.Running)
val presenter = createRoomListPresenter(
client = FakeMatrixClient(roomListService = roomListService, encryptionService = encryptionService, syncService = syncService),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val eventWithContentAsRooms = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
}.last()
val eventSink = eventWithContentAsRooms.eventSink
assertThat(eventWithContentAsRooms.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation)
eventSink(RoomListEvents.DismissRequestVerificationPrompt)
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
}
}
@Test
fun `present - handle DismissRecoveryKeyPrompt`() = runTest {
val encryptionService = FakeEncryptionService().apply {
recoveryStateStateFlow.emit(RecoveryState.DISABLED)
}
val roomListService = FakeRoomListService().apply {
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
}
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
encryptionService = encryptionService,
sessionVerificationService = FakeSessionVerificationService().apply {
emitNeedsSessionVerification(false)
},
syncService = FakeSyncService(initialSyncState = SyncState.Running),
)
val presenter = createRoomListPresenter(
client = matrixClient,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
}.last()
assertThat(initialState.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.SetUpRecovery)
encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
val nextState = awaitItem()
assertThat(nextState.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation)
// Also check other states
encryptionService.emitRecoveryState(RecoveryState.DISABLED)
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.SetUpRecovery)
encryptionService.emitRecoveryState(RecoveryState.WAITING_FOR_SYNC)
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
encryptionService.emitRecoveryState(RecoveryState.DISABLED)
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.SetUpRecovery)
encryptionService.emitRecoveryState(RecoveryState.ENABLED)
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
encryptionService.emitRecoveryState(RecoveryState.DISABLED)
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.SetUpRecovery)
nextState.eventSink(RoomListEvents.DismissBanner)
val finalState = awaitItem()
assertThat(finalState.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
}
}
@Test
fun `present - show context menu`() = runTest {
val room = FakeBaseRoom()
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val presenter = createRoomListPresenter(client = client)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val summary = createRoomListRoomSummary()
initialState.eventSink(RoomListEvents.ShowContextMenu(summary))
awaitItem().also { state ->
assertThat(state.contextMenu)
.isEqualTo(
RoomListState.ContextMenu.Shown(
roomId = summary.roomId,
roomName = summary.name,
isDm = false,
isFavorite = false,
markAsUnreadFeatureFlagEnabled = true,
hasNewContent = false,
displayClearRoomCacheAction = false,
)
)
}
room.givenRoomInfo(
aRoomInfo(isFavorite = true)
)
awaitItem().also { state ->
assertThat(state.contextMenu)
.isEqualTo(
RoomListState.ContextMenu.Shown(
roomId = summary.roomId,
roomName = summary.name,
isDm = false,
isFavorite = true,
markAsUnreadFeatureFlagEnabled = true,
hasNewContent = false,
displayClearRoomCacheAction = false,
)
)
}
}
}
@Test
fun `present - show context menu with view source on`() = runTest {
val presenter = createRoomListPresenter(
appPreferencesStore = InMemoryAppPreferencesStore(
isDeveloperModeEnabled = true,
)
)
presenter.test {
val initialState = awaitItem()
val summary = createRoomListRoomSummary()
initialState.eventSink(RoomListEvents.ShowContextMenu(summary))
awaitItem().also { state ->
assertThat(state.contextMenu)
.isEqualTo(
RoomListState.ContextMenu.Shown(
roomId = summary.roomId,
roomName = summary.name,
isDm = false,
isFavorite = false,
markAsUnreadFeatureFlagEnabled = true,
// true here.
hasNewContent = false,
displayClearRoomCacheAction = true,
)
)
}
}
}
@Test
fun `present - hide context menu`() = runTest {
val room = FakeBaseRoom()
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val presenter = createRoomListPresenter(client = client)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val summary = createRoomListRoomSummary()
initialState.eventSink(RoomListEvents.ShowContextMenu(summary))
val shownState = awaitItem()
assertThat(shownState.contextMenu)
.isEqualTo(
RoomListState.ContextMenu.Shown(
roomId = summary.roomId,
roomName = summary.name,
isDm = false,
isFavorite = false,
markAsUnreadFeatureFlagEnabled = true,
hasNewContent = false,
displayClearRoomCacheAction = false,
)
)
shownState.eventSink(RoomListEvents.HideContextMenu)
val hiddenState = awaitItem()
assertThat(hiddenState.contextMenu).isEqualTo(RoomListState.ContextMenu.Hidden)
}
}
@Test
fun `present - leave room calls into leave room presenter`() = runTest {
val leaveRoomEventsRecorder = EventsRecorder<LeaveRoomEvent>()
val presenter = createRoomListPresenter(
leaveRoomState = aLeaveRoomState(eventSink = leaveRoomEventsRecorder),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomListEvents.LeaveRoom(A_ROOM_ID))
leaveRoomEventsRecorder.assertSingle(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - toggle search menu`() = runTest {
val eventRecorder = EventsRecorder<RoomListSearchEvents>()
val searchPresenter: Presenter<RoomListSearchState> = Presenter {
aRoomListSearchState(
eventSink = eventRecorder
)
}
val presenter = createRoomListPresenter(
searchPresenter = searchPresenter,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
eventRecorder.assertEmpty()
initialState.eventSink(RoomListEvents.ToggleSearchResults)
eventRecorder.assertSingle(
RoomListSearchEvents.ToggleSearchVisibility
)
initialState.eventSink(RoomListEvents.ToggleSearchResults)
eventRecorder.assertList(
listOf(
RoomListSearchEvents.ToggleSearchVisibility,
RoomListSearchEvents.ToggleSearchVisibility
)
)
}
}
@Test
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 matrixClient = FakeMatrixClient(
roomListService = roomListService,
notificationSettingsService = notificationSettingsService
)
val presenter = createRoomListPresenter(client = matrixClient)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
notificationSettingsService.setRoomNotificationMode(A_ROOM_ID, userDefinedMode)
val updatedState = consumeItemsUntilPredicate { state ->
(state.contentState as? RoomListContentState.Rooms)?.summaries.orEmpty().any { summary ->
summary.id == A_ROOM_ID.value && summary.userDefinedNotificationMode == userDefinedMode
}
}.last()
val room = updatedState.contentAsRooms().summaries.find { it.id == A_ROOM_ID.value }
assertThat(room?.userDefinedNotificationMode).isEqualTo(userDefinedMode)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - when set is favorite event is emitted, then the action is called`() = runTest {
val setIsFavoriteResult = lambdaRecorder { _: Boolean -> Result.success(Unit) }
val room = FakeBaseRoom(
setIsFavoriteResult = setIsFavoriteResult
)
val analyticsService = FakeAnalyticsService()
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val presenter = createRoomListPresenter(client = client, analyticsService = analyticsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomListEvents.SetRoomIsFavorite(A_ROOM_ID, true))
setIsFavoriteResult.assertions().isCalledOnce().with(value(true))
initialState.eventSink(RoomListEvents.SetRoomIsFavorite(A_ROOM_ID, false))
setIsFavoriteResult.assertions().isCalledExactly(2)
.withSequence(
listOf(value(true)),
listOf(value(false)),
)
assertThat(analyticsService.capturedEvents).containsExactly(
Interaction(name = Interaction.Name.MobileRoomListRoomContextMenuFavouriteToggle),
Interaction(name = Interaction.Name.MobileRoomListRoomContextMenuFavouriteToggle)
)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - when room service returns no room, then contentState is Empty`() = runTest {
val roomListService = FakeRoomListService()
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(0))
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val presenter = createRoomListPresenter(
client = matrixClient,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
assertThat(awaitItem().contentState).isInstanceOf(RoomListContentState.Empty::class.java)
}
}
@Test
fun `present - check that the room is marked as read with correct RR and as unread`() = runTest {
val markAsReadResult = lambdaRecorder<ReceiptType, Result<Unit>> { Result.success(Unit) }
val markAsReadResult3 = lambdaRecorder<ReceiptType, Result<Unit>> { Result.success(Unit) }
val room = FakeBaseRoom(
markAsReadResult = markAsReadResult,
)
val room2 = FakeBaseRoom(
roomId = A_ROOM_ID_2,
)
val room3 = FakeBaseRoom(
roomId = A_ROOM_ID_3,
markAsReadResult = markAsReadResult3,
)
val allRooms = setOf(room, room2, room3)
val sessionPreferencesStore = InMemorySessionPreferencesStore()
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
givenGetRoomResult(A_ROOM_ID_2, room2)
givenGetRoomResult(A_ROOM_ID_3, room3)
}
val analyticsService = FakeAnalyticsService()
val clearMessagesForRoomLambda = lambdaRecorder<SessionId, RoomId, Unit> { _, _ -> }
val notificationCleaner = FakeNotificationCleaner(
clearMessagesForRoomLambda = clearMessagesForRoomLambda,
)
val presenter = createRoomListPresenter(
client = matrixClient,
sessionPreferencesStore = sessionPreferencesStore,
analyticsService = analyticsService,
notificationCleaner = notificationCleaner,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
allRooms.forEach {
assertThat(it.setUnreadFlagCalls).isEmpty()
}
initialState.eventSink.invoke(RoomListEvents.MarkAsRead(A_ROOM_ID))
markAsReadResult.assertions().isCalledOnce().with(value(ReceiptType.READ))
assertThat(room.setUnreadFlagCalls).isEqualTo(listOf(false))
clearMessagesForRoomLambda.assertions().isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID))
initialState.eventSink.invoke(RoomListEvents.MarkAsUnread(A_ROOM_ID_2))
assertThat(room2.setUnreadFlagCalls).isEqualTo(listOf(true))
// Test again with private read receipts
sessionPreferencesStore.setSendPublicReadReceipts(false)
initialState.eventSink.invoke(RoomListEvents.MarkAsRead(A_ROOM_ID_3))
markAsReadResult3.assertions().isCalledOnce().with(value(ReceiptType.READ_PRIVATE))
assertThat(room3.setUnreadFlagCalls).isEqualTo(listOf(false))
clearMessagesForRoomLambda.assertions().isCalledExactly(2)
.withSequence(
listOf(value(A_SESSION_ID), value(A_ROOM_ID)),
listOf(value(A_SESSION_ID), value(A_ROOM_ID_3)),
)
assertThat(analyticsService.capturedEvents).containsExactly(
Interaction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle),
Interaction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle),
Interaction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle),
)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - when a room is invited then accept and decline events are sent to acceptDeclinePresenter`() = runTest {
val eventSinkRecorder = lambdaRecorder { _: AcceptDeclineInviteEvents -> }
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 presenter = createRoomListPresenter(
client = matrixClient,
acceptDeclineInvitePresenter = acceptDeclinePresenter
)
presenter.test {
val state = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
}.last()
val roomListRoomSummary = state.contentAsRooms().summaries.first {
it.id == roomSummary.roomId.value
}
state.eventSink(RoomListEvents.AcceptInvite(roomListRoomSummary))
state.eventSink(RoomListEvents.DeclineInvite(roomListRoomSummary, blockUser = false))
val inviteData = roomListRoomSummary.toInviteData()
assert(eventSinkRecorder)
.isCalledExactly(2)
.withSequence(
listOf(value(AcceptDeclineInviteEvents.AcceptInvite(inviteData))),
listOf(value(AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = false, shouldConfirm = false))),
)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@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 presenter = createRoomListPresenter(
client = matrixClient,
)
presenter.test {
val state = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
}.last()
state.eventSink(RoomListEvents.UpdateVisibleRange(IntRange(0, 10)))
// If called again, it will cancel the current one, which should not result in a test failure
state.eventSink(RoomListEvents.UpdateVisibleRange(IntRange(0, 11)))
advanceTimeBy(1.seconds)
subscribeToVisibleRoomsLambda.assertions().isCalledOnce()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@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 presenter = createRoomListPresenter(
client = matrixClient,
)
presenter.test {
val state = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
}.last()
state.eventSink(RoomListEvents.UpdateVisibleRange(IntRange(0, 10)))
advanceTimeBy(1.seconds)
subscribeToVisibleRoomsLambda.assertions().isCalledOnce()
// If called again, it will subscribe to the next items
state.eventSink(RoomListEvents.UpdateVisibleRange(IntRange(0, 11)))
advanceTimeBy(1.seconds)
subscribeToVisibleRoomsLambda.assertions().isCalledExactly(2)
}
}
private fun TestScope.createRoomListPresenter(
client: MatrixClient = FakeMatrixClient(),
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
dateFormatter: DateFormatter = FakeDateFormatter(),
roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter(),
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
filtersPresenter: Presenter<RoomListFiltersState> = Presenter { aRoomListFiltersState() },
searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() },
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
) = RoomListPresenter(
client = client,
leaveRoomPresenter = { leaveRoomState },
roomListDataSource = RoomListDataSource(
roomListService = client.roomListService,
roomListRoomSummaryFactory = aRoomListRoomSummaryFactory(
dateFormatter = dateFormatter,
roomLastMessageFormatter = roomLastMessageFormatter,
),
coroutineDispatchers = testCoroutineDispatchers(),
notificationSettingsService = client.notificationSettingsService(),
sessionCoroutineScope = backgroundScope,
dateTimeObserver = FakeDateTimeObserver(),
),
featureFlagService = featureFlagService,
searchPresenter = searchPresenter,
sessionPreferencesStore = sessionPreferencesStore,
filtersPresenter = filtersPresenter,
analyticsService = analyticsService,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() },
batteryOptimizationPresenter = { aBatteryOptimizationState() },
notificationCleaner = notificationCleaner,
appPreferencesStore = appPreferencesStore,
seenInvitesStore = seenInvitesStore,
)
}

View file

@ -0,0 +1,10 @@
/*
* Copyright 2024 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.features.home.impl.roomlist
internal fun RoomListState.contentAsRooms() = contentState as RoomListContentState.Rooms

View file

@ -0,0 +1,293 @@
/*
* Copyright 2024 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.features.home.impl.roomlist
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.home.impl.HomeView
import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.aHomeState
import io.element.android.features.home.impl.components.RoomListMenuAction
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.model.RoomSummaryDisplayType
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.setSafeContent
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RoomListViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `displaying the view automatically sends a couple of UpdateVisibleRangeEvents`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
rule.setRoomListView(
state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
eventSink = eventsRecorder,
)
)
eventsRecorder.assertList(
listOf(
RoomListEvents.UpdateVisibleRange(IntRange.EMPTY),
RoomListEvents.UpdateVisibleRange(0..2),
)
)
}
@Test
fun `clicking on close recovery key banner emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
rule.setRoomListView(
state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
eventSink = eventsRecorder,
)
)
// Remove automatic initial events
eventsRecorder.clear()
val close = rule.activity.getString(CommonStrings.action_close)
rule.onNodeWithContentDescription(close).performClick()
eventsRecorder.assertSingle(RoomListEvents.DismissBanner)
}
@Test
fun `clicking on close setup key banner emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
rule.setRoomListView(
state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery),
eventSink = eventsRecorder,
)
)
// Remove automatic initial events
eventsRecorder.clear()
val close = rule.activity.getString(CommonStrings.action_close)
rule.onNodeWithContentDescription(close).performClick()
eventsRecorder.assertSingle(RoomListEvents.DismissBanner)
}
@Test
fun `clicking on continue recovery key banner invokes the expected callback`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
ensureCalledOnce { callback ->
rule.setRoomListView(
state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
eventSink = eventsRecorder,
),
onConfirmRecoveryKeyClick = callback,
)
// Remove automatic initial events
eventsRecorder.clear()
rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertEmpty()
}
}
@Test
fun `clicking on continue setup key banner invokes the expected callback`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
ensureCalledOnce { callback ->
rule.setRoomListView(
state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery),
eventSink = eventsRecorder,
),
onSetUpRecoveryClick = callback,
)
// Remove automatic initial events
eventsRecorder.clear()
rule.clickOn(R.string.banner_set_up_recovery_submit)
eventsRecorder.assertEmpty()
}
}
@Test
fun `clicking on start chat when the session has no room invokes the expected callback`() {
val eventsRecorder = EventsRecorder<RoomListEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setRoomListView(
state = aRoomListState(
eventSink = eventsRecorder,
contentState = anEmptyContentState(),
),
onCreateRoomClick = callback,
)
rule.clickOn(CommonStrings.action_start_chat)
}
}
@Test
fun `clicking on a room invokes the expected callback`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val state = aRoomListState(
eventSink = eventsRecorder,
)
val room0 = state.contentAsRooms().summaries.first {
it.displayType == RoomSummaryDisplayType.ROOM
}
ensureCalledOnceWithParam(room0.roomId) { callback ->
rule.setRoomListView(
state = state,
onRoomClick = callback,
)
// Remove automatic initial events
eventsRecorder.clear()
rule.onNodeWithText(room0.lastMessage!!.toString()).performClick()
}
eventsRecorder.assertEmpty()
}
@Test
fun `clicking on a room twice invokes the expected callback only once`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val state = aRoomListState(
eventSink = eventsRecorder,
)
val room0 = state.contentAsRooms().summaries.first {
it.displayType == RoomSummaryDisplayType.ROOM
}
ensureCalledOnceWithParam(room0.roomId) { callback ->
rule.setRoomListView(
state = state,
onRoomClick = callback,
)
// Remove automatic initial events
eventsRecorder.clear()
rule.onNodeWithText(room0.lastMessage!!.toString())
.performClick()
.performClick()
}
eventsRecorder.assertEmpty()
}
@Test
fun `long clicking on a room emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val state = aRoomListState(
eventSink = eventsRecorder,
)
val room0 = state.contentAsRooms().summaries.first {
it.displayType == RoomSummaryDisplayType.ROOM
}
rule.setRoomListView(
state = state,
)
// Remove automatic initial events
eventsRecorder.clear()
rule.onNodeWithText(room0.lastMessage!!.toString()).performTouchInput { longClick() }
eventsRecorder.assertSingle(RoomListEvents.ShowContextMenu(room0))
}
@Test
fun `clicking on a room setting invokes the expected callback and emits expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val state = aRoomListState(
contextMenu = aContextMenuShown(),
eventSink = eventsRecorder,
)
val room0 = (state.contextMenu as RoomListState.ContextMenu.Shown).roomId
ensureCalledOnceWithParam(room0) { callback ->
rule.setRoomListView(
state = state,
onRoomSettingsClick = callback,
)
// Remove automatic initial events
eventsRecorder.clear()
rule.clickOn(CommonStrings.common_settings)
}
eventsRecorder.assertSingle(RoomListEvents.HideContextMenu)
}
@Test
fun `clicking on accept and decline invite emits the expected Events`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val state = aRoomListState(
eventSink = eventsRecorder,
)
val invitedRoom = state.contentAsRooms().summaries.first {
it.displayType == RoomSummaryDisplayType.INVITE
}
rule.setRoomListView(state = state)
// Remove automatic initial events
eventsRecorder.clear()
rule.clickOn(CommonStrings.action_accept)
rule.clickOn(CommonStrings.action_decline)
eventsRecorder.assertList(
listOf(
RoomListEvents.AcceptInvite(invitedRoom),
RoomListEvents.ShowDeclineInviteMenu(invitedRoom),
)
)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomListView(
state: RoomListState,
onRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onSettingsClick: () -> Unit = EnsureNeverCalled(),
onSetUpRecoveryClick: () -> Unit = EnsureNeverCalled(),
onConfirmRecoveryKeyClick: () -> Unit = EnsureNeverCalled(),
onCreateRoomClick: () -> Unit = EnsureNeverCalled(),
onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(),
onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onDeclineInviteAndBlockUser: (RoomListRoomSummary) -> Unit = EnsureNeverCalledWithParam(),
) {
setSafeContent {
HomeView(
homeState = aHomeState(roomListState = state),
onRoomClick = onRoomClick,
onSettingsClick = onSettingsClick,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onCreateRoomClick = onCreateRoomClick,
onRoomSettingsClick = onRoomSettingsClick,
onMenuActionClick = onMenuActionClick,
onDeclineInviteAndBlockUser = onDeclineInviteAndBlockUser,
onReportRoomClick = onReportRoomClick,
acceptDeclineInviteView = { },
)
}
}

View file

@ -0,0 +1,133 @@
/*
* Copyright 2024 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.features.home.impl.search
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
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.FakeRoomListService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RoomListSearchPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createRoomListSearchPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse()
assertThat(state.query).isEmpty()
assertThat(state.results).isEmpty()
}
}
}
@Test
fun `present - toggle search visibility`() = runTest {
val presenter = createRoomListSearchPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse()
state.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
}
awaitItem().let { state ->
assertThat(state.isSearchActive).isTrue()
state.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
}
awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse()
}
}
}
@Test
fun `present - query search changes`() = runTest {
val roomListService = FakeRoomListService()
val presenter = createRoomListSearchPresenter(roomListService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().let { state ->
assertThat(
roomListService.allRooms.currentFilter.value
).isEqualTo(
RoomListFilter.None
)
state.eventSink(RoomListSearchEvents.QueryChanged("Search"))
}
awaitItem().let { state ->
assertThat(state.query).isEqualTo("Search")
assertThat(
roomListService.allRooms.currentFilter.value
).isEqualTo(
RoomListFilter.NormalizedMatchRoomName("Search")
)
state.eventSink(RoomListSearchEvents.ClearQuery)
}
awaitItem().let { state ->
assertThat(state.query).isEmpty()
assertThat(
roomListService.allRooms.currentFilter.value
).isEqualTo(
RoomListFilter.None
)
}
}
}
@Test
fun `present - room list changes`() = runTest {
val roomListService = FakeRoomListService()
val presenter = createRoomListSearchPresenter(roomListService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().let { state ->
assertThat(state.results).isEmpty()
}
roomListService.postAllRooms(
listOf(aRoomSummary())
)
awaitItem().let { state ->
assertThat(state.results).hasSize(1)
}
roomListService.postAllRooms(emptyList())
awaitItem().let { state ->
assertThat(state.results).isEmpty()
}
}
}
}
fun TestScope.createRoomListSearchPresenter(
roomListService: RoomListService = FakeRoomListService(),
): RoomListSearchPresenter {
return RoomListSearchPresenter(
dataSource = RoomListSearchDataSource(
roomListService = roomListService,
roomSummaryFactory = aRoomListRoomSummaryFactory(
dateFormatter = FakeDateFormatter(),
roomLastMessageFormatter = FakeRoomLastMessageFormatter(),
),
coroutineDispatchers = testCoroutineDispatchers(),
),
)
}