Show blocked users list (#2437)

* Show blocked users list.

Also allow to unblock them from this list.

* Add non-blocking `AsyncIndicatorHost` component

* Use `StateFlow` for getting ignored users.

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa
2024-02-26 16:24:22 +01:00
committed by GitHub
parent 1dc6b0273b
commit f0700f9904
108 changed files with 1334 additions and 106 deletions

View File

@@ -18,13 +18,11 @@ package io.element.android.appnav.loggedin
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -39,9 +37,7 @@ fun LoggedInView(
.systemBarsPadding()
) {
SyncStateView(
modifier = Modifier
.padding(top = 8.dp)
.align(Alignment.TopCenter),
modifier = Modifier.align(Alignment.TopCenter),
isVisible = state.showSyncSpinner,
)
}

View File

@@ -20,25 +20,15 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics
import androidx.compose.foundation.shape.RoundedCornerShape
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.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@@ -46,38 +36,15 @@ fun SyncStateView(
isVisible: Boolean,
modifier: Modifier = Modifier
) {
val animationSpec = spring<Float>(stiffness = 500F)
AnimatedVisibility(
modifier = modifier,
visible = isVisible,
enter = fadeIn(animationSpec = animationSpec),
exit = fadeOut(animationSpec = animationSpec),
modifier = modifier,
enter = fadeIn(spring(stiffness = 500F)),
exit = fadeOut(spring(stiffness = 500F)),
) {
Surface(
shape = RoundedCornerShape(24.dp),
shadowElevation = 8.dp,
) {
Row(
modifier = Modifier
.background(color = ElementTheme.colors.bgSubtleSecondary)
.padding(horizontal = 24.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
CircularProgressIndicator(
modifier = Modifier
.progressSemantics()
.size(12.dp),
color = ElementTheme.colors.textPrimary,
strokeWidth = 1.5.dp,
)
Text(
text = stringResource(id = CommonStrings.common_syncing),
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyMdMedium
)
}
}
AsyncIndicator.Loading(
text = stringResource(id = CommonStrings.common_syncing),
)
}
}

View File

@@ -34,6 +34,7 @@ import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.preferences.impl.about.AboutNode
import io.element.android.features.preferences.impl.advanced.AdvancedSettingsNode
import io.element.android.features.preferences.impl.analytics.AnalyticsSettingsNode
import io.element.android.features.preferences.impl.blockedusers.BlockedUsersNode
import io.element.android.features.preferences.impl.developer.DeveloperSettingsNode
import io.element.android.features.preferences.impl.developer.tracing.ConfigureTracingNode
import io.element.android.features.preferences.impl.notifications.NotificationSettingsNode
@@ -93,6 +94,9 @@ class PreferencesFlowNode @AssistedInject constructor(
@Parcelize
data class UserProfile(val matrixUser: MatrixUser) : NavTarget
@Parcelize
data object BlockedUsers : NavTarget
@Parcelize
data object SignOut : NavTarget
}
@@ -141,6 +145,10 @@ class PreferencesFlowNode @AssistedInject constructor(
backstack.push(NavTarget.UserProfile(matrixUser))
}
override fun onOpenBlockedUsers() {
backstack.push(NavTarget.BlockedUsers)
}
override fun onSignOutClicked() {
backstack.push(NavTarget.SignOut)
}
@@ -193,6 +201,9 @@ class PreferencesFlowNode @AssistedInject constructor(
.target(LockScreenEntryPoint.Target.Settings)
.build()
}
NavTarget.BlockedUsers -> {
createNode<BlockedUsersNode>(buildContext)
}
NavTarget.SignOut -> {
val callBack: LogoutEntryPoint.Callback = object : LogoutEntryPoint.Callback {
override fun onChangeRecoveryKeyClicked() {

View File

@@ -0,0 +1,25 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.blockedusers
import io.element.android.libraries.matrix.api.core.UserId
sealed interface BlockedUsersEvents {
data class Unblock(val userId: UserId) : BlockedUsersEvents
data object ConfirmUnblock : BlockedUsersEvents
data object Cancel : BlockedUsersEvents
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.blockedusers
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class BlockedUsersNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: BlockedUsersPresenter,
) : Node(buildContext = buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
BlockedUsersView(
state = state,
onBackPressed = ::navigateUp,
modifier = modifier,
)
}
}

View File

@@ -0,0 +1,82 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.blockedusers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class BlockedUsersPresenter @Inject constructor(
private val matrixClient: MatrixClient,
) : Presenter<BlockedUsersState> {
@Composable
override fun present(): BlockedUsersState {
val coroutineScope = rememberCoroutineScope()
var pendingUserToUnblock by remember {
mutableStateOf<UserId?>(null)
}
val unblockUserAction: MutableState<AsyncAction<Unit>> = remember {
mutableStateOf(AsyncAction.Uninitialized)
}
val ignoredUserIds by matrixClient.ignoredUsersFlow.collectAsState()
fun handleEvents(event: BlockedUsersEvents) {
when (event) {
is BlockedUsersEvents.Unblock -> {
pendingUserToUnblock = event.userId
unblockUserAction.value = AsyncAction.Confirming
}
BlockedUsersEvents.ConfirmUnblock -> {
pendingUserToUnblock?.let {
coroutineScope.unblockUser(it, unblockUserAction)
pendingUserToUnblock = null
}
}
BlockedUsersEvents.Cancel -> {
pendingUserToUnblock = null
unblockUserAction.value = AsyncAction.Uninitialized
}
}
}
return BlockedUsersState(
blockedUsers = ignoredUserIds,
unblockUserAction = unblockUserAction.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.unblockUser(userId: UserId, asyncAction: MutableState<AsyncAction<Unit>>) = launch {
runUpdatingState(asyncAction) {
matrixClient.unignoreUser(userId)
}
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.blockedusers
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.ImmutableList
data class BlockedUsersState(
val blockedUsers: ImmutableList<UserId>,
val unblockUserAction: AsyncAction<Unit>,
val eventSink: (BlockedUsersEvents) -> Unit,
)

View File

@@ -0,0 +1,48 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.blockedusers
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.toPersistentList
class BlockedUsersStatePreviewProvider : PreviewParameterProvider<BlockedUsersState> {
override val values: Sequence<BlockedUsersState>
get() = sequenceOf(
aBlockedUsersState(),
aBlockedUsersState(blockedUsers = emptyList()),
aBlockedUsersState(unblockUserAction = AsyncAction.Confirming),
// Sadly there's no good way to preview Loading or Failure states since they're presented with an animation
// All these 3 screen states will be displayed as the Uninitialized one
aBlockedUsersState(unblockUserAction = AsyncAction.Loading),
aBlockedUsersState(unblockUserAction = AsyncAction.Failure(Throwable("Failed to unblock user"))),
aBlockedUsersState(unblockUserAction = AsyncAction.Success(Unit)),
)
}
internal fun aBlockedUsersState(
blockedUsers: List<UserId> = aMatrixUserList().map { it.userId },
unblockUserAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
): BlockedUsersState {
return BlockedUsersState(
blockedUsers = blockedUsers.toPersistentList(),
unblockUserAction = unblockUserAction,
eventSink = {},
)
}

View File

@@ -0,0 +1,137 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.blockedusers
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
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.components.MatrixUserRow
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BlockedUsersView(
state: BlockedUsersState,
onBackPressed: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(CommonStrings.common_blocked_users),
style = ElementTheme.typography.aliasScreenTitle,
)
},
navigationIcon = {
BackButton(onClick = onBackPressed)
}
)
}
) { padding ->
LazyColumn(
modifier = Modifier.padding(padding)
) {
items(state.blockedUsers) { userId ->
BlockedUserItem(
userId = userId,
onClick = { state.eventSink(BlockedUsersEvents.Unblock(it)) }
)
}
}
}
val asyncIndicatorState = rememberAsyncIndicatorState()
AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), state = asyncIndicatorState)
when (state.unblockUserAction) {
is AsyncAction.Loading -> {
LaunchedEffect(state.unblockUserAction) {
asyncIndicatorState.enqueue {
AsyncIndicator.Loading(text = stringResource(R.string.screen_blocked_users_unblocking))
}
}
}
is AsyncAction.Failure -> {
LaunchedEffect(state.unblockUserAction) {
asyncIndicatorState.enqueue(durationMs = AsyncIndicator.DURATION_SHORT) {
AsyncIndicator.Failure(text = stringResource(CommonStrings.common_failed))
}
}
}
is AsyncAction.Confirming -> {
ConfirmationDialog(
title = stringResource(R.string.screen_blocked_users_unblock_alert_title),
content = stringResource(R.string.screen_blocked_users_unblock_alert_description),
submitText = stringResource(R.string.screen_blocked_users_unblock_alert_action),
onSubmitClicked = { state.eventSink(BlockedUsersEvents.ConfirmUnblock) },
onDismiss = { state.eventSink(BlockedUsersEvents.Cancel) }
)
}
else -> Unit
}
}
}
@Composable
private fun BlockedUserItem(
userId: UserId,
onClick: (UserId) -> Unit,
) {
MatrixUserRow(
modifier = Modifier.clickable { onClick(userId) },
matrixUser = MatrixUser(userId),
)
}
@PreviewsDayNight
@Composable
internal fun BlockedUsersViewPreview(@PreviewParameter(BlockedUsersStatePreviewProvider::class) state: BlockedUsersState) {
ElementPreview {
BlockedUsersView(
state = state,
onBackPressed = {}
)
}
}

View File

@@ -53,6 +53,7 @@ class PreferencesRootNode @AssistedInject constructor(
fun onOpenLockScreenSettings()
fun onOpenAdvancedSettings()
fun onOpenUserProfile(matrixUser: MatrixUser)
fun onOpenBlockedUsers()
fun onSignOutClicked()
}
@@ -117,6 +118,10 @@ class PreferencesRootNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onOpenUserProfile(matrixUser) }
}
private fun onOpenBlockedUsers() {
plugins<Callback>().forEach { it.onOpenBlockedUsers() }
}
private fun onSignOutClicked() {
plugins<Callback>().forEach { it.onSignOutClicked() }
}
@@ -141,6 +146,7 @@ class PreferencesRootNode @AssistedInject constructor(
onOpenNotificationSettings = this::onOpenNotificationSettings,
onOpenLockScreenSettings = this::onOpenLockScreenSettings,
onOpenUserProfile = this::onOpenUserProfile,
onOpenBlockedUsers = this::onOpenBlockedUsers,
onSignOutClicked = {
if (state.directLogoutState.canDoDirectSignOut) {
state.directLogoutState.eventSink(DirectLogoutEvents.Logout(ignoreSdkError = false))

View File

@@ -62,6 +62,7 @@ fun PreferencesRootView(
onOpenAdvancedSettings: () -> Unit,
onOpenNotificationSettings: () -> Unit,
onOpenUserProfile: (MatrixUser) -> Unit,
onOpenBlockedUsers: () -> Unit,
onSignOutClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -121,6 +122,11 @@ fun PreferencesRootView(
onClick = onOpenNotificationSettings,
)
}
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_blocked_users)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())),
onClick = onOpenBlockedUsers,
)
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_report_a_problem)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())),
@@ -230,6 +236,7 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
onOpenNotificationSettings = {},
onOpenLockScreenSettings = {},
onOpenUserProfile = {},
onOpenBlockedUsers = {},
onSignOutClicked = {},
)
}

View File

@@ -7,6 +7,9 @@
<string name="screen_advanced_settings_element_call_base_url_validation_error">"Адрас пазначаны няправільна, пераканайцеся, што вы ўказалі пратакол (http/https) і правільны адрас."</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Адключыць рэдактар фарматаванага тэксту і ўключыць Markdown."</string>
<string name="screen_advanced_settings_view_source_description">"Уключыце опцыю для прагляду крыніцы паведамлення на часовай шкале."</string>
<string name="screen_blocked_users_unblock_alert_action">"Разблакіраваць"</string>
<string name="screen_blocked_users_unblock_alert_description">"Вы зноў зможаце ўбачыць усе паведамленні."</string>
<string name="screen_blocked_users_unblock_alert_title">"Разблакіраваць карыстальніка"</string>
<string name="screen_edit_profile_display_name">"Бачнае імя"</string>
<string name="screen_edit_profile_display_name_placeholder">"Ваша бачнае імя"</string>
<string name="screen_edit_profile_error">"Узнікла невядомая памылка, і інфармацыю не ўдалося змяніць."</string>

View File

@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_advanced_settings_send_read_receipts">"Потвърждения за прочитане"</string>
<string name="screen_blocked_users_unblock_alert_action">"Отблокиране"</string>
<string name="screen_blocked_users_unblock_alert_title">"Отблокиране на потребителя"</string>
<string name="screen_edit_profile_display_name">"Име"</string>
<string name="screen_edit_profile_display_name_placeholder">"Вашето Име"</string>
<string name="screen_edit_profile_error">"Възникна неизвестна грешка и информацията не можа да бъде променена."</string>

View File

@@ -11,6 +11,9 @@
<string name="screen_advanced_settings_share_presence">"Sdílejte přítomnost"</string>
<string name="screen_advanced_settings_share_presence_description">"Pokud je tato funkce vypnutá, nebudete moci odesílat ani přijímat potvrzení o přečtení ani upozornění na psaní"</string>
<string name="screen_advanced_settings_view_source_description">"Povolit možnost zobrazení zdroje zprávy na časové ose."</string>
<string name="screen_blocked_users_unblock_alert_action">"Odblokovat"</string>
<string name="screen_blocked_users_unblock_alert_description">"Znovu uvidíte všechny zprávy od nich."</string>
<string name="screen_blocked_users_unblock_alert_title">"Odblokovat uživatele"</string>
<string name="screen_edit_profile_display_name">"Zobrazované jméno"</string>
<string name="screen_edit_profile_display_name_placeholder">"Vaše zobrazované jméno"</string>
<string name="screen_edit_profile_error">"Došlo k neznámé chybě a informace nelze změnit."</string>

View File

@@ -11,6 +11,10 @@
<string name="screen_advanced_settings_share_presence">"Präsenz teilen"</string>
<string name="screen_advanced_settings_share_presence_description">"Wenn diese Option deaktiviert ist, kannst du keine Lesebestätigungen oder Tipp-Benachrichtigungen senden oder empfangen."</string>
<string name="screen_advanced_settings_view_source_description">"Option aktiveren, um Nachrichtenquelle in der Zeitleiste anzuzeigen."</string>
<string name="screen_blocked_users_unblock_alert_action">"Blockierung aufheben"</string>
<string name="screen_blocked_users_unblock_alert_description">"Du kannst dann wieder alle Nachrichten von ihnen sehen."</string>
<string name="screen_blocked_users_unblock_alert_title">"Blockierung aufheben"</string>
<string name="screen_blocked_users_unblocking">"Blockierung wird aufgehoben…"</string>
<string name="screen_edit_profile_display_name">"Anzeigename"</string>
<string name="screen_edit_profile_display_name_placeholder">"Dein Anzeigename"</string>
<string name="screen_edit_profile_error">"Ein unbekannter Fehler ist aufgetreten und die Informationen konnten nicht geändert werden."</string>

