Merge branch 'develop' into feature/bma/sendImageFromKeyboard
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright (c) 2023 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.architecture
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.Stable
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
|
||||
/**
|
||||
* Sealed type that allows to model an asynchronous operation triggered by the user.
|
||||
*/
|
||||
@Stable
|
||||
sealed interface AsyncAction<out T> {
|
||||
|
||||
/**
|
||||
* Represents an uninitialized operation (i.e. yet to be run by the user).
|
||||
*/
|
||||
data object Uninitialized : AsyncAction<Nothing>
|
||||
|
||||
/**
|
||||
* Represents an operation that is currently waiting for user confirmation.
|
||||
*/
|
||||
data object Confirming : AsyncAction<Nothing>
|
||||
|
||||
/**
|
||||
* Represents an operation that is currently ongoing.
|
||||
*/
|
||||
data object Loading : AsyncAction<Nothing>
|
||||
|
||||
/**
|
||||
* Represents a failed operation.
|
||||
*
|
||||
* @property error the error that caused the operation to fail.
|
||||
*/
|
||||
data class Failure(
|
||||
val error: Throwable,
|
||||
) : AsyncAction<Nothing>
|
||||
|
||||
/**
|
||||
* Represents a successful operation.
|
||||
*
|
||||
* @param T the type of data returned by the operation.
|
||||
* @property data the data returned by the operation.
|
||||
*/
|
||||
data class Success<out T>(
|
||||
val data: T,
|
||||
) : AsyncAction<T>
|
||||
|
||||
/**
|
||||
* Returns the data returned by the operation, or null otherwise.
|
||||
*/
|
||||
fun dataOrNull(): T? = when (this) {
|
||||
is Success -> data
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the error that caused the operation to fail, or null otherwise.
|
||||
*/
|
||||
fun errorOrNull(): Throwable? = when (this) {
|
||||
is Failure -> error
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun isUninitialized(): Boolean = this == Uninitialized
|
||||
|
||||
fun isConfirming(): Boolean = this is Confirming
|
||||
|
||||
fun isLoading(): Boolean = this is Loading
|
||||
|
||||
fun isFailure(): Boolean = this is Failure
|
||||
|
||||
fun isSuccess(): Boolean = this is Success
|
||||
}
|
||||
|
||||
suspend inline fun <T> MutableState<AsyncAction<T>>.runCatchingUpdatingState(
|
||||
errorTransform: (Throwable) -> Throwable = { it },
|
||||
block: () -> T,
|
||||
): Result<T> = runUpdatingState(
|
||||
state = this,
|
||||
errorTransform = errorTransform,
|
||||
resultBlock = {
|
||||
runCatching {
|
||||
block()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
suspend inline fun <T> (suspend () -> T).runCatchingUpdatingState(
|
||||
state: MutableState<AsyncAction<T>>,
|
||||
errorTransform: (Throwable) -> Throwable = { it },
|
||||
): Result<T> = runUpdatingState(
|
||||
state = state,
|
||||
errorTransform = errorTransform,
|
||||
resultBlock = {
|
||||
runCatching {
|
||||
this()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
suspend inline fun <T> MutableState<AsyncAction<T>>.runUpdatingState(
|
||||
errorTransform: (Throwable) -> Throwable = { it },
|
||||
resultBlock: () -> Result<T>,
|
||||
): Result<T> = runUpdatingState(
|
||||
state = this,
|
||||
errorTransform = errorTransform,
|
||||
resultBlock = resultBlock,
|
||||
)
|
||||
|
||||
/**
|
||||
* Calls the specified [Result]-returning function [resultBlock]
|
||||
* encapsulating its progress and return value into an [AsyncAction] while
|
||||
* posting its updates to the MutableState [state].
|
||||
*
|
||||
* @param T the type of data returned by the operation.
|
||||
* @param state the [MutableState] to post updates to.
|
||||
* @param errorTransform a function to transform the error before posting it.
|
||||
* @param resultBlock a suspending function that returns a [Result].
|
||||
* @return the [Result] returned by [resultBlock].
|
||||
*/
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE")
|
||||
suspend inline fun <T> runUpdatingState(
|
||||
state: MutableState<AsyncAction<T>>,
|
||||
errorTransform: (Throwable) -> Throwable = { it },
|
||||
resultBlock: suspend () -> Result<T>,
|
||||
): Result<T> {
|
||||
contract {
|
||||
callsInPlace(resultBlock, InvocationKind.EXACTLY_ONCE)
|
||||
}
|
||||
state.value = AsyncAction.Loading
|
||||
return resultBlock().fold(
|
||||
onSuccess = {
|
||||
state.value = AsyncAction.Success(it)
|
||||
Result.success(it)
|
||||
},
|
||||
onFailure = {
|
||||
val error = errorTransform(it)
|
||||
state.value = AsyncAction.Failure(
|
||||
error = error,
|
||||
)
|
||||
Result.failure(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import kotlin.contracts.contract
|
||||
* Sealed type that allows to model an asynchronous operation.
|
||||
*/
|
||||
@Stable
|
||||
sealed interface Async<out T> {
|
||||
sealed interface AsyncData<out T> {
|
||||
|
||||
/**
|
||||
* Represents a failed operation.
|
||||
@@ -38,7 +38,7 @@ sealed interface Async<out T> {
|
||||
data class Failure<out T>(
|
||||
val error: Throwable,
|
||||
val prevData: T? = null,
|
||||
) : Async<T>
|
||||
) : AsyncData<T>
|
||||
|
||||
/**
|
||||
* Represents an operation that is currently ongoing.
|
||||
@@ -48,7 +48,7 @@ sealed interface Async<out T> {
|
||||
*/
|
||||
data class Loading<out T>(
|
||||
val prevData: T? = null,
|
||||
) : Async<T>
|
||||
) : AsyncData<T>
|
||||
|
||||
/**
|
||||
* Represents a successful operation.
|
||||
@@ -58,12 +58,12 @@ sealed interface Async<out T> {
|
||||
*/
|
||||
data class Success<out T>(
|
||||
val data: T,
|
||||
) : Async<T>
|
||||
) : AsyncData<T>
|
||||
|
||||
/**
|
||||
* Represents an uninitialized operation (i.e. yet to be run).
|
||||
*/
|
||||
data object Uninitialized : Async<Nothing>
|
||||
data object Uninitialized : AsyncData<Nothing>
|
||||
|
||||
/**
|
||||
* Returns the data returned by the operation, or null otherwise.
|
||||
@@ -94,7 +94,7 @@ sealed interface Async<out T> {
|
||||
fun isUninitialized(): Boolean = this == Uninitialized
|
||||
}
|
||||
|
||||
suspend inline fun <T> MutableState<Async<T>>.runCatchingUpdatingState(
|
||||
suspend inline fun <T> MutableState<AsyncData<T>>.runCatchingUpdatingState(
|
||||
errorTransform: (Throwable) -> Throwable = { it },
|
||||
block: () -> T,
|
||||
): Result<T> = runUpdatingState(
|
||||
@@ -108,7 +108,7 @@ suspend inline fun <T> MutableState<Async<T>>.runCatchingUpdatingState(
|
||||
)
|
||||
|
||||
suspend inline fun <T> (suspend () -> T).runCatchingUpdatingState(
|
||||
state: MutableState<Async<T>>,
|
||||
state: MutableState<AsyncData<T>>,
|
||||
errorTransform: (Throwable) -> Throwable = { it },
|
||||
): Result<T> = runUpdatingState(
|
||||
state = state,
|
||||
@@ -120,7 +120,7 @@ suspend inline fun <T> (suspend () -> T).runCatchingUpdatingState(
|
||||
},
|
||||
)
|
||||
|
||||
suspend inline fun <T> MutableState<Async<T>>.runUpdatingState(
|
||||
suspend inline fun <T> MutableState<AsyncData<T>>.runUpdatingState(
|
||||
errorTransform: (Throwable) -> Throwable = { it },
|
||||
resultBlock: () -> Result<T>,
|
||||
): Result<T> = runUpdatingState(
|
||||
@@ -131,7 +131,7 @@ suspend inline fun <T> MutableState<Async<T>>.runUpdatingState(
|
||||
|
||||
/**
|
||||
* Calls the specified [Result]-returning function [resultBlock]
|
||||
* encapsulating its progress and return value into an [Async] while
|
||||
* encapsulating its progress and return value into an [AsyncData] while
|
||||
* posting its updates to the MutableState [state].
|
||||
*
|
||||
* @param T the type of data returned by the operation.
|
||||
@@ -143,7 +143,7 @@ suspend inline fun <T> MutableState<Async<T>>.runUpdatingState(
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE")
|
||||
suspend inline fun <T> runUpdatingState(
|
||||
state: MutableState<Async<T>>,
|
||||
state: MutableState<AsyncData<T>>,
|
||||
errorTransform: (Throwable) -> Throwable = { it },
|
||||
resultBlock: suspend () -> Result<T>,
|
||||
): Result<T> {
|
||||
@@ -151,15 +151,15 @@ suspend inline fun <T> runUpdatingState(
|
||||
callsInPlace(resultBlock, InvocationKind.EXACTLY_ONCE)
|
||||
}
|
||||
val prevData = state.value.dataOrNull()
|
||||
state.value = Async.Loading(prevData = prevData)
|
||||
state.value = AsyncData.Loading(prevData = prevData)
|
||||
return resultBlock().fold(
|
||||
onSuccess = {
|
||||
state.value = Async.Success(it)
|
||||
state.value = AsyncData.Success(it)
|
||||
Result.success(it)
|
||||
},
|
||||
onFailure = {
|
||||
val error = errorTransform(it)
|
||||
state.value = Async.Failure(
|
||||
state.value = AsyncData.Failure(
|
||||
error = error,
|
||||
prevData = prevData,
|
||||
)
|
||||
@@ -22,10 +22,10 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class AsyncKtTest {
|
||||
class AsyncDataKtTest {
|
||||
@Test
|
||||
fun `updates state when block returns success`() = runTest {
|
||||
val state = TestableMutableState<Async<Int>>(Async.Uninitialized)
|
||||
val state = TestableMutableState<AsyncData<Int>>(AsyncData.Uninitialized)
|
||||
|
||||
val result = runUpdatingState(state) {
|
||||
delay(1)
|
||||
@@ -35,15 +35,15 @@ class AsyncKtTest {
|
||||
assertThat(result.isSuccess).isTrue()
|
||||
assertThat(result.getOrNull()).isEqualTo(1)
|
||||
|
||||
assertThat(state.popFirst()).isEqualTo(Async.Uninitialized)
|
||||
assertThat(state.popFirst()).isEqualTo(Async.Loading(null))
|
||||
assertThat(state.popFirst()).isEqualTo(Async.Success(1))
|
||||
assertThat(state.popFirst()).isEqualTo(AsyncData.Uninitialized)
|
||||
assertThat(state.popFirst()).isEqualTo(AsyncData.Loading(null))
|
||||
assertThat(state.popFirst()).isEqualTo(AsyncData.Success(1))
|
||||
state.assertNoMoreValues()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updates state when block returns failure`() = runTest {
|
||||
val state = TestableMutableState<Async<Int>>(Async.Uninitialized)
|
||||
val state = TestableMutableState<AsyncData<Int>>(AsyncData.Uninitialized)
|
||||
|
||||
val result = runUpdatingState(state) {
|
||||
delay(1)
|
||||
@@ -53,15 +53,15 @@ class AsyncKtTest {
|
||||
assertThat(result.isFailure).isTrue()
|
||||
assertThat(result.exceptionOrNull()).isEqualTo(MyThrowable("hello"))
|
||||
|
||||
assertThat(state.popFirst()).isEqualTo(Async.Uninitialized)
|
||||
assertThat(state.popFirst()).isEqualTo(Async.Loading(null))
|
||||
assertThat(state.popFirst()).isEqualTo(Async.Failure<Int>(MyThrowable("hello")))
|
||||
assertThat(state.popFirst()).isEqualTo(AsyncData.Uninitialized)
|
||||
assertThat(state.popFirst()).isEqualTo(AsyncData.Loading(null))
|
||||
assertThat(state.popFirst()).isEqualTo(AsyncData.Failure<Int>(MyThrowable("hello")))
|
||||
state.assertNoMoreValues()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updates state when block returns failure transforming the error`() = runTest {
|
||||
val state = TestableMutableState<Async<Int>>(Async.Uninitialized)
|
||||
val state = TestableMutableState<AsyncData<Int>>(AsyncData.Uninitialized)
|
||||
|
||||
val result = runUpdatingState(state, { MyThrowable(it.message + " world") }) {
|
||||
delay(1)
|
||||
@@ -71,9 +71,9 @@ class AsyncKtTest {
|
||||
assertThat(result.isFailure).isTrue()
|
||||
assertThat(result.exceptionOrNull()).isEqualTo(MyThrowable("hello world"))
|
||||
|
||||
assertThat(state.popFirst()).isEqualTo(Async.Uninitialized)
|
||||
assertThat(state.popFirst()).isEqualTo(Async.Loading(null))
|
||||
assertThat(state.popFirst()).isEqualTo(Async.Failure<Int>(MyThrowable("hello world")))
|
||||
assertThat(state.popFirst()).isEqualTo(AsyncData.Uninitialized)
|
||||
assertThat(state.popFirst()).isEqualTo(AsyncData.Loading(null))
|
||||
assertThat(state.popFirst()).isEqualTo(AsyncData.Failure<Int>(MyThrowable("hello world")))
|
||||
state.assertNoMoreValues()
|
||||
}
|
||||
}
|
||||
@@ -17,14 +17,15 @@
|
||||
package io.element.android.libraries.designsystem.components.async
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
|
||||
open class AsyncProvider : PreviewParameterProvider<Async<Unit>> {
|
||||
override val values: Sequence<Async<Unit>>
|
||||
open class AsyncActionProvider : PreviewParameterProvider<AsyncAction<Unit>> {
|
||||
override val values: Sequence<AsyncAction<Unit>>
|
||||
get() = sequenceOf(
|
||||
Async.Uninitialized,
|
||||
Async.Loading(),
|
||||
Async.Failure(Exception("An error occurred")),
|
||||
Async.Success(Unit),
|
||||
AsyncAction.Uninitialized,
|
||||
AsyncAction.Confirming,
|
||||
AsyncAction.Loading,
|
||||
AsyncAction.Failure(Exception("An error occurred")),
|
||||
AsyncAction.Success(Unit),
|
||||
)
|
||||
}
|
||||
@@ -19,8 +19,9 @@ package io.element.android.libraries.designsystem.components.async
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogDefaults
|
||||
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
||||
@@ -28,52 +29,28 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
|
||||
/**
|
||||
* Render an Async object.
|
||||
* Render an AsyncAction object.
|
||||
* - If Success, invoke the callback [onSuccess], only once.
|
||||
* - If Failure, display a dialog with the error, which can be transformed, using [errorMessage]. When
|
||||
* closed, [onErrorDismiss] will be invoked. If [onRetry] is not null, a retry button will be displayed.
|
||||
* - When loading, display a loading dialog, if [showProgressDialog] is true, with on optional [progressText].
|
||||
* - When loading, display a loading dialog using [progressDialog]. Pass empty lambda to disable.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> AsyncView(
|
||||
async: Async<T>,
|
||||
fun <T> AsyncActionView(
|
||||
async: AsyncAction<T>,
|
||||
onSuccess: (T) -> Unit,
|
||||
onErrorDismiss: () -> Unit,
|
||||
showProgressDialog: Boolean = true,
|
||||
progressText: String? = null,
|
||||
errorTitle: @Composable (Throwable) -> String = { ErrorDialogDefaults.title },
|
||||
errorMessage: @Composable (Throwable) -> String = { it.message ?: it.toString() },
|
||||
onRetry: (() -> Unit)? = null,
|
||||
) {
|
||||
AsyncView(
|
||||
async = async,
|
||||
onSuccess = onSuccess,
|
||||
onErrorDismiss = onErrorDismiss,
|
||||
progressDialog = {
|
||||
if (showProgressDialog) {
|
||||
AsyncViewDefaults.ProgressDialog(progressText)
|
||||
}
|
||||
},
|
||||
errorTitle = errorTitle,
|
||||
errorMessage = errorMessage,
|
||||
onRetry = onRetry,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> AsyncView(
|
||||
async: Async<T>,
|
||||
onSuccess: (T) -> Unit,
|
||||
onErrorDismiss: () -> Unit,
|
||||
progressDialog: @Composable () -> Unit = { AsyncViewDefaults.ProgressDialog() },
|
||||
confirmationDialog: @Composable () -> Unit = { },
|
||||
progressDialog: @Composable () -> Unit = { AsyncActionViewDefaults.ProgressDialog() },
|
||||
errorTitle: @Composable (Throwable) -> String = { ErrorDialogDefaults.title },
|
||||
errorMessage: @Composable (Throwable) -> String = { it.message ?: it.toString() },
|
||||
onRetry: (() -> Unit)? = null,
|
||||
) {
|
||||
when (async) {
|
||||
Async.Uninitialized -> Unit
|
||||
is Async.Loading -> progressDialog()
|
||||
is Async.Failure -> {
|
||||
AsyncAction.Uninitialized -> Unit
|
||||
AsyncAction.Confirming -> confirmationDialog()
|
||||
is AsyncAction.Loading -> progressDialog()
|
||||
is AsyncAction.Failure -> {
|
||||
if (onRetry == null) {
|
||||
ErrorDialog(
|
||||
title = errorTitle(async.error),
|
||||
@@ -89,7 +66,7 @@ fun <T> AsyncView(
|
||||
)
|
||||
}
|
||||
}
|
||||
is Async.Success -> {
|
||||
is AsyncAction.Success -> {
|
||||
LaunchedEffect(async) {
|
||||
onSuccess(async.data)
|
||||
}
|
||||
@@ -97,7 +74,7 @@ fun <T> AsyncView(
|
||||
}
|
||||
}
|
||||
|
||||
object AsyncViewDefaults {
|
||||
object AsyncActionViewDefaults {
|
||||
@Composable
|
||||
fun ProgressDialog(progressText: String? = null) {
|
||||
ProgressDialog(
|
||||
@@ -108,12 +85,20 @@ object AsyncViewDefaults {
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun AsyncViewPreview(
|
||||
@PreviewParameter(AsyncProvider::class) async: Async<Unit>,
|
||||
internal fun AsyncActionViewPreview(
|
||||
@PreviewParameter(AsyncActionProvider::class) async: AsyncAction<Unit>,
|
||||
) = ElementPreview {
|
||||
AsyncView(
|
||||
AsyncActionView(
|
||||
async = async,
|
||||
onSuccess = {},
|
||||
onErrorDismiss = {},
|
||||
confirmationDialog = {
|
||||
ConfirmationDialog(
|
||||
title = "Confirmation",
|
||||
content = "Are you sure?",
|
||||
onSubmitClicked = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -59,7 +59,7 @@ fun <T> SearchBar(
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
showBackButton: Boolean = true,
|
||||
resultState: SearchBarResultState<T> = SearchBarResultState.NotSearching(),
|
||||
resultState: SearchBarResultState<T> = SearchBarResultState.Initial(),
|
||||
shape: Shape = SearchBarDefaults.inputFieldShape,
|
||||
tonalElevation: Dp = SearchBarDefaults.TonalElevation,
|
||||
windowInsets: WindowInsets = SearchBarDefaults.windowInsets,
|
||||
@@ -129,7 +129,7 @@ fun <T> SearchBar(
|
||||
resultHandler(resultState.results)
|
||||
}
|
||||
|
||||
is SearchBarResultState.NoResults<T> -> {
|
||||
is SearchBarResultState.NoResultsFound<T> -> {
|
||||
// No results found, show a message
|
||||
Spacer(Modifier.size(80.dp))
|
||||
|
||||
@@ -184,10 +184,10 @@ object ElementSearchBarDefaults {
|
||||
@Immutable
|
||||
sealed interface SearchBarResultState<in T> {
|
||||
/** No search results are available yet (e.g. because the user hasn't entered a search term). */
|
||||
class NotSearching<T> : SearchBarResultState<T>
|
||||
class Initial<T> : SearchBarResultState<T>
|
||||
|
||||
/** The search has completed, but no results were found. */
|
||||
class NoResults<T> : SearchBarResultState<T>
|
||||
class NoResultsFound<T> : SearchBarResultState<T>
|
||||
|
||||
/** The search has completed, and some matching users were found. */
|
||||
data class Results<T>(val results: T) : SearchBarResultState<T>
|
||||
@@ -199,7 +199,7 @@ internal fun SearchBarInactivePreview() = ElementThemedPreview { ContentToPrevie
|
||||
|
||||
@Preview(group = PreviewGroup.Search)
|
||||
@Composable
|
||||
internal fun SearchBarActiveEmptyQueryPreview() = ElementThemedPreview {
|
||||
internal fun SearchBarActiveNoneQueryPreview() = ElementThemedPreview {
|
||||
ContentToPreview(
|
||||
query = "",
|
||||
active = true,
|
||||
@@ -231,7 +231,7 @@ internal fun SearchBarActiveWithNoResultsPreview() = ElementThemedPreview {
|
||||
ContentToPreview(
|
||||
query = "search term",
|
||||
active = true,
|
||||
resultState = SearchBarResultState.NoResults(),
|
||||
resultState = SearchBarResultState.NoResultsFound<String>(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -257,16 +257,15 @@ internal fun SearchBarActiveWithContentPreview() = ElementThemedPreview {
|
||||
.background(color = Color.Blue)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
},
|
||||
resultHandler = {
|
||||
Text(
|
||||
text = "Results go here",
|
||||
modifier = Modifier
|
||||
.background(color = Color.Green)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "Results go here",
|
||||
modifier = Modifier
|
||||
.background(color = Color.Green)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -275,7 +274,7 @@ private fun ContentToPreview(
|
||||
query: String = "",
|
||||
active: Boolean = false,
|
||||
showBackButton: Boolean = true,
|
||||
resultState: SearchBarResultState<String> = SearchBarResultState.NotSearching(),
|
||||
resultState: SearchBarResultState<String> = SearchBarResultState.Initial(),
|
||||
contentPrefix: @Composable ColumnScope.() -> Unit = {},
|
||||
contentSuffix: @Composable ColumnScope.() -> Unit = {},
|
||||
resultHandler: @Composable ColumnScope.(String) -> Unit = {},
|
||||
|
||||
@@ -44,6 +44,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.RedactedConte
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
|
||||
@@ -62,47 +63,52 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
|
||||
private val stateContentFormatter: StateContentFormatter,
|
||||
) : RoomLastMessageFormatter {
|
||||
|
||||
companion object {
|
||||
// Max characters to display in the last message. This works around https://github.com/element-hq/element-x-android/issues/2105
|
||||
private const val MAX_SAFE_LENGTH = 500
|
||||
}
|
||||
|
||||
override fun format(event: EventTimelineItem, isDmRoom: Boolean): CharSequence? {
|
||||
val isOutgoing = event.isOwn
|
||||
val senderDisplayName = (event.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: event.sender.value
|
||||
return when (val content = event.content) {
|
||||
is MessageContent -> processMessageContents(content, senderDisplayName, isDmRoom)
|
||||
RedactedContent -> {
|
||||
val message = sp.getString(CommonStrings.common_message_removed)
|
||||
if (!isDmRoom) {
|
||||
prefix(message, senderDisplayName)
|
||||
} else {
|
||||
message
|
||||
is MessageContent -> processMessageContents(content, senderDisplayName, isDmRoom)
|
||||
RedactedContent -> {
|
||||
val message = sp.getString(CommonStrings.common_message_removed)
|
||||
if (!isDmRoom) {
|
||||
prefix(message, senderDisplayName)
|
||||
} else {
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
is StickerContent -> {
|
||||
content.body
|
||||
}
|
||||
is UnableToDecryptContent -> {
|
||||
val message = sp.getString(CommonStrings.common_waiting_for_decryption_key)
|
||||
if (!isDmRoom) {
|
||||
prefix(message, senderDisplayName)
|
||||
} else {
|
||||
message
|
||||
is StickerContent -> {
|
||||
content.body
|
||||
}
|
||||
}
|
||||
is RoomMembershipContent -> {
|
||||
roomMembershipContentFormatter.format(content, senderDisplayName, isOutgoing)
|
||||
}
|
||||
is ProfileChangeContent -> {
|
||||
profileChangeContentFormatter.format(content, senderDisplayName, isOutgoing)
|
||||
}
|
||||
is StateContent -> {
|
||||
stateContentFormatter.format(content, senderDisplayName, isOutgoing, RenderingMode.RoomList)
|
||||
}
|
||||
is PollContent -> {
|
||||
val message = sp.getString(CommonStrings.common_poll_summary, content.question)
|
||||
prefixIfNeeded(message, senderDisplayName, isDmRoom)
|
||||
}
|
||||
is FailedToParseMessageLikeContent, is FailedToParseStateContent, is UnknownContent -> {
|
||||
prefixIfNeeded(sp.getString(CommonStrings.common_unsupported_event), senderDisplayName, isDmRoom)
|
||||
}
|
||||
}
|
||||
is UnableToDecryptContent -> {
|
||||
val message = sp.getString(CommonStrings.common_waiting_for_decryption_key)
|
||||
if (!isDmRoom) {
|
||||
prefix(message, senderDisplayName)
|
||||
} else {
|
||||
message
|
||||
}
|
||||
}
|
||||
is RoomMembershipContent -> {
|
||||
roomMembershipContentFormatter.format(content, senderDisplayName, isOutgoing)
|
||||
}
|
||||
is ProfileChangeContent -> {
|
||||
profileChangeContentFormatter.format(content, senderDisplayName, isOutgoing)
|
||||
}
|
||||
is StateContent -> {
|
||||
stateContentFormatter.format(content, senderDisplayName, isOutgoing, RenderingMode.RoomList)
|
||||
}
|
||||
is PollContent -> {
|
||||
val message = sp.getString(CommonStrings.common_poll_summary, content.question)
|
||||
prefixIfNeeded(message, senderDisplayName, isDmRoom)
|
||||
}
|
||||
is FailedToParseMessageLikeContent, is FailedToParseStateContent, is UnknownContent -> {
|
||||
prefixIfNeeded(sp.getString(CommonStrings.common_unsupported_event), senderDisplayName, isDmRoom)
|
||||
}
|
||||
}?.take(MAX_SAFE_LENGTH)
|
||||
}
|
||||
|
||||
private fun processMessageContents(messageContent: MessageContent, senderDisplayName: String, isDmRoom: Boolean): CharSequence? {
|
||||
@@ -120,6 +126,9 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
|
||||
is ImageMessageType -> {
|
||||
sp.getString(CommonStrings.common_image)
|
||||
}
|
||||
is StickerMessageType -> {
|
||||
sp.getString(CommonStrings.common_sticker)
|
||||
}
|
||||
is LocationMessageType -> {
|
||||
sp.getString(CommonStrings.common_shared_location)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
<string name="state_event_room_name_changed_by_you">"Změnili jste název místnosti na: %1$s"</string>
|
||||
<string name="state_event_room_name_removed">"%1$s odstranil(a) název místnosti"</string>
|
||||
<string name="state_event_room_name_removed_by_you">"Odstranili jste název místnosti"</string>
|
||||
<string name="state_event_room_none">"%1$s neprovedl(a) žádné změny"</string>
|
||||
<string name="state_event_room_none_by_you">"Neprovedli jste žádné změny"</string>
|
||||
<string name="state_event_room_reject">"%1$s pozvánku odmítl(a)"</string>
|
||||
<string name="state_event_room_reject_by_you">"Odmítli jste pozvání"</string>
|
||||
<string name="state_event_room_remove">"%1$s odebral(a) %2$s"</string>
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
<string name="state_event_room_name_changed_by_you">"Vous avez changé le nom du salon en : %1$s"</string>
|
||||
<string name="state_event_room_name_removed">"%1$s a supprimé le nom du salon"</string>
|
||||
<string name="state_event_room_name_removed_by_you">"Vous avez supprimé le nom du salon"</string>
|
||||
<string name="state_event_room_none">"%1$s n‘a fait aucun changement visible"</string>
|
||||
<string name="state_event_room_none_by_you">"Vous n‘avez fait aucun changement visible"</string>
|
||||
<string name="state_event_room_reject">"%1$s a rejeté l’invitation"</string>
|
||||
<string name="state_event_room_reject_by_you">"Vous avez refusé l’invitation"</string>
|
||||
<string name="state_event_room_remove">"%1$s a supprimé %2$s"</string>
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
<string name="state_event_room_name_changed_by_you">"Вы изменили название комнаты на: %1$s"</string>
|
||||
<string name="state_event_room_name_removed">"%1$s удалил название комнаты"</string>
|
||||
<string name="state_event_room_name_removed_by_you">"Вы удалили название комнаты"</string>
|
||||
<string name="state_event_room_none">"%1$s ничего не изменилось"</string>
|
||||
<string name="state_event_room_none_by_you">"Вы не внесли никаких изменений"</string>
|
||||
<string name="state_event_room_reject">"%1$s отклонил приглашение"</string>
|
||||
<string name="state_event_room_reject_by_you">"Вы отклонили приглашение"</string>
|
||||
<string name="state_event_room_remove">"%1$s удалил %2$s"</string>
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
<string name="state_event_room_name_changed_by_you">"Zmenili ste názov miestnosti na: %1$s"</string>
|
||||
<string name="state_event_room_name_removed">"%1$s odstránil/a názov miestnosti"</string>
|
||||
<string name="state_event_room_name_removed_by_you">"Odstránili ste názov miestnosti"</string>
|
||||
<string name="state_event_room_none">"%1$s nevykonal/a žiadne zmeny"</string>
|
||||
<string name="state_event_room_none_by_you">"Nevykonali ste žiadne zmeny"</string>
|
||||
<string name="state_event_room_reject">"%1$s odmietol/a pozvánku"</string>
|
||||
<string name="state_event_room_reject_by_you">"Odmietli ste pozvánku"</string>
|
||||
<string name="state_event_room_remove">"%1$s odstránil/a %2$s"</string>
|
||||
|
||||
@@ -43,6 +43,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.RedactedConte
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
|
||||
@@ -165,6 +166,7 @@ class DefaultRoomLastMessageFormatterTest {
|
||||
AudioMessageType(body, MediaSource("url"), null),
|
||||
VoiceMessageType(body, MediaSource("url"), null, null),
|
||||
ImageMessageType(body, MediaSource("url"), null),
|
||||
StickerMessageType(body, MediaSource("url"), null),
|
||||
FileMessageType(body, MediaSource("url"), null),
|
||||
LocationMessageType(body, "geo:1,2", null),
|
||||
NoticeMessageType(body, null),
|
||||
@@ -196,6 +198,7 @@ class DefaultRoomLastMessageFormatterTest {
|
||||
is AudioMessageType -> "Audio"
|
||||
is VoiceMessageType -> "Voice message"
|
||||
is ImageMessageType -> "Image"
|
||||
is StickerMessageType -> "Sticker"
|
||||
is FileMessageType -> "File"
|
||||
is LocationMessageType -> "Shared location"
|
||||
is EmoteMessageType -> "* $senderName ${type.body}"
|
||||
@@ -214,6 +217,7 @@ class DefaultRoomLastMessageFormatterTest {
|
||||
is AudioMessageType -> "$senderName: Audio"
|
||||
is VoiceMessageType -> "$senderName: Voice message"
|
||||
is ImageMessageType -> "$senderName: Image"
|
||||
is StickerMessageType -> "$senderName: Sticker"
|
||||
is FileMessageType -> "$senderName: File"
|
||||
is LocationMessageType -> "$senderName: Shared location"
|
||||
is TextMessageType,
|
||||
@@ -226,6 +230,7 @@ class DefaultRoomLastMessageFormatterTest {
|
||||
is AudioMessageType -> true
|
||||
is VoiceMessageType -> true
|
||||
is ImageMessageType -> true
|
||||
is StickerMessageType -> true
|
||||
is FileMessageType -> true
|
||||
is LocationMessageType -> false
|
||||
is EmoteMessageType -> false
|
||||
|
||||
@@ -38,6 +38,12 @@ data class ImageMessageType(
|
||||
val info: ImageInfo?
|
||||
) : MessageType
|
||||
|
||||
data class StickerMessageType(
|
||||
val body: String,
|
||||
val source: MediaSource,
|
||||
val info: ImageInfo?
|
||||
) : MessageType
|
||||
|
||||
data class LocationMessageType(
|
||||
val body: String,
|
||||
val geoUri: String,
|
||||
|
||||
@@ -32,7 +32,11 @@ import org.jsoup.nodes.Document
|
||||
* @param prefix if not null, the prefix will be inserted at the beginning of the message.
|
||||
*/
|
||||
fun FormattedBody.toHtmlDocument(prefix: String? = null): Document? {
|
||||
return takeIf { it.format == MessageFormat.HTML }?.body?.let { formattedBody ->
|
||||
return takeIf { it.format == MessageFormat.HTML }?.body
|
||||
// Trim whitespace at the end to avoid having wrong rendering of the message.
|
||||
// We don't trim the start in case it's used as indentation.
|
||||
?.trimEnd()
|
||||
?.let { formattedBody ->
|
||||
val dom = if (prefix != null) {
|
||||
Jsoup.parse("$prefix $formattedBody")
|
||||
} else {
|
||||
|
||||
@@ -29,7 +29,7 @@ import androidx.compose.runtime.setValue
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
@@ -64,8 +64,8 @@ class MediaViewerPresenter @AssistedInject constructor(
|
||||
val mediaFile: MutableState<MediaFile?> = remember {
|
||||
mutableStateOf(null)
|
||||
}
|
||||
val localMedia: MutableState<Async<LocalMedia>> = remember {
|
||||
mutableStateOf(Async.Uninitialized)
|
||||
val localMedia: MutableState<AsyncData<LocalMedia>> = remember {
|
||||
mutableStateOf(AsyncData.Uninitialized)
|
||||
}
|
||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||
localMediaActions.Configure()
|
||||
@@ -79,7 +79,7 @@ class MediaViewerPresenter @AssistedInject constructor(
|
||||
fun handleEvents(mediaViewerEvents: MediaViewerEvents) {
|
||||
when (mediaViewerEvents) {
|
||||
MediaViewerEvents.RetryLoading -> loadMediaTrigger++
|
||||
MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized
|
||||
MediaViewerEvents.ClearLoadingError -> localMedia.value = AsyncData.Uninitialized
|
||||
MediaViewerEvents.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value)
|
||||
MediaViewerEvents.Share -> coroutineScope.share(localMedia.value)
|
||||
MediaViewerEvents.OpenWith -> coroutineScope.open(localMedia.value)
|
||||
@@ -97,8 +97,8 @@ class MediaViewerPresenter @AssistedInject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.downloadMedia(mediaFile: MutableState<MediaFile?>, localMedia: MutableState<Async<LocalMedia>>) = launch {
|
||||
localMedia.value = Async.Loading()
|
||||
private fun CoroutineScope.downloadMedia(mediaFile: MutableState<MediaFile?>, localMedia: MutableState<AsyncData<LocalMedia>>) = launch {
|
||||
localMedia.value = AsyncData.Loading()
|
||||
mediaLoader.downloadMediaFile(
|
||||
source = inputs.mediaSource,
|
||||
mimeType = inputs.mediaInfo.mimeType,
|
||||
@@ -114,15 +114,15 @@ class MediaViewerPresenter @AssistedInject constructor(
|
||||
)
|
||||
}
|
||||
.onSuccess {
|
||||
localMedia.value = Async.Success(it)
|
||||
localMedia.value = AsyncData.Success(it)
|
||||
}
|
||||
.onFailure {
|
||||
localMedia.value = Async.Failure(it)
|
||||
localMedia.value = AsyncData.Failure(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.saveOnDisk(localMedia: Async<LocalMedia>) = launch {
|
||||
if (localMedia is Async.Success) {
|
||||
private fun CoroutineScope.saveOnDisk(localMedia: AsyncData<LocalMedia>) = launch {
|
||||
if (localMedia is AsyncData.Success) {
|
||||
localMediaActions.saveOnDisk(localMedia.data)
|
||||
.onSuccess {
|
||||
val snackbarMessage = SnackbarMessage(CommonStrings.common_file_saved_on_disk_android)
|
||||
@@ -135,8 +135,8 @@ class MediaViewerPresenter @AssistedInject constructor(
|
||||
} else Unit
|
||||
}
|
||||
|
||||
private fun CoroutineScope.share(localMedia: Async<LocalMedia>) = launch {
|
||||
if (localMedia is Async.Success) {
|
||||
private fun CoroutineScope.share(localMedia: AsyncData<LocalMedia>) = launch {
|
||||
if (localMedia is AsyncData.Success) {
|
||||
localMediaActions.share(localMedia.data)
|
||||
.onFailure {
|
||||
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
|
||||
@@ -145,8 +145,8 @@ class MediaViewerPresenter @AssistedInject constructor(
|
||||
} else Unit
|
||||
}
|
||||
|
||||
private fun CoroutineScope.open(localMedia: Async<LocalMedia>) = launch {
|
||||
if (localMedia is Async.Success) {
|
||||
private fun CoroutineScope.open(localMedia: AsyncData<LocalMedia>) = launch {
|
||||
if (localMedia is AsyncData.Success) {
|
||||
localMediaActions.open(localMedia.data)
|
||||
.onFailure {
|
||||
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
package io.element.android.libraries.mediaviewer.api.viewer
|
||||
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||
@@ -25,7 +25,7 @@ import io.element.android.libraries.mediaviewer.api.local.MediaInfo
|
||||
data class MediaViewerState(
|
||||
val mediaInfo: MediaInfo,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val downloadedMedia: Async<LocalMedia>,
|
||||
val downloadedMedia: AsyncData<LocalMedia>,
|
||||
val snackbarMessage: SnackbarMessage?,
|
||||
val canDownload: Boolean,
|
||||
val canShare: Boolean,
|
||||
|
||||
@@ -18,7 +18,7 @@ package io.element.android.libraries.mediaviewer.api.viewer
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.local.aFileInfo
|
||||
@@ -31,48 +31,48 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
|
||||
override val values: Sequence<MediaViewerState>
|
||||
get() = sequenceOf(
|
||||
aMediaViewerState(),
|
||||
aMediaViewerState(Async.Loading()),
|
||||
aMediaViewerState(Async.Failure(IllegalStateException("error"))),
|
||||
aMediaViewerState(AsyncData.Loading()),
|
||||
aMediaViewerState(AsyncData.Failure(IllegalStateException("error"))),
|
||||
aMediaViewerState(
|
||||
Async.Success(
|
||||
AsyncData.Success(
|
||||
LocalMedia(Uri.EMPTY, anImageInfo())
|
||||
),
|
||||
anImageInfo(),
|
||||
),
|
||||
aMediaViewerState(
|
||||
Async.Success(
|
||||
AsyncData.Success(
|
||||
LocalMedia(Uri.EMPTY, aVideoInfo())
|
||||
),
|
||||
aVideoInfo(),
|
||||
),
|
||||
aMediaViewerState(
|
||||
Async.Success(
|
||||
AsyncData.Success(
|
||||
LocalMedia(Uri.EMPTY, aPdfInfo())
|
||||
),
|
||||
aPdfInfo(),
|
||||
),
|
||||
aMediaViewerState(
|
||||
Async.Loading(),
|
||||
AsyncData.Loading(),
|
||||
aFileInfo(),
|
||||
),
|
||||
aMediaViewerState(
|
||||
Async.Success(
|
||||
AsyncData.Success(
|
||||
LocalMedia(Uri.EMPTY, aFileInfo())
|
||||
),
|
||||
aFileInfo(),
|
||||
),
|
||||
aMediaViewerState(
|
||||
Async.Loading(),
|
||||
AsyncData.Loading(),
|
||||
anAudioInfo(),
|
||||
),
|
||||
aMediaViewerState(
|
||||
Async.Success(
|
||||
AsyncData.Success(
|
||||
LocalMedia(Uri.EMPTY, anAudioInfo())
|
||||
),
|
||||
anAudioInfo(),
|
||||
),
|
||||
aMediaViewerState(
|
||||
Async.Success(
|
||||
AsyncData.Success(
|
||||
LocalMedia(Uri.EMPTY, anImageInfo())
|
||||
),
|
||||
anImageInfo(),
|
||||
@@ -83,7 +83,7 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
|
||||
}
|
||||
|
||||
fun aMediaViewerState(
|
||||
downloadedMedia: Async<LocalMedia> = Async.Uninitialized,
|
||||
downloadedMedia: AsyncData<LocalMedia> = AsyncData.Uninitialized,
|
||||
mediaInfo: MediaInfo = anImageInfo(),
|
||||
canDownload: Boolean = true,
|
||||
canShare: Boolean = true,
|
||||
|
||||
@@ -47,7 +47,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
||||
@@ -93,7 +93,7 @@ fun MediaViewerView(
|
||||
modifier,
|
||||
topBar = {
|
||||
MediaViewerTopBar(
|
||||
actionsEnabled = state.downloadedMedia is Async.Success,
|
||||
actionsEnabled = state.downloadedMedia is AsyncData.Success,
|
||||
mimeType = state.mediaInfo.mimeType,
|
||||
onBackPressed = onBackPressed,
|
||||
canDownload = state.canDownload,
|
||||
@@ -121,7 +121,7 @@ fun MediaViewerView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (state.downloadedMedia is Async.Failure) {
|
||||
if (state.downloadedMedia is AsyncData.Failure) {
|
||||
ErrorView(
|
||||
errorMessage = stringResource(id = CommonStrings.error_unknown),
|
||||
onRetry = ::onRetry,
|
||||
@@ -144,7 +144,7 @@ fun MediaViewerView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberShowProgress(downloadedMedia: Async<LocalMedia>): Boolean {
|
||||
private fun rememberShowProgress(downloadedMedia: AsyncData<LocalMedia>): Boolean {
|
||||
var showProgress by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ 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.Async
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.matrix.test.media.FakeMediaLoader
|
||||
import io.element.android.libraries.matrix.test.media.aMediaSource
|
||||
@@ -60,13 +60,13 @@ class MediaViewerPresenterTest {
|
||||
presenter.present()
|
||||
}.test {
|
||||
var state = awaitItem()
|
||||
assertThat(state.downloadedMedia).isEqualTo(Async.Uninitialized)
|
||||
assertThat(state.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
|
||||
assertThat(state.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
|
||||
state = awaitItem()
|
||||
assertThat(state.downloadedMedia).isInstanceOf(Async.Loading::class.java)
|
||||
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
|
||||
state = awaitItem()
|
||||
val successData = state.downloadedMedia.dataOrNull()
|
||||
assertThat(state.downloadedMedia).isInstanceOf(Async.Success::class.java)
|
||||
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(successData).isNotNull()
|
||||
}
|
||||
}
|
||||
@@ -81,15 +81,15 @@ class MediaViewerPresenterTest {
|
||||
presenter.present()
|
||||
}.test {
|
||||
var state = awaitItem()
|
||||
assertThat(state.downloadedMedia).isEqualTo(Async.Uninitialized)
|
||||
assertThat(state.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
|
||||
state = awaitItem()
|
||||
assertThat(state.downloadedMedia).isInstanceOf(Async.Loading::class.java)
|
||||
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
|
||||
// no state changes while media is loading
|
||||
state.eventSink(MediaViewerEvents.OpenWith)
|
||||
state.eventSink(MediaViewerEvents.Share)
|
||||
state.eventSink(MediaViewerEvents.SaveOnDisk)
|
||||
state = awaitItem()
|
||||
assertThat(state.downloadedMedia).isInstanceOf(Async.Success::class.java)
|
||||
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
|
||||
// Should succeed without change of state
|
||||
state.eventSink(MediaViewerEvents.OpenWith)
|
||||
// Should succeed without change of state
|
||||
@@ -128,21 +128,21 @@ class MediaViewerPresenterTest {
|
||||
}.test {
|
||||
mediaLoader.shouldFail = true
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized)
|
||||
assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
|
||||
assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
|
||||
assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
|
||||
val failureState = awaitItem()
|
||||
assertThat(failureState.downloadedMedia).isInstanceOf(Async.Failure::class.java)
|
||||
assertThat(failureState.downloadedMedia).isInstanceOf(AsyncData.Failure::class.java)
|
||||
mediaLoader.shouldFail = false
|
||||
failureState.eventSink(MediaViewerEvents.RetryLoading)
|
||||
//There is one recomposition because of the retry mechanism
|
||||
skipItems(1)
|
||||
val retryLoadingState = awaitItem()
|
||||
assertThat(retryLoadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
|
||||
assertThat(retryLoadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
|
||||
val successState = awaitItem()
|
||||
val successData = successState.downloadedMedia.dataOrNull()
|
||||
assertThat(successState.downloadedMedia).isInstanceOf(Async.Success::class.java)
|
||||
assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
|
||||
assertThat(successData).isNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageT
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
||||
@@ -231,6 +232,7 @@ class NotifiableEventResolver @Inject constructor(
|
||||
is EmoteMessageType -> "* $senderDisplayName ${messageType.body}"
|
||||
is FileMessageType -> messageType.body
|
||||
is ImageMessageType -> messageType.body
|
||||
is StickerMessageType -> messageType.body
|
||||
is NoticeMessageType -> messageType.body
|
||||
is TextMessageType -> messageType.toPlainText()
|
||||
is VideoMessageType -> messageType.body
|
||||
|
||||
@@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageT
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
||||
@@ -234,6 +235,23 @@ class NotifiableEventResolverTest {
|
||||
assertThat(result).isEqualTo(expectedResult)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve event message sticker`() = runTest {
|
||||
val sut = createNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
createNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomMessage(
|
||||
senderId = A_USER_ID_2,
|
||||
messageType = StickerMessageType("Sticker", MediaSource("url"), null),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
|
||||
val expectedResult = createNotifiableMessageEvent(body = "Sticker")
|
||||
assertThat(result).isEqualTo(expectedResult)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve event message file`() = runTest {
|
||||
val sut = createNotifiableEventResolver(
|
||||
|
||||
@@ -51,7 +51,7 @@ class RoomSelectPresenter @AssistedInject constructor(
|
||||
var selectedRooms by remember { mutableStateOf(persistentListOf<RoomSummaryDetails>()) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
var isSearchActive by remember { mutableStateOf(false) }
|
||||
var results: SearchBarResultState<ImmutableList<RoomSummaryDetails>> by remember { mutableStateOf(SearchBarResultState.NotSearching()) }
|
||||
var results: SearchBarResultState<ImmutableList<RoomSummaryDetails>> by remember { mutableStateOf(SearchBarResultState.Initial()) }
|
||||
|
||||
val summaries by client.roomListService.allRooms.summaries.collectAsState()
|
||||
|
||||
@@ -64,7 +64,7 @@ class RoomSelectPresenter @AssistedInject constructor(
|
||||
results = if (filteredSummaries.isNotEmpty()) {
|
||||
SearchBarResultState.Results(filteredSummaries)
|
||||
} else {
|
||||
SearchBarResultState.NoResults()
|
||||
SearchBarResultState.NoResultsFound()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ open class RoomSelectStateProvider : PreviewParameterProvider<RoomSelectState> {
|
||||
}
|
||||
|
||||
private fun aRoomSelectState(
|
||||
resultState: SearchBarResultState<ImmutableList<RoomSummaryDetails>> = SearchBarResultState.NotSearching(),
|
||||
resultState: SearchBarResultState<ImmutableList<RoomSummaryDetails>> = SearchBarResultState.Initial(),
|
||||
query: String = "",
|
||||
isSearchActive: Boolean = false,
|
||||
selectedRooms: ImmutableList<RoomSummaryDetails> = persistentListOf(),
|
||||
|
||||
@@ -45,11 +45,11 @@ class RoomSelectPresenterTests {
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.selectedRooms).isEmpty()
|
||||
assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.NotSearching::class.java)
|
||||
assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java)
|
||||
assertThat(initialState.isSearchActive).isFalse()
|
||||
// Search is run automatically
|
||||
val searchState = awaitItem()
|
||||
assertThat(searchState.resultState).isInstanceOf(SearchBarResultState.NoResults::class.java)
|
||||
assertThat(searchState.resultState).isInstanceOf(SearchBarResultState.NoResultsFound::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ class RoomSelectPresenterTests {
|
||||
|
||||
initialState.eventSink(RoomSelectEvents.UpdateQuery("string not contained"))
|
||||
assertThat(awaitItem().query).isEqualTo("string not contained")
|
||||
assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResults::class.java)
|
||||
assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResultsFound::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Typeface
|
||||
import android.text.style.ReplacementSpan
|
||||
import io.element.android.libraries.core.extensions.orEmpty
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class MentionSpan(
|
||||
@@ -32,51 +34,63 @@ class MentionSpan(
|
||||
val typeface: Typeface = Typeface.DEFAULT,
|
||||
) : ReplacementSpan() {
|
||||
|
||||
companion object {
|
||||
private const val MAX_LENGTH = 20
|
||||
}
|
||||
|
||||
private var actualText: CharSequence? = null
|
||||
private var textWidth = 0
|
||||
private var cachedRect: RectF = RectF()
|
||||
private val backgroundPaint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
color = backgroundColor
|
||||
}
|
||||
|
||||
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
|
||||
val mentionText = getActualText(text, start)
|
||||
var actualEnd = end
|
||||
if (mentionText != text.toString()) {
|
||||
actualEnd = end + 1
|
||||
}
|
||||
val mentionText = getActualText(text, start, end)
|
||||
paint.typeface = typeface
|
||||
return paint.measureText(mentionText, start, actualEnd).roundToInt() + startPadding + endPadding
|
||||
textWidth = paint.measureText(mentionText, 0, mentionText.length).roundToInt()
|
||||
return textWidth + startPadding + endPadding
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
|
||||
val mentionText = getActualText(text, start)
|
||||
var actualEnd = end
|
||||
if (mentionText != text.toString()) {
|
||||
actualEnd = end + 1
|
||||
}
|
||||
val textWidth = paint.measureText(mentionText, start, actualEnd)
|
||||
val mentionText = getActualText(text, start, end)
|
||||
|
||||
// Extra vertical space to add below the baseline (y). This helps us center the span vertically
|
||||
val extraVerticalSpace = y + paint.ascent() + paint.descent() - top
|
||||
val rect = RectF(x, top.toFloat(), x + textWidth + startPadding + endPadding, y.toFloat() + extraVerticalSpace)
|
||||
paint.color = backgroundColor
|
||||
canvas.drawRoundRect(rect, rect.height() / 2, rect.height() / 2, paint)
|
||||
if (cachedRect.isEmpty) {
|
||||
cachedRect = RectF(x, top.toFloat(), x + textWidth + startPadding + endPadding, y.toFloat() + extraVerticalSpace)
|
||||
}
|
||||
|
||||
val rect = cachedRect
|
||||
val radius = rect.height() / 2
|
||||
canvas.drawRoundRect(rect, radius, radius, backgroundPaint)
|
||||
paint.color = textColor
|
||||
paint.typeface = typeface
|
||||
canvas.drawText(mentionText, start, actualEnd, x + startPadding, y.toFloat(), paint)
|
||||
canvas.drawText(mentionText, 0, mentionText.length, x + startPadding, y.toFloat(), paint)
|
||||
}
|
||||
|
||||
private fun getActualText(text: CharSequence?, start: Int): String {
|
||||
return when (type) {
|
||||
Type.USER -> {
|
||||
val mentionText = text.toString()
|
||||
if (start in mentionText.indices && mentionText[start] != '@') {
|
||||
mentionText.replaceRange(start, start, "@")
|
||||
} else {
|
||||
mentionText
|
||||
private fun getActualText(text: CharSequence?, start: Int, end: Int): CharSequence {
|
||||
if (actualText != null) return actualText!!
|
||||
return buildString {
|
||||
val mentionText = text.orEmpty()
|
||||
when (type) {
|
||||
Type.USER -> {
|
||||
if (start in mentionText.indices && mentionText[start] != '@') {
|
||||
append("@")
|
||||
}
|
||||
}
|
||||
Type.ROOM -> {
|
||||
if (start in mentionText.indices && mentionText[start] != '#') {
|
||||
append("#")
|
||||
}
|
||||
}
|
||||
}
|
||||
Type.ROOM -> {
|
||||
val mentionText = text.toString()
|
||||
if (start in mentionText.indices && mentionText[start] != '#') {
|
||||
mentionText.replaceRange(start, start, "#")
|
||||
} else {
|
||||
mentionText
|
||||
}
|
||||
append(mentionText.substring(start, min(end, start + MAX_LENGTH)))
|
||||
if (end - start > MAX_LENGTH) {
|
||||
append("…")
|
||||
}
|
||||
actualText = this
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,5 +20,5 @@ import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface UserRepository {
|
||||
|
||||
suspend fun search(query: String): Flow<List<UserSearchResult>>
|
||||
fun search(query: String): Flow<UserSearchResultState>
|
||||
}
|
||||
|
||||
@@ -22,3 +22,8 @@ data class UserSearchResult(
|
||||
val matrixUser: MatrixUser,
|
||||
val isUnresolved: Boolean = false,
|
||||
)
|
||||
|
||||
data class UserSearchResultState(
|
||||
val results: List<UserSearchResult>,
|
||||
val isSearching: Boolean,
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.usersearch.api.UserListDataSource
|
||||
import io.element.android.libraries.usersearch.api.UserRepository
|
||||
import io.element.android.libraries.usersearch.api.UserSearchResult
|
||||
import io.element.android.libraries.usersearch.api.UserSearchResultState
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
@@ -36,36 +37,45 @@ class MatrixUserRepository @Inject constructor(
|
||||
private val dataSource: UserListDataSource
|
||||
) : UserRepository {
|
||||
|
||||
override suspend fun search(query: String): Flow<List<UserSearchResult>> = flow {
|
||||
// If the search term is a MXID that's not ours, we'll show a 'fake' result for that user, then update it when we get search results.
|
||||
override fun search(query: String): Flow<UserSearchResultState> = flow {
|
||||
val shouldQueryProfile = MatrixPatterns.isUserId(query) && !client.isMe(UserId(query))
|
||||
if (shouldQueryProfile) {
|
||||
emit(listOf(UserSearchResult(MatrixUser(UserId(query)))))
|
||||
val shouldFetchSearchResults = query.length >= MINIMUM_SEARCH_LENGTH
|
||||
// If the search term is a MXID that's not ours, we'll show a 'fake' result for that user, then update it when we get search results.
|
||||
val fakeSearchResult = if (shouldQueryProfile) {
|
||||
UserSearchResult(MatrixUser(UserId(query)))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (query.length >= MINIMUM_SEARCH_LENGTH) {
|
||||
// Debounce
|
||||
delay(DEBOUNCE_TIME_MILLIS)
|
||||
|
||||
val results = dataSource
|
||||
.search(query, MAXIMUM_SEARCH_RESULTS)
|
||||
.filter { !client.isMe(it.userId) }
|
||||
.map { UserSearchResult(it) }
|
||||
.toMutableList()
|
||||
|
||||
// If the query is another user's MXID and the result doesn't contain that user ID, query the profile information explicitly
|
||||
if (shouldQueryProfile && results.none { it.matrixUser.userId.value == query }) {
|
||||
results.add(
|
||||
0,
|
||||
dataSource.getProfile(UserId(query))
|
||||
?.let { UserSearchResult(it) }
|
||||
?: UserSearchResult(MatrixUser(UserId(query)), isUnresolved = true))
|
||||
}
|
||||
|
||||
if (shouldQueryProfile || shouldFetchSearchResults) {
|
||||
emit(UserSearchResultState(isSearching = shouldFetchSearchResults, results = listOfNotNull(fakeSearchResult)))
|
||||
}
|
||||
if (shouldFetchSearchResults) {
|
||||
val results = fetchSearchResults(query, shouldQueryProfile)
|
||||
emit(results)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchSearchResults(query: String, shouldQueryProfile: Boolean): UserSearchResultState {
|
||||
// Debounce
|
||||
delay(DEBOUNCE_TIME_MILLIS)
|
||||
val results = dataSource
|
||||
.search(query, MAXIMUM_SEARCH_RESULTS)
|
||||
.filter { !client.isMe(it.userId) }
|
||||
.map { UserSearchResult(it) }
|
||||
.toMutableList()
|
||||
|
||||
// If the query is another user's MXID and the result doesn't contain that user ID, query the profile information explicitly
|
||||
if (shouldQueryProfile && results.none { it.matrixUser.userId.value == query }) {
|
||||
results.add(
|
||||
0,
|
||||
dataSource.getProfile(UserId(query))
|
||||
?.let { UserSearchResult(it) }
|
||||
?: UserSearchResult(MatrixUser(UserId(query)), isUnresolved = true))
|
||||
}
|
||||
|
||||
return UserSearchResultState(results = results, isSearching = false)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DEBOUNCE_TIME_MILLIS = 250L
|
||||
private const val MINIMUM_SEARCH_LENGTH = 3
|
||||
|
||||
@@ -54,7 +54,14 @@ internal class MatrixUserRepositoryTest {
|
||||
val result = repository.search("some query")
|
||||
|
||||
result.test {
|
||||
assertThat(awaitItem()).isEmpty()
|
||||
awaitItem().also {
|
||||
assertThat(it.isSearching).isTrue()
|
||||
assertThat(it.results).isEmpty()
|
||||
}
|
||||
awaitItem().also {
|
||||
assertThat(it.isSearching).isFalse()
|
||||
assertThat(it.results).isEmpty()
|
||||
}
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
@@ -68,7 +75,14 @@ internal class MatrixUserRepositoryTest {
|
||||
val result = repository.search("some query")
|
||||
|
||||
result.test {
|
||||
assertThat(awaitItem()).isEqualTo(aMatrixUserList().toUserSearchResults())
|
||||
awaitItem().also {
|
||||
assertThat(it.isSearching).isTrue()
|
||||
assertThat(it.results).isEmpty()
|
||||
}
|
||||
awaitItem().also {
|
||||
assertThat(it.isSearching).isFalse()
|
||||
assertThat(it.results).isEqualTo(aMatrixUserList().toUserSearchResults())
|
||||
}
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
@@ -81,9 +95,11 @@ internal class MatrixUserRepositoryTest {
|
||||
val result = repository.search(A_USER_ID.value)
|
||||
|
||||
result.test {
|
||||
assertThat(awaitItem()).isEqualTo(listOf(placeholderResult()))
|
||||
skipItems(1)
|
||||
awaitComplete()
|
||||
awaitItem().also {
|
||||
assertThat(it.isSearching).isTrue()
|
||||
assertThat(it.results).isEqualTo(listOf(placeholderResult()))
|
||||
}
|
||||
cancelAndConsumeRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,8 +111,11 @@ internal class MatrixUserRepositoryTest {
|
||||
val result = repository.search(SESSION_ID.value)
|
||||
|
||||
result.test {
|
||||
assertThat(awaitItem()).isEmpty()
|
||||
awaitComplete()
|
||||
awaitItem().also {
|
||||
assertThat(it.isSearching).isTrue()
|
||||
assertThat(it.results).isEmpty()
|
||||
}
|
||||
cancelAndConsumeRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +129,8 @@ internal class MatrixUserRepositoryTest {
|
||||
val result = repository.search("some text")
|
||||
|
||||
result.test {
|
||||
assertThat(awaitItem()).isEqualTo(aMatrixUserList().toUserSearchResults())
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().results).isEqualTo(aMatrixUserList().toUserSearchResults())
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
@@ -126,7 +146,7 @@ internal class MatrixUserRepositoryTest {
|
||||
|
||||
result.test {
|
||||
skipItems(1)
|
||||
assertThat(awaitItem()).isEqualTo(searchResults.toUserSearchResults())
|
||||
assertThat(awaitItem().results).isEqualTo(searchResults.toUserSearchResults())
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
@@ -145,7 +165,7 @@ internal class MatrixUserRepositoryTest {
|
||||
|
||||
result.test {
|
||||
skipItems(1)
|
||||
assertThat(awaitItem()).isEqualTo((listOf(userProfile) + searchResults).toUserSearchResults())
|
||||
assertThat(awaitItem().results).isEqualTo((listOf(userProfile) + searchResults).toUserSearchResults())
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
@@ -163,7 +183,8 @@ internal class MatrixUserRepositoryTest {
|
||||
val result = repository.search(SESSION_ID.value)
|
||||
|
||||
result.test {
|
||||
assertThat(awaitItem()).isEqualTo(searchResults.toUserSearchResults())
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().results).isEqualTo(searchResults.toUserSearchResults())
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
@@ -181,7 +202,7 @@ internal class MatrixUserRepositoryTest {
|
||||
|
||||
result.test {
|
||||
skipItems(1)
|
||||
assertThat(awaitItem()).isEqualTo(listOf(placeholderResult(isUnresolved = true)) + searchResults.toUserSearchResults())
|
||||
assertThat(awaitItem().results).isEqualTo(listOf(placeholderResult(isUnresolved = true)) + searchResults.toUserSearchResults())
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
package io.element.android.libraries.usersearch.test
|
||||
|
||||
import io.element.android.libraries.usersearch.api.UserRepository
|
||||
import io.element.android.libraries.usersearch.api.UserSearchResult
|
||||
import io.element.android.libraries.usersearch.api.UserSearchResultState
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
|
||||
@@ -26,15 +26,15 @@ class FakeUserRepository : UserRepository {
|
||||
var providedQuery: String? = null
|
||||
private set
|
||||
|
||||
private val flow = MutableSharedFlow<List<UserSearchResult>>()
|
||||
private val flow = MutableSharedFlow<UserSearchResultState>()
|
||||
|
||||
override suspend fun search(query: String): Flow<List<UserSearchResult>> {
|
||||
override fun search(query: String): Flow<UserSearchResultState> {
|
||||
providedQuery = query
|
||||
return flow
|
||||
}
|
||||
|
||||
suspend fun emitResult(result: List<UserSearchResult>) {
|
||||
flow.emit(result)
|
||||
suspend fun emitState(state: UserSearchResultState) {
|
||||
flow.emit(state)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user