Show blocked users list (#2437)

* Show blocked users list.

Also allow to unblock them from this list.

* Add non-blocking `AsyncIndicatorHost` component

* Use `StateFlow` for getting ignored users.

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa 2024-02-26 16:24:22 +01:00 committed by GitHub
parent 1fd78f2e69
commit cdf89adcd2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
108 changed files with 1334 additions and 106 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -43,8 +43,11 @@ import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
class FakeMatrixClient(
@ -70,6 +73,8 @@ class FakeMatrixClient(
var removeAvatarCalled: Boolean = false
private set
override val ignoredUsersFlow: MutableStateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf())
private var ignoreUserResult: Result<Unit> = Result.success(Unit)
private var unignoreUserResult: Result<Unit> = Result.success(Unit)
private var createRoomResult: Result<RoomId> = Result.success(A_ROOM_ID)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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