View File

@@ -7,6 +7,9 @@
<string name="screen_advanced_settings_element_call_base_url_validation_error">"URL no válida, asegúrate de incluir el protocolo (http/https) y la dirección correcta."</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Desactiva el editor de texto enriquecido para escribir Markdown manualmente."</string>
<string name="screen_advanced_settings_view_source_description">"Habilita la opción para ver el contenido en bruto del mensaje en la cronología."</string>
<string name="screen_blocked_users_unblock_alert_action">"Desbloquear"</string>
<string name="screen_blocked_users_unblock_alert_description">"Podrás ver todos sus mensajes de nuevo."</string>
<string name="screen_blocked_users_unblock_alert_title">"Desbloquear usuario"</string>
<string name="screen_edit_profile_display_name">"Nombre público"</string>
<string name="screen_edit_profile_display_name_placeholder">"Tu nombre visible"</string>
<string name="screen_edit_profile_error">"Se encontró un error desconocido y no se pudo cambiar la información."</string>

View File

@@ -8,7 +8,12 @@
<string name="screen_advanced_settings_rich_text_editor_description">"Désactivez léditeur de texte enrichi pour saisir manuellement du Markdown."</string>
<string name="screen_advanced_settings_send_read_receipts">"Accusés de lecture"</string>
<string name="screen_advanced_settings_send_read_receipts_description">"En cas de désactivation, vos accusés de lecture ne seront pas envoyés aux autres membres. Vous verrez toujours les accusés des autres membres."</string>
<string name="screen_advanced_settings_share_presence">"Partager la présence"</string>
<string name="screen_advanced_settings_share_presence_description">"Si cette option est désactivée, vous ne pourrez ni envoyer ni recevoir de confirmations de lecture ni de notifications de saisie"</string>
<string name="screen_advanced_settings_view_source_description">"Activer cette option pour pouvoir voir la source des messages dans la discussion."</string>
<string name="screen_blocked_users_unblock_alert_action">"Débloquer"</string>
<string name="screen_blocked_users_unblock_alert_description">"Vous pourrez à nouveau voir tous ses messages."</string>
<string name="screen_blocked_users_unblock_alert_title">"Débloquer lutilisateur"</string>
<string name="screen_edit_profile_display_name">"Pseudonyme"</string>
<string name="screen_edit_profile_display_name_placeholder">"Votre pseudonyme"</string>
<string name="screen_edit_profile_error">"Une erreur inconnue sest produite et les informations nont pas pu être modifiées."</string>

View File

@@ -11,6 +11,9 @@
<string name="screen_advanced_settings_share_presence">"Jelenlét megosztása"</string>
<string name="screen_advanced_settings_share_presence_description">"Ha ki van kapcsolva, nem tud olvasási visszaigazolást vagy írási értesítést küldeni és fogadni"</string>
<string name="screen_advanced_settings_view_source_description">"Engedélyezd a beállítást az üzenet forrásának megjelenítéséhez az idővonalon."</string>
<string name="screen_blocked_users_unblock_alert_action">"Letiltás feloldása"</string>
<string name="screen_blocked_users_unblock_alert_description">"Újra láthatja az összes üzenetét."</string>
<string name="screen_blocked_users_unblock_alert_title">"Felhasználó kitiltásának feloldása"</string>
<string name="screen_edit_profile_display_name">"Megjelenítendő név"</string>
<string name="screen_edit_profile_display_name_placeholder">"Saját megjelenítendő név"</string>
<string name="screen_edit_profile_error">"Ismeretlen hiba történt, és az információ módosítása nem sikerült."</string>

View File

@@ -7,6 +7,9 @@
<string name="screen_advanced_settings_element_call_base_url_validation_error">"URL tidak valid, pastikan Anda menyertakan protokol (http/https) dan alamat yang benar."</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Nonaktifkan penyunting teks kaya untuk mengetik Markdown secara manual."</string>
<string name="screen_advanced_settings_view_source_description">"Aktifkan opsi untuk melihat sumber pesan dalam lini masa."</string>
<string name="screen_blocked_users_unblock_alert_action">"Buka blokir"</string>
<string name="screen_blocked_users_unblock_alert_description">"Anda akan dapat melihat semua pesan dari mereka lagi."</string>
<string name="screen_blocked_users_unblock_alert_title">"Buka blokir pengguna"</string>
<string name="screen_edit_profile_display_name">"Nama tampilan"</string>
<string name="screen_edit_profile_display_name_placeholder">"Nama tampilan Anda"</string>
<string name="screen_edit_profile_error">"Terjadi kesalahan yang tidak diketahui dan informasi tidak dapat diubah."</string>

View File

@@ -11,6 +11,9 @@
<string name="screen_advanced_settings_share_presence">"Condividi presenza online"</string>
<string name="screen_advanced_settings_share_presence_description">"Se disattivato, non potrai inviare o ricevere ricevute di lettura o notifiche di digitazione."</string>
<string name="screen_advanced_settings_view_source_description">"Attiva l\'opzione per visualizzare il sorgente del messaggio nella linea temporale."</string>
<string name="screen_blocked_users_unblock_alert_action">"Sblocca"</string>
<string name="screen_blocked_users_unblock_alert_description">"Potrai vedere di nuovo tutti i suoi messaggi."</string>
<string name="screen_blocked_users_unblock_alert_title">"Sblocca utente"</string>
<string name="screen_edit_profile_display_name">"Nome da mostrare"</string>
<string name="screen_edit_profile_display_name_placeholder">"Il tuo nome da mostrare"</string>
<string name="screen_edit_profile_error">"Si è verificato un errore sconosciuto e non è stato possibile modificare le informazioni."</string>

View File

@@ -11,6 +11,9 @@
<string name="screen_advanced_settings_share_presence">"Împărtășiți prezența"</string>
<string name="screen_advanced_settings_share_presence_description">"Dacă dezactivată, nu veți putea trimite sau primi chitanțe de citire sau notificări de tastare."</string>
<string name="screen_advanced_settings_view_source_description">"Activați opțiunea pentru a vizualiza sursa mesajelor."</string>
<string name="screen_blocked_users_unblock_alert_action">"Deblocați"</string>
<string name="screen_blocked_users_unblock_alert_description">"La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta."</string>
<string name="screen_blocked_users_unblock_alert_title">"Deblocați utilizatorul"</string>
<string name="screen_edit_profile_display_name">"Nume"</string>
<string name="screen_edit_profile_display_name_placeholder">"Numele dumneavoastra"</string>
<string name="screen_edit_profile_error">"A fost întâlnită o eroare necunoscută și informațiile nu au putut fi modificate."</string>

View File

