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:
parent
1fd78f2e69
commit
cdf89adcd2
108 changed files with 1334 additions and 106 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0f12f65bd54debdd46849ee2de58727cc5525df402e6467e641218184aa017c7
|
||||
size 9628
|
||||
oid sha256:3a5d28daefa0d088c24136406fad2c0457585784cfbf7aefc8e720d203ed5d1f
|
||||
size 9744
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6a5a759ada36389158202ec331ca097da81cf487dcfab639dc15018a5f5fc03c
|
||||
size 8229
|
||||
oid sha256:4a9bbf2aa592c0b6d4b7b09a09b5ee94f1b8a26427852c43e49278b6e7fb4266
|
||||
size 8399
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d0cb8cd165599d1bae48bc9b6334e519cdba89287756b65caae86cd268667f40
|
||||
size 62939
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8c9e8374a634bf28b84e9945596a0c144971f550f8c1cd57abc0f4db30556fe5
|
||||
size 8959
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:709a470ac8764fee25eca83978daf039c089f2e8073b22afc1aa08fcf0d69998
|
||||
size 60072
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a515efeb80f1380d661f8531f0c85bb46abd71bf535e050444fbb9f3e72f7011
|
||||
size 65964
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9a47cd564da066d38db3e26f7b80b2a6e9a63ab276a2af688bee10bae462e775
|
||||
size 65580
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d0cb8cd165599d1bae48bc9b6334e519cdba89287756b65caae86cd268667f40
|
||||
size 62939
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:faa8c0cfc37478b8396d40c793ee3388fadfea8463c5f7cc4db1f5cc2cf10144
|
||||
size 58891
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cc12e1a6ebd2340718bfb70a5ceb265c526cef8c1fac285e1e6ec924a7c12324
|
||||
size 8393
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fc5c62caa2da7e51d035ffc7a00d9d3abaeee06cbd48c3d0a6e7e3226c04e4e4
|
||||
size 55857
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6974b5d06b3d8b64bf26c3b0b48cd57a2c2e38cee6aa8b06bb85d64392e1b685
|
||||
size 60855
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:96053ba2bb1dbbb69cd0ddb7e218ad68401ee1344844508766c5724a08e171fb
|
||||
size 60491
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:faa8c0cfc37478b8396d40c793ee3388fadfea8463c5f7cc4db1f5cc2cf10144
|
||||
size 58891
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a508cc1bef69d9b5b2ccd96b8090892263781b7188c17158267c7a6347fb9ec3
|
||||
size 36900
|
||||
oid sha256:be4d708775d03680be450dfd5eebfeda24f1841eedced3e7de2ba036669ac479
|
||||
size 39086
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:faf22bc041d24f7c9cc36324237f4c2da81f655b42c3381cfe72ea1ae1037f0e
|
||||
size 36539
|
||||
oid sha256:53660f4354d2a750ab539370151c9ee4ac49f9d06712f7646fdd487bf133753a
|
||||
size 38766
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:54e5b726e9aba9fddbba19e658314ad8375ec717d6e3ae086d0c3afd7f9af00a
|
||||
size 38814
|
||||
oid sha256:8ae1828957e00adcbdbb772a0ebdc1fb6604afbe644cc5d8662da9fcd3973129
|
||||
size 41162
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:078df60d22b49832f6c5cafe2c9ab656541f44c853e28f02ee350f64c5bae761
|
||||
size 38767
|
||||
oid sha256:58e567815b4dc36abf8422b17f9902d8581dbec1a1ed216df2f120c6198b6fe5
|
||||
size 41124
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue