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:
committed by
GitHub
parent
1dc6b0273b
commit
f0700f9904
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 l’utilisateur"</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 s’est produite et les informations n’ont pas pu être modifiées."</string>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 won’t 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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 l’utilisateur"</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user