@@ -11,6 +11,10 @@
<string name="screen_advanced_settings_share_presence">"Поделиться присутствием"</string>
<string name="screen_advanced_settings_share_presence_description">"Если выключено, вы не сможете отправлять, получать уведомления о прочтении и наборе текста"</string>
<string name="screen_advanced_settings_view_source_description">"Включить опцию просмотра источника сообщения в ленте."</string>
<string name="screen_blocked_users_unblock_alert_action">"Разблокировать"</string>
<string name="screen_blocked_users_unblock_alert_description">"Вы снова сможете увидеть все сообщения."</string>
<string name="screen_blocked_users_unblock_alert_title">"Разблокировать пользователя"</string>
<string name="screen_blocked_users_unblocking">"Разблокировка…"</string>
<string name="screen_edit_profile_display_name">"Отображаемое имя"</string>
<string name="screen_edit_profile_display_name_placeholder">"Ваше отображаемое имя"</string>
<string name="screen_edit_profile_error">"Произошла неизвестная ошибка, изменить информацию не удалось."</string>

View File

@@ -11,6 +11,9 @@
<string name="screen_advanced_settings_share_presence">"Zdieľať prítomnosť"</string>
<string name="screen_advanced_settings_share_presence_description">"Ak je vypnuté, nebudete môcť odosielať ani prijímať potvrdenia o prečítaní alebo písať upozornenia"</string>
<string name="screen_advanced_settings_view_source_description">"Povoliť možnosť zobrazenia zdroja správy na časovej osi."</string>
<string name="screen_blocked_users_unblock_alert_action">"Odblokovať"</string>
<string name="screen_blocked_users_unblock_alert_description">"Všetky správy od nich budete môcť opäť vidieť."</string>
<string name="screen_blocked_users_unblock_alert_title">"Odblokovať používateľa"</string>
<string name="screen_edit_profile_display_name">"Zobrazované meno"</string>
<string name="screen_edit_profile_display_name_placeholder">"Vaše zobrazované meno"</string>
<string name="screen_edit_profile_error">"Vyskytla sa neznáma chyba a informácie nebolo možné zmeniť."</string>

View File

@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_blocked_users_unblock_alert_action">"Avblockera"</string>
<string name="screen_blocked_users_unblock_alert_description">"Du kommer att kunna se alla meddelanden från dem igen."</string>
<string name="screen_blocked_users_unblock_alert_title">"Avblockera användare"</string>
<string name="screen_edit_profile_display_name">"Visningsnamn"</string>
<string name="screen_edit_profile_display_name_placeholder">"Ditt visningsnamn"</string>
<string name="screen_edit_profile_error">"Ett okänt fel påträffades och informationen kunde inte ändras."</string>

View File

@@ -11,6 +11,10 @@
<string name="screen_advanced_settings_share_presence">"Share presence"</string>
<string name="screen_advanced_settings_share_presence_description">"If turned off, you wont be able to send or receive read receipts or typing notifications"</string>
<string name="screen_advanced_settings_view_source_description">"Enable option to view message source in the timeline."</string>
<string name="screen_blocked_users_unblock_alert_action">"Unblock"</string>
<string name="screen_blocked_users_unblock_alert_description">"You\'ll be able to see all messages from them again."</string>
<string name="screen_blocked_users_unblock_alert_title">"Unblock user"</string>
<string name="screen_blocked_users_unblocking">"Unblocking…"</string>
<string name="screen_edit_profile_display_name">"Display name"</string>
<string name="screen_edit_profile_display_name_placeholder">"Your display name"</string>
<string name="screen_edit_profile_error">"An unknown error was encountered and the information couldn\'t be changed."</string>

View File

@@ -0,0 +1,161 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.preferences.impl.blockedusers
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.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
class BlockedUsersPresenterTests {
@Test
fun `present - initial state with no blocked users`() = runTest {
val presenter = aBlockedUsersPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
with(awaitItem()) {
assertThat(blockedUsers).isEmpty()
assertThat(unblockUserAction).isEqualTo(AsyncAction.Uninitialized)
}
}
}
@Test
fun `present - initial state with blocked users`() = runTest {
val matrixClient = FakeMatrixClient().apply {
ignoredUsersFlow.value = persistentListOf(A_USER_ID)
}
val presenter = aBlockedUsersPresenter(matrixClient = matrixClient)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
with(awaitItem()) {
assertThat(blockedUsers).isEqualTo(persistentListOf(A_USER_ID))
assertThat(unblockUserAction).isEqualTo(AsyncAction.Uninitialized)
}
}
}
@Test
fun `present - blocked users list updates with new emissions`() = runTest {
val matrixClient = FakeMatrixClient().apply {
ignoredUsersFlow.value = persistentListOf(A_USER_ID)
}
val presenter = aBlockedUsersPresenter(matrixClient = matrixClient)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
with(awaitItem()) {
assertThat(blockedUsers).containsAtLeastElementsIn(persistentListOf(A_USER_ID))
assertThat(unblockUserAction).isEqualTo(AsyncAction.Uninitialized)
}
matrixClient.ignoredUsersFlow.value = persistentListOf(A_USER_ID, A_USER_ID_2)
with(awaitItem()) {
assertThat(blockedUsers).isEqualTo(persistentListOf(A_USER_ID, A_USER_ID_2))
assertThat(unblockUserAction).isEqualTo(AsyncAction.Uninitialized)
}
}
}
@Test
fun `present - unblock user`() = runTest {
val matrixClient = FakeMatrixClient().apply {
ignoredUsersFlow.value = persistentListOf(A_USER_ID)
}
val presenter = aBlockedUsersPresenter(matrixClient = matrixClient)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(BlockedUsersEvents.Unblock(A_USER_ID))
assertThat(awaitItem().unblockUserAction).isInstanceOf(AsyncAction.Confirming::class.java)
initialState.eventSink(BlockedUsersEvents.ConfirmUnblock)
assertThat(awaitItem().unblockUserAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().unblockUserAction).isInstanceOf(AsyncAction.Success::class.java)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - unblock user handles failure`() = runTest {
val matrixClient = FakeMatrixClient().apply {
ignoredUsersFlow.value = persistentListOf(A_USER_ID)
givenUnignoreUserResult(Result.failure(IllegalStateException("User not banned")))
}
val presenter = aBlockedUsersPresenter(matrixClient = matrixClient)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(BlockedUsersEvents.Unblock(A_USER_ID))
assertThat(awaitItem().unblockUserAction).isInstanceOf(AsyncAction.Confirming::class.java)
initialState.eventSink(BlockedUsersEvents.ConfirmUnblock)
assertThat(awaitItem().unblockUserAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().unblockUserAction).isInstanceOf(AsyncAction.Failure::class.java)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - unblock user then cancel`() = runTest {
val matrixClient = FakeMatrixClient().apply {
ignoredUsersFlow.value = persistentListOf(A_USER_ID)
givenUnignoreUserResult(Result.failure(IllegalStateException("User not banned")))
}
val presenter = aBlockedUsersPresenter(matrixClient = matrixClient)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(BlockedUsersEvents.Unblock(A_USER_ID))
assertThat(awaitItem().unblockUserAction).isInstanceOf(AsyncAction.Confirming::class.java)
initialState.eventSink(BlockedUsersEvents.Cancel)
assertThat(awaitItem().unblockUserAction).isEqualTo(AsyncAction.Uninitialized)
}
}
@Test
fun `present - confirm unblock without a pending blocked user does nothing`() = runTest {
val presenter = aBlockedUsersPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(BlockedUsersEvents.ConfirmUnblock)
ensureAllEventsConsumed()
}
}
private fun aBlockedUsersPresenter(
matrixClient: FakeMatrixClient = FakeMatrixClient(),
) = BlockedUsersPresenter(matrixClient)
}

