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,
)
}
}