View File

@@ -0,0 +1,117 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.components.async
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
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.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
/**
* A helper to create [AsyncIndicatorView] with some defaults.
*/
@Stable
object AsyncIndicator {
/**
* A loading async indicator.
* @param text The text to display.
* @param modifier The modifier to apply to the indicator.
*/
@Composable
fun Loading(
text: String,
modifier: Modifier = Modifier,
) {
AsyncIndicatorView(
modifier = modifier,
text = text,
spacing = 10.dp,
) {
CircularProgressIndicator(
modifier = Modifier
.progressSemantics()
.size(12.dp),
color = ElementTheme.colors.textPrimary,
strokeWidth = 1.5.dp,
)
}
}
/**
* A failure async indicator.
* @param text The text to display.
* @param modifier The modifier to apply to the indicator.
*/
@Composable
fun Failure(
text: String,
modifier: Modifier = Modifier,
) {
AsyncIndicatorView(
modifier = modifier,
text = text,
spacing = defaultSpacing
) {
Icon(
modifier = Modifier.size(18.dp),
imageVector = CompoundIcons.Close(),
contentDescription = null,
)
}
}
/**
* A custom async indicator.
* @param text The text to display.
* @param modifier The modifier to apply to the indicator.
* @param spacing The spacing between the leading content and the text.
* @param leadingContent The leading content to display.
*/
@Composable
fun Custom(
text: String,
modifier: Modifier = Modifier,
spacing: Dp = defaultSpacing,
leadingContent: @Composable (() -> Unit)? = null,
) {
AsyncIndicatorView(
modifier = modifier,
text = text,
spacing = spacing,
leadingContent = leadingContent,
)
}
/**
* A short duration to display indicators.
*/
const val DURATION_SHORT = 3000L
/**
* A long duration to display indicators.
*/
const val DURATION_LONG = 5000L
private val defaultSpacing = 4.dp
}

View File

@@ -0,0 +1,152 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.components.async
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Stable
class AsyncIndicatorState {
private val queue = SnapshotStateList<AsyncIndicatorItem>()
val currentItem = mutableStateOf<AsyncIndicatorItem?>(null)
val currentAnimationState = MutableTransitionState(false)
/**
* Enqueue a new indicator to be displayed.
* @param durationMs The duration to display the indicator, if `null` (the default value) it will be displayed indefinitely, until the next indicator is
* displayed or the current one is manually cleared.
* @param composable The composable to display.
*/
fun enqueue(durationMs: Long? = null, composable: @Composable () -> Unit) {
queue.add(AsyncIndicatorItem(composable, durationMs))
if (currentItem.value == null || currentItem.value?.durationMs == null) {
nextState()
}
}
internal fun nextState() {
if (!currentAnimationState.isIdle) return
if (currentItem.value != null && currentAnimationState.currentState && currentAnimationState.isIdle) {
// Is visible and not animating, start the exit animation
currentAnimationState.targetState = false
} else if (currentItem.value == null || !currentAnimationState.currentState && currentAnimationState.isIdle) {
// Not visible or present, start the enter animation for the next item
val newItem = queue.removeFirstOrNull()
if (newItem != null) {
currentItem.value = null
currentAnimationState.targetState = true
}
currentItem.value = newItem
}
}
/**
* Clear the current indicator using its exit animation.
*/
fun clear() {
currentAnimationState.targetState = false
}
}
/**
* An item to be displayed in the [AsyncIndicatorHost].
*/
data class AsyncIndicatorItem(
val composable: @Composable () -> Unit,
val durationMs: Long? = null,
)
/**
* Remember an [AsyncIndicatorState] instance.
*/
@Composable
fun rememberAsyncIndicatorState(): AsyncIndicatorState {
return remember { AsyncIndicatorState() }
}
/**
* A host for displaying async indicators.
* @param modifier The modifier to apply.
* @param state The [AsyncIndicatorState] which values this component will display.
* @param enterTransition The enter transition to use for the displayed indicators.
* @param exitTransition The exit transition to use for the hiding indicators.
*/
@Composable
fun AsyncIndicatorHost(
modifier: Modifier = Modifier,
state: AsyncIndicatorState = rememberAsyncIndicatorState(),
enterTransition: EnterTransition = fadeIn(spring(stiffness = 500F)) + slideInVertically(),
exitTransition: ExitTransition = fadeOut(spring(stiffness = 500F)) + slideOutVertically(),
) {
val coroutineScope = rememberCoroutineScope()
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.TopCenter,
) {
if (LocalInspectionMode.current) {
state.currentItem.value?.composable?.invoke()
} else {
state.currentItem.value?.let { item ->
AnimatedVisibility(
visibleState = state.currentAnimationState,
enter = enterTransition,
exit = exitTransition,
) {
item.composable()
}
if (state.currentAnimationState.hasEntered() && item.durationMs != null) {
SideEffect {
coroutineScope.launch {
delay(item.durationMs)
state.nextState()
}
}
} else if (state.currentAnimationState.hasExited()) {
SideEffect {
state.nextState()
}
}
}
}
}
}
internal fun MutableTransitionState<Boolean>.hasEntered() = currentState && isIdle
internal fun MutableTransitionState<Boolean>.hasExited() = !currentState && isIdle

View File

@@ -0,0 +1,90 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.components.async
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
internal fun AsyncIndicatorView(
text: String,
spacing: Dp,
modifier: Modifier = Modifier,
elevation: Dp = 8.dp,
leadingContent: @Composable (() -> Unit)?,
) {
Box(
modifier = modifier
.padding(horizontal = 32.dp)
.padding(elevation)
) {
Surface(
shape = RoundedCornerShape(24.dp),
shadowElevation = elevation,
) {
Row(
modifier = Modifier
.background(color = ElementTheme.colors.bgSubtleSecondary)
.padding(horizontal = 24.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing)
) {
leadingContent?.let { view ->
view()
}
Text(
text = text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyMdMedium
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun AsyncIndicatorView_Loading_Preview() {
ElementPreview {
AsyncIndicator.Loading(text = "Loading")
}
}
@PreviewsDayNight
@Composable
internal fun AsyncIndicatorView_Failed_Preview() {
ElementPreview {
AsyncIndicator.Failure(text = "Failed")
}
}

View File

@@ -0,0 +1,280 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.component.async
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.updateTransition
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.rememberCoroutineScope
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.libraries.designsystem.components.async.AsyncIndicatorItem
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorState
import io.element.android.libraries.designsystem.components.async.hasEntered
import io.element.android.libraries.designsystem.components.async.hasExited
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AsyncIndicatorTests {
@Test
fun `initial state`() = runTest {
val state = AsyncIndicatorState()
moleculeFlow(RecompositionMode.Immediate) {
val transitionState = fakeAsyncIndicatorHost(state = state)
val item = state.currentItem.value
Snapshot(
currentItem = item,
currentAnimationState = TransitionStateSnapshot(transitionState),
)
}.test {
with(awaitItem()) {
assertThat(currentItem).isNull()
assertThat(currentAnimationState.currentState).isFalse()
assertThat(currentAnimationState.targetState).isFalse()
}
}
}
@Test
fun `add item with timeout`() = runTest(StandardTestDispatcher()) {
val state = AsyncIndicatorState()
moleculeFlow(RecompositionMode.Immediate) {
val transitionState = fakeAsyncIndicatorHost(state = state)
val item = state.currentItem.value
Snapshot(
currentItem = item,
currentAnimationState = TransitionStateSnapshot(transitionState),
)
}.test {
skipItems(1)
state.enqueue(durationMs = 1000, composable = {})
// Give it some time to pre-load the events
advanceTimeBy(1000)
runCurrent()
// First, item is invisible but the target state is visible (will start animating)
with(awaitItem()) {
assertThat(currentItem).isNotNull()
assertThat(currentAnimationState.currentState).isFalse()
assertThat(currentAnimationState.targetState).isTrue()
}
// Then, item is visible and the target state is visible (stopped animating)
with(awaitItem()) {
assertThat(currentItem).isNotNull()
assertThat(currentAnimationState.currentState).isTrue()
assertThat(currentAnimationState.targetState).isTrue()
}
// Then, item is visible and the target state is not visible (will start animating)
with(awaitItem()) {
assertThat(currentItem).isNotNull()
assertThat(currentAnimationState.currentState).isTrue()
assertThat(currentAnimationState.targetState).isFalse()
}
// Then, item is not visible and the target state is not visible (stopped animating)
with(awaitItem()) {
assertThat(currentItem).isNotNull()
assertThat(currentAnimationState.currentState).isFalse()
assertThat(currentAnimationState.targetState).isFalse()
}
// Finally, the current item is removed
with(awaitItem()) {
assertThat(currentItem).isNull()
}
}
}
@Test
fun `add item without timeout`() = runTest(StandardTestDispatcher()) {
val state = AsyncIndicatorState()
moleculeFlow(RecompositionMode.Immediate) {
val transitionState = fakeAsyncIndicatorHost(state = state)
val item = state.currentItem.value
Snapshot(
currentItem = item,
currentAnimationState = TransitionStateSnapshot(transitionState),
)
}.test {
skipItems(1)
state.enqueue(composable = {})
// First, item is invisible but the target state is visible (will start animating)
with(awaitItem()) {
assertThat(currentItem).isNotNull()
assertThat(currentAnimationState.currentState).isFalse()
assertThat(currentAnimationState.targetState).isTrue()
}
// Then, item is visible and the target state is visible (stopped animating)
with(awaitItem()) {
assertThat(currentItem).isNotNull()
assertThat(currentAnimationState.currentState).isTrue()
assertThat(currentAnimationState.targetState).isTrue()
}
// That's all, the current item will be displayed indefinitely
ensureAllEventsConsumed()
}
}
@Test
fun `add item without timeout then clear`() = runTest(StandardTestDispatcher()) {
val state = AsyncIndicatorState()
moleculeFlow(RecompositionMode.Immediate) {
val transitionState = fakeAsyncIndicatorHost(state = state)
val item = state.currentItem.value
Snapshot(
currentItem = item,
currentAnimationState = TransitionStateSnapshot(transitionState),
)
}.test {
skipItems(1)
state.enqueue(composable = {})
// First, item is invisible but the target state is visible (will start animating)
with(awaitItem()) {
assertThat(currentItem).isNotNull()
assertThat(currentAnimationState.currentState).isFalse()
assertThat(currentAnimationState.targetState).isTrue()
}
// Then, item is visible and the target state is visible (stopped animating)
with(awaitItem()) {
assertThat(currentItem).isNotNull()
assertThat(currentAnimationState.currentState).isTrue()
assertThat(currentAnimationState.targetState).isTrue()
}
// Clear the current item
state.clear()
// Animating the exit animation
with(awaitItem()) {
assertThat(currentItem).isNotNull()
assertThat(currentAnimationState.currentState).isTrue()
assertThat(currentAnimationState.targetState).isFalse()
}
// Current item is no longer visible
with(awaitItem()) {
assertThat(currentItem).isNotNull()
assertThat(currentAnimationState.currentState).isFalse()
assertThat(currentAnimationState.targetState).isFalse()
}
// Finally, the current item is removed
with(awaitItem()) {
assertThat(currentItem).isNull()
}
}
}
@Test
fun `add item without timeout, then another one`() = runTest(StandardTestDispatcher()) {
val state = AsyncIndicatorState()
moleculeFlow(RecompositionMode.Immediate) {
val transitionState = fakeAsyncIndicatorHost(state = state)
val item = state.currentItem.value
Snapshot(
currentItem = item,
currentAnimationState = TransitionStateSnapshot(transitionState),
)
}.test {
var firstItem: Any? = null
skipItems(1)
state.enqueue(composable = {})
state.enqueue(composable = {})
// First, item is invisible but the target state is visible (will start animating)
with(awaitItem()) {
firstItem = currentItem
assertThat(currentItem).isNotNull()
assertThat(currentAnimationState.currentState).isFalse()
assertThat(currentAnimationState.targetState).isTrue()
}
// Then, item is visible and the target state is visible (stopped animating)
with(awaitItem()) {
assertThat(currentItem).isNotNull()
assertThat(currentAnimationState.currentState).isTrue()
assertThat(currentAnimationState.targetState).isTrue()
}
// Then, item is visible and the target state is not visible (will start animating)
with(awaitItem()) {
assertThat(currentItem).isNotNull()
assertThat(currentAnimationState.currentState).isTrue()
assertThat(currentAnimationState.targetState).isFalse()
}
// Then, item is not visible and the target state is not visible (stopped animating)
with(awaitItem()) {
assertThat(currentItem).isNotNull()
assertThat(currentAnimationState.currentState).isFalse()
assertThat(currentAnimationState.targetState).isFalse()
}
// Then a new item will be not visible and its target animation visible
with(awaitItem()) {
assertThat(currentItem).isNotNull()
assertThat(firstItem).isNotEqualTo(currentItem)
assertThat(currentAnimationState.currentState).isFalse()
assertThat(currentAnimationState.targetState).isTrue()
}
// Finally, the second item is visible and not animating
with(awaitItem()) {
assertThat(currentItem).isNotNull()
assertThat(firstItem).isNotEqualTo(currentItem)
assertThat(currentAnimationState.currentState).isTrue()
assertThat(currentAnimationState.targetState).isTrue()
}
// That's all, the current item will be displayed indefinitely
ensureAllEventsConsumed()
}
}
@Composable
private fun fakeAsyncIndicatorHost(state: AsyncIndicatorState): Transition<Boolean>? {
val coroutineScope = rememberCoroutineScope()
val transition = state.currentItem.value?.let {
// If there is an item, update its transition state to simulate an animation
updateTransition(state.currentAnimationState, label = "")
}
if (state.currentAnimationState.hasEntered() && state.currentItem.value?.durationMs != null) {
SideEffect {
coroutineScope.launch {
delay(state.currentItem.value!!.durationMs!!)
state.nextState()
}
}
} else if (state.currentItem.value != null && state.currentAnimationState.hasExited()) {
SideEffect {
state.nextState()
}
}
return transition
}
private data class Snapshot(
val currentItem: AsyncIndicatorItem?,
val currentAnimationState: TransitionStateSnapshot,
)
private data class TransitionStateSnapshot(
val currentState: Boolean,
val targetState: Boolean,
) {
constructor(transition: Transition<Boolean>?) : this(
currentState = transition?.currentState ?: false,
targetState = transition?.targetState ?: false,
)
}
}

View File

@@ -34,7 +34,9 @@ import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
import java.io.Closeable
interface MatrixClient : Closeable {
@@ -43,6 +45,7 @@ interface MatrixClient : Closeable {
val roomListService: RoomListService
val mediaLoader: MatrixMediaLoader
val sessionCoroutineScope: CoroutineScope
val ignoredUsersFlow: StateFlow<ImmutableList<UserId>>
suspend fun getRoom(roomId: RoomId): MatrixRoom?
suspend fun findDM(userId: UserId): RoomId?
suspend fun ignoreUser(userId: UserId): Result<Unit>

View File

@@ -62,16 +62,24 @@ import io.element.android.libraries.matrix.impl.usersearch.UserProfileMapper
import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper
import io.element.android.libraries.matrix.impl.util.SessionDirectoryNameProvider
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
@@ -79,6 +87,7 @@ import org.matrix.rustcomponents.sdk.BackupState
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientDelegate
import org.matrix.rustcomponents.sdk.FilterTimelineEventType
import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.PowerLevels
import org.matrix.rustcomponents.sdk.Room
@@ -240,6 +249,16 @@ class RustMatrixClient(
private val clientDelegateTaskHandle: TaskHandle? = client.setDelegate(clientDelegate)
override val ignoredUsersFlow = mxCallbackFlow<ImmutableList<UserId>> {
client.subscribeToIgnoredUsers(object : IgnoredUsersListener {
override fun call(ignoredUserIds: List<String>) {
channel.trySend(ignoredUserIds.map(::UserId).toPersistentList())
}
})
}
.buffer(Channel.UNLIMITED)
.stateIn(sessionCoroutineScope, started = SharingStarted.Eagerly, initialValue = persistentListOf())
init {
roomListService.state.onEach { state ->
if (state == RoomListService.State.Running) {

View File

@@ -43,8 +43,11 @@ 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.tests.testutils.simulateLongTask
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
class FakeMatrixClient(
@@ -70,6 +73,8 @@ class FakeMatrixClient(
var removeAvatarCalled: Boolean = false
private set
override val ignoredUsersFlow: MutableStateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf())
private var ignoreUserResult: Result<Unit> = Result.success(Unit)
private var unignoreUserResult: Result<Unit> = Result.success(Unit)
private var createRoomResult: Result<RoomId> = Result.success(A_ROOM_ID)

View File

@@ -236,9 +236,6 @@
<string name="invite_friends_text">"Гэй, пагавары са мной у %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Rageshake паведаміць пра памылку"</string>
<string name="screen_blocked_users_unblock_alert_action">"Разблакіраваць"</string>
<string name="screen_blocked_users_unblock_alert_description">"Вы зноў зможаце ўбачыць усе паведамленні."</string>
<string name="screen_blocked_users_unblock_alert_title">"Разблакіраваць карыстальніка"</string>
<string name="screen_media_picker_error_failed_selection">"Не ўдалося выбраць носьбіт, паўтарыце спробу."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Не атрымалася апрацаваць медыяфайл для загрузкі, паспрабуйце яшчэ раз."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Не атрымалася загрузіць медыяфайлы, паспрабуйце яшчэ раз."</string>

View File

@@ -194,8 +194,6 @@
<string name="invite_friends_rich_title">"🔐️ Присъединете се към мен в %1$s"</string>
<string name="invite_friends_text">"Хей, говорете с мен в %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="screen_blocked_users_unblock_alert_action">"Отблокиране"</string>
<string name="screen_blocked_users_unblock_alert_title">"Отблокиране на потребителя"</string>
<string name="screen_share_location_title">"Споделяне на местоположение"</string>
<string name="screen_share_my_location_action">"Споделяне на моето местоположение"</string>
<string name="screen_share_open_apple_maps">"Отваряне в Apple Maps"</string>

View File

@@ -237,9 +237,6 @@
<string name="invite_friends_text">"Ahoj, ozvi se mi na %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Zatřeste zařízením pro nahlášení chyby"</string>
<string name="screen_blocked_users_unblock_alert_action">"Odblokovat"</string>
<string name="screen_blocked_users_unblock_alert_description">"Znovu uvidíte všechny zprávy od nich."</string>
<string name="screen_blocked_users_unblock_alert_title">"Odblokovat uživatele"</string>
<string name="screen_media_picker_error_failed_selection">"Výběr média se nezdařil, zkuste to prosím znovu."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>

View File

@@ -237,10 +237,6 @@
<string name="invite_friends_text">"Hey, sprich mit mir auf %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Schüttel heftig zum Melden von Fehlern"</string>
<string name="screen_blocked_users_unblock_alert_action">"Blockierung aufheben"</string>
<string name="screen_blocked_users_unblock_alert_description">"Du kannst dann wieder alle Nachrichten von ihnen sehen."</string>
<string name="screen_blocked_users_unblock_alert_title">"Blockierung aufheben"</string>
<string name="screen_blocked_users_unblocking">"Blockierung wird aufgehoben…"</string>
<string name="screen_media_picker_error_failed_selection">"Medienauswahl fehlgeschlagen, bitte versuche es erneut."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Das Hochladen der Medien ist fehlgeschlagen. Bitte versuche es erneut."</string>

View File

@@ -231,9 +231,6 @@
<string name="invite_friends_text">"Hola, puedes hablar conmigo en %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Agitar con fuerza para informar de un error"</string>
<string name="screen_blocked_users_unblock_alert_action">"Desbloquear"</string>
<string name="screen_blocked_users_unblock_alert_description">"Podrás ver todos sus mensajes de nuevo."</string>
<string name="screen_blocked_users_unblock_alert_title">"Desbloquear usuario"</string>
<string name="screen_media_picker_error_failed_selection">"Error al seleccionar archivos multimedia, por favor inténtalo de nuevo."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Error al procesar el contenido multimedia, por favor inténtalo de nuevo."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Error al subir el contenido multimedia, por favor inténtalo de nuevo."</string>

View File

@@ -234,9 +234,6 @@
<string name="invite_friends_text">"Salut, parle-moi sur %1$s : %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Rageshake pour signaler un problème"</string>
<string name="screen_blocked_users_unblock_alert_action">"Débloquer"</string>
<string name="screen_blocked_users_unblock_alert_description">"Vous pourrez à nouveau voir tous ses messages."</string>
<string name="screen_blocked_users_unblock_alert_title">"Débloquer lutilisateur"</string>
<string name="screen_media_picker_error_failed_selection">"Échec de la sélection du média, veuillez réessayer."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Échec du traitement des médias à télécharger, veuillez réessayer."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Échec du téléchargement du média, veuillez réessayer."</string>

View File

@@ -233,9 +233,6 @@
<string name="invite_friends_text">"Beszélgessünk a(z) %1$s: %2$s -n"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Az eszköz rázása a hibajelentéshez"</string>
<string name="screen_blocked_users_unblock_alert_action">"Letiltás feloldása"</string>
<string name="screen_blocked_users_unblock_alert_description">"Újra láthatja az összes üzenetét."</string>
<string name="screen_blocked_users_unblock_alert_title">"Felhasználó kitiltásának feloldása"</string>
<string name="screen_media_picker_error_failed_selection">"Nem sikerült kiválasztani a médiát, próbálja újra."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Nem sikerült feldolgozni a feltöltendő médiát, próbálja újra."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Nem sikerült a média feltöltése, próbálja újra."</string>

View File

@@ -227,9 +227,6 @@
<string name="invite_friends_text">"Hai, bicaralah dengan saya di %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Rageshake untuk melaporkan kutu"</string>
<string name="screen_blocked_users_unblock_alert_action">"Buka blokir"</string>
<string name="screen_blocked_users_unblock_alert_description">"Anda akan dapat melihat semua pesan dari mereka lagi."</string>
<string name="screen_blocked_users_unblock_alert_title">"Buka blokir pengguna"</string>
<string name="screen_media_picker_error_failed_selection">"Gagal memilih media, silakan coba lagi."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Gagal memproses media untuk diunggah, silakan coba lagi."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Gagal mengunggah media, silakan coba lagi."</string>

View File

@@ -233,9 +233,6 @@
<string name="invite_friends_text">"Ehi, parlami su %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Scuoti per segnalare un problema"</string>
<string name="screen_blocked_users_unblock_alert_action">"Sblocca"</string>
<string name="screen_blocked_users_unblock_alert_description">"Potrai vedere di nuovo tutti i suoi messaggi."</string>
<string name="screen_blocked_users_unblock_alert_title">"Sblocca utente"</string>
<string name="screen_media_picker_error_failed_selection">"Selezione del file multimediale fallita, riprova."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Elaborazione del file multimediale da caricare fallita, riprova."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Caricamento del file multimediale fallito, riprova."</string>

View File

@@ -238,9 +238,6 @@
<string name="invite_friends_text">"Hei, vorbește cu mine pe %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Rageshake pentru a raporta erori"</string>
<string name="screen_blocked_users_unblock_alert_action">"Deblocați"</string>
<string name="screen_blocked_users_unblock_alert_description">"La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta."</string>
<string name="screen_blocked_users_unblock_alert_title">"Deblocați utilizatorul"</string>
<string name="screen_media_picker_error_failed_selection">"Selectarea fișierelor media a eșuat, încercați din nou."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Procesarea datelor media a eșuat, vă rugăm să încercați din nou."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Încărcarea fișierelor media a eșuat, încercați din nou."</string>

View File

@@ -113,6 +113,7 @@
<string name="common_analytics">"Аналитика"</string>
<string name="common_appearance">"Внешний вид"</string>
<string name="common_audio">"Аудио"</string>
<string name="common_blocked_users">"Заблокированные пользователи"</string>
<string name="common_bubbles">"Пузыри"</string>
<string name="common_chat_backup">"Резервная копия чатов"</string>
<string name="common_copyright">"Авторское право"</string>
@@ -129,7 +130,9 @@
<string name="common_enter_your_pin">"Введите свой PIN-код"</string>
<string name="common_error">"Ошибка"</string>
<string name="common_everyone">"Для всех"</string>
<string name="common_failed">"Ошибка"</string>
<string name="common_favourite">"Избранное"</string>
<string name="common_favourited">"Избранное"</string>
<string name="common_file">"Файл"</string>
<string name="common_file_saved_on_disk_android">"Файл сохранен в «Загрузки»"</string>
<string name="common_forward_message">"Переслать сообщение"</string>
@@ -155,6 +158,7 @@
<string name="common_mute">"Без звука"</string>
<string name="common_no_results">"Ничего не найдено"</string>
<string name="common_offline">"Не в сети"</string>
<string name="common_or">"или"</string>
<string name="common_password">"Пароль"</string>
<string name="common_people">"Люди"</string>
<string name="common_permalink">"Постоянная ссылка"</string>
@@ -237,9 +241,6 @@
<string name="invite_friends_text">"Привет, поговори со мной по %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Встряхните устройство, чтобы сообщить об ошибке"</string>
<string name="screen_blocked_users_unblock_alert_action">"Разблокировать"</string>
<string name="screen_blocked_users_unblock_alert_description">"Вы снова сможете увидеть все сообщения."</string>
<string name="screen_blocked_users_unblock_alert_title">"Разблокировать пользователя"</string>
<string name="screen_media_picker_error_failed_selection">"Не удалось выбрать носитель, попробуйте еще раз."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Не удалось загрузить медиафайлы, попробуйте еще раз."</string>

View File

@@ -237,9 +237,6 @@
<string name="invite_friends_text">"Ahoj, porozprávajte sa so mnou na %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Zúrivo potriasť pre nahlásenie chyby"</string>
<string name="screen_blocked_users_unblock_alert_action">"Odblokovať"</string>
<string name="screen_blocked_users_unblock_alert_description">"Všetky správy od nich budete môcť opäť vidieť."</string>
<string name="screen_blocked_users_unblock_alert_title">"Odblokovať používateľa"</string>
<string name="screen_media_picker_error_failed_selection">"Nepodarilo sa vybrať médium, skúste to prosím znova."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Nepodarilo sa nahrať médiá, skúste to prosím znova."</string>

View File

@@ -173,9 +173,6 @@
<string name="invite_friends_text">"Hallå, prata med mig på %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Raseriskaka för att rapportera bugg"</string>
<string name="screen_blocked_users_unblock_alert_action">"Avblockera"</string>
<string name="screen_blocked_users_unblock_alert_description">"Du kommer att kunna se alla meddelanden från dem igen."</string>
<string name="screen_blocked_users_unblock_alert_title">"Avblockera användare"</string>
<string name="screen_media_picker_error_failed_selection">"Misslyckades att välja media, vänligen pröva igen."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Misslyckades att bearbeta media för uppladdning, vänligen pröva igen."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Misslyckades att ladda upp media, vänligen pröva igen."</string>

View File

@@ -238,9 +238,6 @@
<string name="invite_friends_text">"Привіт, пишіть мені за адресою %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Повідомити про ваду за допомогою Rageshake"</string>
<string name="screen_blocked_users_unblock_alert_action">"Розблокувати"</string>
<string name="screen_blocked_users_unblock_alert_description">"Ви знову зможете бачити всі повідомлення від них."</string>
<string name="screen_blocked_users_unblock_alert_title">"Розблокувати користувача"</string>
<string name="screen_media_picker_error_failed_selection">"Не вдалося вибрати медіафайл, спробуйте ще раз."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Не вдалося обробити медіафайл для завантаження, спробуйте ще раз."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Не вдалося завантажити медіафайл, спробуйте ще раз."</string>

View File

@@ -213,8 +213,6 @@
<string name="error_some_messages_have_not_been_sent">"有些訊息尚未傳送"</string>
<string name="invite_friends_text">"嘿,來 %1$s 和我聊天:%2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="screen_blocked_users_unblock_alert_action">"解除封鎖"</string>
<string name="screen_blocked_users_unblock_alert_title">"解除封鎖使用者"</string>
<string name="screen_media_upload_preview_error_failed_sending">"無法上傳媒體檔案,請稍後再試。"</string>
<string name="screen_share_location_title">"分享位置"</string>
<string name="screen_share_my_location_action">"分享我的位置"</string>

View File

@@ -237,10 +237,6 @@
<string name="invite_friends_text">"Hey, talk to me on %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Rageshake to report bug"</string>
<string name="screen_blocked_users_unblock_alert_action">"Unblock"</string>
<string name="screen_blocked_users_unblock_alert_description">"You\'ll be able to see all messages from them again."</string>
<string name="screen_blocked_users_unblock_alert_title">"Unblock user"</string>
<string name="screen_blocked_users_unblocking">"Unblocking…"</string>
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string>

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