Merge branch 'develop' into feature/fga/image_loading

This commit is contained in:
ganfra 2023-05-22 20:59:37 +02:00
commit 63513ae2da
212 changed files with 1616 additions and 916 deletions

View file

@ -18,8 +18,10 @@ package io.element.android.libraries.designsystem.components.preferences
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
@ -45,7 +47,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun PreferenceView(
title: String,
@ -70,6 +72,7 @@ fun PreferenceView(
Column(
modifier = Modifier
.padding(it)
.consumeWindowInsets(it)
.verticalScroll(
state = scrollState,
)

View file

@ -14,8 +14,6 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.layout.RowScope

View file

@ -14,8 +14,6 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.layout.RowScope
@ -57,6 +55,7 @@ fun MediumTopAppBar(
internal fun MediumTopAppBarPreview() =
ElementThemedPreview { ContentToPreview() }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ContentToPreview() {
MediumTopAppBar(title = { Text(text = "Title") })

View file

@ -14,8 +14,6 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterialApi::class, ExperimentalMaterialApi::class)
package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.background
@ -107,6 +105,7 @@ internal fun ModalBottomSheetLayoutLightPreview() =
internal fun ModalBottomSheetLayoutDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun ContentToPreview() {
ModalBottomSheetLayout(

View file

@ -23,10 +23,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
@ -49,7 +48,6 @@ import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.utils.allBooleans
import io.element.android.libraries.designsystem.utils.asInt
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun OutlinedTextField(
value: String,
@ -70,8 +68,8 @@ fun OutlinedTextField(
singleLine: Boolean = false,
maxLines: Int = Int.MAX_VALUE,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = TextFieldDefaults.outlinedShape,
colors: TextFieldColors = TextFieldDefaults.outlinedTextFieldColors()
shape: Shape = OutlinedTextFieldDefaults.shape,
colors: TextFieldColors = OutlinedTextFieldDefaults.colors()
) {
androidx.compose.material3.OutlinedTextField(
value = value,

View file

@ -18,7 +18,6 @@ package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FabPosition
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScaffoldDefaults
@ -27,7 +26,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Scaffold(
modifier: Modifier = Modifier,

View file

@ -14,77 +14,218 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SearchBarColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.ui.strings.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchBar(
fun <T> SearchBar(
query: String,
onQueryChange: (String) -> Unit,
onSearch: (String) -> Unit,
active: Boolean,
onActiveChange: (Boolean) -> Unit,
placeHolderTitle: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
resultState: SearchBarResultState<T> = SearchBarResultState.NotSearching(),
shape: Shape = SearchBarDefaults.inputFieldShape,
colors: SearchBarColors = SearchBarDefaults.colors(),
tonalElevation: Dp = SearchBarDefaults.Elevation,
windowInsets: WindowInsets = SearchBarDefaults.windowInsets,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable ColumnScope.() -> Unit,
contentPrefix: @Composable ColumnScope.() -> Unit = {},
contentSuffix: @Composable ColumnScope.() -> Unit = {},
resultHandler: @Composable ColumnScope.(T) -> Unit = {},
) {
val focusManager = LocalFocusManager.current
if (!active) {
onQueryChange("")
focusManager.clearFocus()
}
androidx.compose.material3.SearchBar(
query = query,
onQueryChange = onQueryChange,
onSearch = onSearch,
onSearch = { focusManager.clearFocus() },
active = active,
onActiveChange = onActiveChange,
modifier = modifier,
modifier = modifier.padding(horizontal = if (!active) 16.dp else 0.dp),
enabled = enabled,
placeholder = placeholder,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
placeholder = {
Text(
text = placeHolderTitle,
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
)
},
leadingIcon = if (active) {
{ BackButton(onClick = { onActiveChange(false) }) }
} else {
null
},
trailingIcon = when {
active && query.isNotEmpty() -> {
{
IconButton(onClick = { onQueryChange("") }) {
Icon(Icons.Default.Close, stringResource(R.string.action_clear))
}
}
}
!active -> {
{
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.action_search),
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
)
}
}
else -> null
},
shape = shape,
colors = colors,
colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent),
tonalElevation = tonalElevation,
windowInsets = windowInsets,
interactionSource = interactionSource,
content = content,
content = {
contentPrefix()
when (resultState) {
is SearchBarResultState.Results<T> -> {
resultHandler(resultState.results)
}
is SearchBarResultState.NoResults<T> -> {
// No results found, show a message
Spacer(Modifier.size(80.dp))
Text(
text = stringResource(R.string.common_no_results),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.fillMaxWidth()
)
}
else -> {
// Not searching - nothing to show.
}
}
contentSuffix()
},
)
}
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>
/** The search has completed, but no results were found. */
class NoResults<T> : SearchBarResultState<T>
/** The search has completed, and some matching users were found. */
data class Results<T>(val results: T) : SearchBarResultState<T>
}
@Preview(group = PreviewGroup.Search)
@Composable
internal fun SearchBarPreviewInactive() = ElementThemedPreview { ContentToPreview() }
@Preview(group = PreviewGroup.Search)
@Composable
internal fun SearchBarPreviewActiveEmptyQuery() = ElementThemedPreview {
ContentToPreview(
query = "",
active = true,
)
}
@Preview(group = PreviewGroup.Search)
@Composable
internal fun DockedSearchBarPreview() = ElementThemedPreview { ContentToPreview() }
@Composable
private fun ContentToPreview() {
SearchBar(
query = "Some text",
onQueryChange = {},
onSearch = {},
active = false,
onActiveChange = {},
content = {},
internal fun SearchBarPreviewActiveWithQuery() = ElementThemedPreview {
ContentToPreview(
query = "search term",
active = true,
)
}
@Preview(group = PreviewGroup.Search)
@Composable
internal fun SearchBarPreviewActiveWithNoResults() = ElementThemedPreview {
ContentToPreview(
query = "search term",
active = true,
resultState = SearchBarResultState.NoResults(),
)
}
@Preview(group = PreviewGroup.Search)
@Composable
internal fun SearchBarPreviewActiveWithContent() = ElementThemedPreview {
ContentToPreview(
query = "search term",
active = true,
resultState = SearchBarResultState.Results("result!"),
contentPrefix = {
Text(text = "Content that goes before the search results", modifier = Modifier.background(color = Color.Red).fillMaxWidth())
},
contentSuffix = {
Text(text = "Content that goes after the search results", modifier = Modifier.background(color = Color.Blue).fillMaxWidth())
},
resultHandler = {
Text(text = "Results go here", modifier = Modifier.background(color = Color.Green).fillMaxWidth())
}
)
}
@Composable
private fun ContentToPreview(
query: String = "",
active: Boolean = false,
resultState: SearchBarResultState<String> = SearchBarResultState.NotSearching(),
contentPrefix: @Composable ColumnScope.() -> Unit = {},
contentSuffix: @Composable ColumnScope.() -> Unit = {},
resultHandler: @Composable ColumnScope.(String) -> Unit = {},
) {
SearchBar(
query = query,
active = active,
resultState = resultState,
onQueryChange = {},
onActiveChange = {},
placeHolderTitle = "Search for things",
contentPrefix = contentPrefix,
contentSuffix = contentSuffix,
resultHandler = resultHandler,
)
}

View file

@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
@ -50,7 +49,6 @@ import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.utils.allBooleans
import io.element.android.libraries.designsystem.utils.asInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TextField(
value: String,
@ -71,8 +69,8 @@ fun TextField(
singleLine: Boolean = false,
maxLines: Int = Int.MAX_VALUE,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = TextFieldDefaults.filledShape,
colors: TextFieldColors = TextFieldDefaults.textFieldColors()
shape: Shape = TextFieldDefaults.shape,
colors: TextFieldColors = TextFieldDefaults.colors()
) {
androidx.compose.material3.TextField(
value = value,

View file

@ -14,8 +14,6 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.layout.RowScope

View file

@ -43,6 +43,7 @@ interface MatrixClient : Closeable {
suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId>
suspend fun createDM(userId: UserId): Result<RoomId>
suspend fun getProfile(userId: UserId): Result<MatrixUser>
suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults>
fun startSync()
fun stopSync()
fun sessionVerificationService(): SessionVerificationService
@ -51,10 +52,7 @@ interface MatrixClient : Closeable {
suspend fun logout()
suspend fun loadUserDisplayName(): Result<String>
suspend fun loadUserAvatarURLString(): Result<String?>
suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String>
fun onSlidingSyncUpdate()
fun roomMembershipObserver(): RoomMembershipObserver
suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults>
}

View file

@ -14,8 +14,6 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.libraries.matrix.impl
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@ -46,7 +44,6 @@ import io.element.android.libraries.matrix.impl.verification.RustSessionVerifica
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.filter
@ -284,6 +281,13 @@ class RustMatrixClient constructor(
}
}
override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> =
withContext(dispatchers.io) {
runCatching {
client.searchUsers(searchTerm, limit.toULong()).let(UserSearchResultMapper::map)
}
}
override fun sessionVerificationService(): SessionVerificationService = verificationService
override fun pushersService(): PushersService = pushersService
@ -341,6 +345,13 @@ class RustMatrixClient constructor(
}
}
@OptIn(ExperimentalUnsignedTypes::class)
override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String> = withContext(dispatchers.io) {
runCatching {
client.uploadMedia(mimeType, data.toUByteArray().toList())
}
}
override fun onSlidingSyncUpdate() {
if (!verificationService.isReady.value) {
try {
@ -353,13 +364,6 @@ class RustMatrixClient constructor(
override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver
override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> =
withContext(dispatchers.io) {
runCatching {
client.searchUsers(searchTerm, limit.toULong()).let(UserSearchResultMapper::map)
}
}
private fun File.deleteSessionDirectory(userID: String): Boolean {
// Rust sanitises the user ID replacing invalid characters with an _
val sanitisedUserID = userID.replace(":", "_")

View file

@ -91,7 +91,6 @@ internal class RustRoomSummaryDataSource(
coroutineScope.cancel()
}
//@OptIn(FlowPreview::class)
override fun roomSummaries(): StateFlow<List<RoomSummary>> {
return roomSummaries
}

View file

@ -60,6 +60,7 @@ class FakeMatrixClient(
private val getRoomResults = mutableMapOf<RoomId, MatrixRoom>()
private val searchUserResults = mutableMapOf<String, Result<MatrixSearchUserResults>>()
private val getProfileResults = mutableMapOf<UserId, Result<MatrixUser>>()
private var uploadMediaResult: Result<String> = Result.success(AN_AVATAR_URL)
override fun getRoom(roomId: RoomId): MatrixRoom? {
return getRoomResults[roomId]
@ -92,6 +93,10 @@ class FakeMatrixClient(
return getProfileResults[userId] ?: Result.failure(IllegalStateException("No profile found for $userId"))
}
override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> {
return searchUserResults[searchTerm] ?: Result.failure(IllegalStateException("No response defined for $searchTerm"))
}
override fun startSync() = Unit
override fun stopSync() = Unit
@ -111,6 +116,10 @@ class FakeMatrixClient(
return userAvatarURLString
}
override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String> {
return uploadMediaResult
}
override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService
override fun pushersService(): PushersService = pushersService
@ -123,10 +132,6 @@ class FakeMatrixClient(
return RoomMembershipObserver()
}
override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> {
return searchUserResults[searchTerm] ?: Result.failure(IllegalStateException("No response defined for $searchTerm"))
}
// Mocks
fun givenLogoutError(failure: Throwable?) {
@ -168,4 +173,8 @@ class FakeMatrixClient(
fun givenGetProfileResult(userId: UserId, result: Result<MatrixUser>) {
getProfileResults[userId] = result
}
fun givenUploadMediaResult(result: Result<String>) {
uploadMediaResult = result
}
}

View file

@ -37,6 +37,7 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.core)
implementation(projects.libraries.uiStrings)
implementation(libs.coil.compose)
ksp(libs.showkase.processor)

View file

@ -26,11 +26,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
@Composable
fun CheckableMatrixUserRow(
@ -40,18 +43,39 @@ fun CheckableMatrixUserRow(
avatarSize: AvatarSize = AvatarSize.MEDIUM,
onCheckedChange: (Boolean) -> Unit = {},
enabled: Boolean = true,
) {
) = CheckableUserRow(
checked = checked,
avatarData = matrixUser.getAvatarData(avatarSize),
name = matrixUser.getBestName(),
subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value,
modifier = modifier,
onCheckedChange = onCheckedChange,
enabled = enabled,
)
@Composable
fun CheckableUserRow(
checked: Boolean,
avatarData: AvatarData,
name: String,
subtext: String?,
modifier: Modifier = Modifier,
onCheckedChange: (Boolean) -> Unit = {},
enabled: Boolean = true,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(role = Role.Checkbox) { onCheckedChange(!checked) },
.clickable(role = Role.Checkbox, enabled = enabled) {
onCheckedChange(!checked)
},
verticalAlignment = Alignment.CenterVertically,
) {
MatrixUserRow(
UserRow(
modifier = Modifier.weight(1f),
matrixUser = matrixUser,
avatarSize = avatarSize,
avatarData = avatarData,
name = name,
subtext = subtext,
)
Checkbox(
@ -77,5 +101,7 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
Column {
CheckableMatrixUserRow(checked = true, matrixUser)
CheckableMatrixUserRow(checked = false, matrixUser)
CheckableMatrixUserRow(checked = true, matrixUser, enabled = false)
CheckableMatrixUserRow(checked = false, matrixUser, enabled = false)
}
}

View file

@ -33,6 +33,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@ -46,6 +47,19 @@ fun MatrixUserRow(
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
avatarSize: AvatarSize = AvatarSize.MEDIUM,
) = UserRow(
avatarData = matrixUser.getAvatarData(avatarSize),
name = matrixUser.getBestName(),
subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value,
modifier = modifier,
)
@Composable
fun UserRow(
avatarData: AvatarData,
name: String,
subtext: String?,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
@ -54,9 +68,7 @@ fun MatrixUserRow(
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(
matrixUser.getAvatarData(size = avatarSize),
)
Avatar(avatarData)
Column(
modifier = Modifier
.padding(start = 12.dp),
@ -65,15 +77,15 @@ fun MatrixUserRow(
Text(
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
text = matrixUser.getBestName(),
text = name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary,
)
// Id
if (matrixUser.displayName.isNullOrEmpty().not()) {
subtext?.let {
Text(
text = matrixUser.userId.value,
text = subtext,
color = MaterialTheme.colorScheme.secondary,
fontSize = 14.sp,
maxLines = 1,

View file

@ -0,0 +1,94 @@
/*
* 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.matrix.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.ui.strings.R as StringR
@Composable
fun SelectedUser(
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
onUserRemoved: (MatrixUser) -> Unit = {},
) {
Box(modifier = modifier.width(56.dp)) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Avatar(matrixUser.getAvatarData(size = AvatarSize.Custom(56.dp)))
Text(
text = matrixUser.getBestName(),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.bodyLarge,
)
}
IconButton(
modifier = Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.size(20.dp)
.align(Alignment.TopEnd),
onClick = { onUserRemoved(matrixUser) }
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(id = StringR.string.action_remove),
tint = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
@Preview
@Composable
internal fun SelectedUserLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun SelectedUserDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
SelectedUser(aMatrixUser())
}

View file

@ -0,0 +1,87 @@
/*
* 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.matrix.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@Composable
fun SelectedUsersList(
selectedUsers: ImmutableList<MatrixUser>,
modifier: Modifier = Modifier,
autoScroll: Boolean = false,
contentPadding: PaddingValues = PaddingValues(0.dp),
onUserRemoved: (MatrixUser) -> Unit = {},
) {
val lazyListState = rememberLazyListState()
if (autoScroll) {
var currentSize by rememberSaveable { mutableStateOf(selectedUsers.size) }
LaunchedEffect(selectedUsers.size) {
val isItemAdded = selectedUsers.size > currentSize
if (isItemAdded) {
lazyListState.animateScrollToItem(selectedUsers.lastIndex)
}
currentSize = selectedUsers.size
}
}
LazyRow(
state = lazyListState,
modifier = modifier,
contentPadding = contentPadding,
horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
items(selectedUsers.toList()) { matrixUser ->
SelectedUser(
matrixUser = matrixUser,
onUserRemoved = onUserRemoved,
)
}
}
}
@Preview
@Composable
internal fun SelectedUsersListLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun SelectedUsersListDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
SelectedUsersList(
selectedUsers = aMatrixUserList().take(6).toImmutableList(),
)
}

View file

@ -27,6 +27,11 @@ interface PickerProvider {
onResult: (uri: Uri?, mimeType: String?) -> Unit
): PickerLauncher<PickVisualMediaRequest, Uri?>
@Composable
fun registerGalleryImagePicker(
onResult: (Uri?) -> Unit
): PickerLauncher<PickVisualMediaRequest, Uri?>
@Composable
fun registerFilePicker(
mimeType: String,
@ -38,5 +43,4 @@ interface PickerProvider {
@Composable
fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean>
}

View file

@ -26,6 +26,13 @@ sealed interface PickerType<Input, Output> {
fun getContract(): ActivityResultContract<Input, Output>
fun getDefaultRequest(): Input
object Image : PickerType<PickVisualMediaRequest, Uri?> {
override fun getContract() = ActivityResultContracts.PickVisualMedia()
override fun getDefaultRequest(): PickVisualMediaRequest {
return PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
}
}
object ImageAndVideo : PickerType<PickVisualMediaRequest, Uri?> {
override fun getContract() = ActivityResultContracts.PickVisualMedia()
override fun getDefaultRequest(): PickVisualMediaRequest {

View file

@ -59,6 +59,20 @@ class PickerProviderImpl constructor(private val isInTest: Boolean) : PickerProv
}
}
/**
* Remembers and returns a [PickerLauncher] for a gallery picture.
* [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected.
*/
@Composable
override fun registerGalleryImagePicker(onResult: (Uri?) -> Unit): PickerLauncher<PickVisualMediaRequest, Uri?> {
// Tests and UI preview can't handle Contexts, so we might as well disable the whole picker
return if (LocalInspectionMode.current || isInTest) {
NoOpPickerLauncher { onResult(null) }
} else {
rememberPickerLauncher(type = PickerType.Image) { uri -> onResult(uri) }
}
}
/**
* Remembers and returns a [PickerLauncher] for a gallery item, either a picture or a video.
* [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected.

View file

@ -33,6 +33,11 @@ class FakePickerProvider : PickerProvider {
return NoOpPickerLauncher { onResult(result, mimeType) }
}
@Composable
override fun registerGalleryImagePicker(onResult: (uri: Uri?) -> Unit): PickerLauncher<PickVisualMediaRequest, Uri?> {
return NoOpPickerLauncher { onResult(result) }
}
@Composable
override fun registerFilePicker(mimeType: String, onResult: (Uri?) -> Unit): PickerLauncher<String, Uri?> {
return NoOpPickerLauncher { onResult(result) }

View file

@ -24,10 +24,13 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
import java.io.File
sealed interface MediaUploadInfo {
data class Image(val file: File, val info: ImageInfo, val thumbnailInfo: ThumbnailProcessingInfo) : MediaUploadInfo
data class Video(val file: File, val info: VideoInfo, val thumbnailInfo: ThumbnailProcessingInfo) : MediaUploadInfo
data class Audio(val file: File, val info: AudioInfo) : MediaUploadInfo
data class AnyFile(val file: File, val info: FileInfo) : MediaUploadInfo
val file: File
data class Image(override val file: File, val info: ImageInfo, val thumbnailInfo: ThumbnailProcessingInfo) : MediaUploadInfo
data class Video(override val file: File, val info: VideoInfo, val thumbnailInfo: ThumbnailProcessingInfo) : MediaUploadInfo
data class Audio(override val file: File, val info: AudioInfo) : MediaUploadInfo
data class AnyFile(override val file: File, val info: FileInfo) : MediaUploadInfo
}
data class ThumbnailProcessingInfo(

View file

@ -130,19 +130,15 @@ class AndroidMediaPreProcessor @Inject constructor(
}.mapFailure { MediaPreProcessor.Failure(it) }
private fun MediaUploadInfo.postProcess(uri: Uri): MediaUploadInfo {
fun File.rename(name: String): File {
return File(context.cacheDir, name).also {
renameTo(it)
}
}
val name = context.getFileName(uri) ?: return this
val renamedFile = File(context.cacheDir, name).also {
file.renameTo(it)
}
return when (this) {
is MediaUploadInfo.AnyFile -> copy(file = file.rename(name))
is MediaUploadInfo.Audio -> copy(file = file.rename(name))
is MediaUploadInfo.Image -> copy(file = file.rename(name))
is MediaUploadInfo.Video -> copy(file = file.rename(name))
is MediaUploadInfo.AnyFile -> copy(file = renamedFile)
is MediaUploadInfo.Audio -> copy(file = renamedFile)
is MediaUploadInfo.Image -> copy(file = renamedFile)
is MediaUploadInfo.Video -> copy(file = renamedFile)
}
}

View file

@ -63,6 +63,7 @@
<string name="common_developer_options">"Developer options"</string>
<string name="common_edited_suffix">"(edited)"</string>
<string name="common_editing">"Editing"</string>
<string name="common_emote">"* %1$s %2$s"</string>
<string name="common_encryption_enabled">"Encryption enabled"</string>
<string name="common_error">"Error"</string>
<string name="common_file">"File"</string>
@ -84,12 +85,14 @@
<string name="common_report_a_bug">"Report a bug"</string>
<string name="common_report_submitted">"Report submitted"</string>
<string name="common_search_for_someone">"Search for someone"</string>
<string name="common_search_results">"Search results"</string>
<string name="common_security">"Security"</string>
<string name="common_select_your_server">"Select your server"</string>
<string name="common_sending">"Sending…"</string>
<string name="common_server_not_supported">"Server not supported"</string>
<string name="common_server_url">"Server URL"</string>
<string name="common_settings">"Settings"</string>
<string name="common_starting_chat">"Starting chat…"</string>
<string name="common_sticker">"Sticker"</string>
<string name="common_success">"Success"</string>
<string name="common_suggestions">"Suggestions"</string>
@ -159,4 +162,4 @@
<string name="screen_analytics_settings_read_terms">"You can read all our terms %1$s."</string>
<string name="screen_analytics_settings_read_terms_content_link">"here"</string>
<string name="screen_report_content_block_user">"Block user"</string>
</resources>
</resources>

View file

@ -0,0 +1,28 @@
/*
* 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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.usersearch.api"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
}

View file

@ -0,0 +1,26 @@
/*
* 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.usersearch.api
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
interface UserListDataSource {
//TODO should probably have a flow
suspend fun search(query: String): List<MatrixUser>
suspend fun getProfile(userId: UserId): MatrixUser?
}

View file

@ -0,0 +1,25 @@
/*
* 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.usersearch.api
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.coroutines.flow.Flow
interface UserRepository {
suspend fun search(query: String): Flow<List<MatrixUser>>
}

View file

@ -0,0 +1,45 @@
/*
* 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.
*/
plugins {
id("io.element.android-library")
alias(libs.plugins.anvil)
}
android {
namespace = "io.element.android.libraries.usersearch.impl"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.di)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.matrix.api)
api(projects.libraries.usersearch.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.usersearch.test)
}

View file

@ -0,0 +1,43 @@
/*
* 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.usersearch.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserListDataSource
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class MatrixUserListDataSource @Inject constructor(
private val client: MatrixClient
) : UserListDataSource {
override suspend fun search(query: String): List<MatrixUser> {
val res = client.searchUsers(query, MAX_SEARCH_RESULTS)
return res.getOrNull()?.results.orEmpty()
}
override suspend fun getProfile(userId: UserId): MatrixUser? {
return client.getProfile(userId).getOrNull()
}
companion object {
private const val MAX_SEARCH_RESULTS = 5L
}
}

View file

@ -0,0 +1,64 @@
/*
* 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.usersearch.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.UserId
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 kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class MatrixUserRepository @Inject constructor(
private val dataSource: UserListDataSource
) : UserRepository {
override suspend fun search(query: String): Flow<List<MatrixUser>> = flow {
// Manually add a fake result with the matrixId, if any
val isUserId = MatrixPatterns.isUserId(query)
if (isUserId) {
emit(listOf(MatrixUser(UserId(query))))
}
if (query.length >= MINIMUM_SEARCH_LENGTH) {
// Debounce
delay(DEBOUNCE_TIME_MILLIS)
val results = dataSource.search(query).toMutableList()
// If the query is a user ID and the result doesn't contain that user ID, query the profile information explicitly
if (isUserId && results.none { it.userId.value == query }) {
val getProfileResult: MatrixUser? = dataSource.getProfile(UserId(query))
val profile = getProfileResult ?: MatrixUser(UserId(query))
results.add(0, profile)
}
emit(results)
}
}
companion object {
private const val DEBOUNCE_TIME_MILLIS = 500L
private const val MINIMUM_SEARCH_LENGTH = 3
}
}

View file

@ -0,0 +1,101 @@
/*
* 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.usersearch.impl
import com.google.common.truth.Truth
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import kotlinx.coroutines.test.runTest
import org.junit.Test
internal class MatrixUserListDataSourceTest {
@Test
fun `search - returns users on success`() = runTest {
val matrixClient = FakeMatrixClient()
matrixClient.givenSearchUsersResult(
searchTerm = "test",
result = Result.success(
MatrixSearchUserResults(
results = listOf(
aMatrixUserProfile(),
aMatrixUserProfile(userId = A_USER_ID_2)
),
limited = false
)
)
)
val dataSource = MatrixUserListDataSource(matrixClient)
val results = dataSource.search("test")
Truth.assertThat(results).containsExactly(
aMatrixUserProfile(),
aMatrixUserProfile(userId = A_USER_ID_2)
)
}
@Test
fun `search - returns empty list on error`() = runTest {
val matrixClient = FakeMatrixClient()
matrixClient.givenSearchUsersResult(
searchTerm = "test",
result = Result.failure(Throwable("Ruhroh"))
)
val dataSource = MatrixUserListDataSource(matrixClient)
val results = dataSource.search("test")
Truth.assertThat(results).isEmpty()
}
@Test
fun `get profile - returns user on success`() = runTest {
val matrixClient = FakeMatrixClient()
matrixClient.givenGetProfileResult(
userId = A_USER_ID,
result = Result.success(aMatrixUserProfile())
)
val dataSource = MatrixUserListDataSource(matrixClient)
val result = dataSource.getProfile(A_USER_ID)
Truth.assertThat(result).isEqualTo(aMatrixUserProfile())
}
@Test
fun `get profile - returns null on error`() = runTest {
val matrixClient = FakeMatrixClient()
matrixClient.givenGetProfileResult(
userId = A_USER_ID,
result = Result.failure(Throwable("Ruhroh"))
)
val dataSource = MatrixUserListDataSource(matrixClient)
val result = dataSource.getProfile(A_USER_ID)
Truth.assertThat(result).isNull()
}
private fun aMatrixUserProfile(
userId: UserId = A_USER_ID,
displayName: String = A_USER_NAME,
avatarUrl: String = AN_AVATAR_URL
) = MatrixUser(userId, displayName, avatarUrl)
}

View file

@ -0,0 +1,140 @@
/*
* 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.usersearch.impl
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.test.FakeUserListDataSource
import kotlinx.coroutines.test.runTest
import org.junit.Test
internal class MatrixUserRepositoryTest {
@Test
fun `search - emits nothing if the search query is too short`() = runTest {
val dataSource = FakeUserListDataSource()
val repository = MatrixUserRepository(dataSource)
val result = repository.search("x")
result.test {
awaitComplete()
}
}
@Test
fun `search - returns empty list if no results are found`() = runTest {
val dataSource = FakeUserListDataSource()
val repository = MatrixUserRepository(dataSource)
val result = repository.search("some query")
result.test {
assertThat(awaitItem()).isEmpty()
awaitComplete()
}
}
@Test
fun `search - returns users if results are found`() = runTest {
val dataSource = FakeUserListDataSource()
dataSource.givenSearchResult(aMatrixUserList())
val repository = MatrixUserRepository(dataSource)
val result = repository.search("some query")
result.test {
assertThat(awaitItem()).isEqualTo(aMatrixUserList())
awaitComplete()
}
}
@Test
fun `search - immediately returns placeholder if search is mxid`() = runTest {
val dataSource = FakeUserListDataSource()
val repository = MatrixUserRepository(dataSource)
val result = repository.search(A_USER_ID.value)
result.test {
assertThat(awaitItem()).isEqualTo(listOf(MatrixUser(userId = A_USER_ID)))
skipItems(1)
awaitComplete()
}
}
@Test
fun `search - does not change results if they contain searched mxid`() = runTest {
val searchResults = aMatrixUserListWithoutUserId(A_USER_ID) + MatrixUser(userId = A_USER_ID, displayName = A_USER_NAME)
val dataSource = FakeUserListDataSource()
dataSource.givenSearchResult(searchResults)
val repository = MatrixUserRepository(dataSource)
val result = repository.search(A_USER_ID.value)
result.test {
skipItems(1)
assertThat(awaitItem()).isEqualTo(searchResults)
awaitComplete()
}
}
@Test
fun `search - gets profile results if searched mxid not in results`() = runTest {
val userProfile = MatrixUser(userId = A_USER_ID, displayName = A_USER_NAME)
val searchResults = aMatrixUserListWithoutUserId(A_USER_ID)
val dataSource = FakeUserListDataSource()
dataSource.givenSearchResult(searchResults)
dataSource.givenUserProfile(userProfile)
val repository = MatrixUserRepository(dataSource)
val result = repository.search(A_USER_ID.value)
result.test {
skipItems(1)
assertThat(awaitItem()).isEqualTo(listOf(userProfile) + searchResults)
awaitComplete()
}
}
@Test
fun `search - just shows id if profile can't be loaded`() = runTest {
val searchResults = aMatrixUserListWithoutUserId(A_USER_ID)
val dataSource = FakeUserListDataSource()
dataSource.givenSearchResult(searchResults)
dataSource.givenUserProfile(null)
val repository = MatrixUserRepository(dataSource)
val result = repository.search(A_USER_ID.value)
result.test {
skipItems(1)
assertThat(awaitItem()).isEqualTo(listOf(MatrixUser(userId = A_USER_ID)) + searchResults)
awaitComplete()
}
}
private fun aMatrixUserListWithoutUserId(userId: UserId) = aMatrixUserList().filterNot { it.userId == userId }
}

View file

@ -0,0 +1,30 @@
/*
* 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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.usersearch"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.matrix.api)
api(projects.libraries.usersearch.api)
}

View file

@ -0,0 +1,39 @@
/*
* 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.usersearch.test
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserListDataSource
class FakeUserListDataSource : UserListDataSource {
private var searchResult: List<MatrixUser> = emptyList()
private var profile: MatrixUser? = null
override suspend fun search(query: String): List<MatrixUser> = searchResult
override suspend fun getProfile(userId: UserId): MatrixUser? = profile
fun givenSearchResult(users: List<MatrixUser>) {
this.searchResult = users
}
fun givenUserProfile(matrixUser: MatrixUser?) {
this.profile = matrixUser
}
}

View file

@ -0,0 +1,40 @@
/*
* 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.usersearch.test
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
class FakeUserRepository : UserRepository {
var providedQuery: String? = null
private set
private val flow = MutableSharedFlow<List<MatrixUser>>()
override suspend fun search(query: String): Flow<List<MatrixUser>> {
providedQuery = query
return flow
}
suspend fun emitResult(result: List<MatrixUser>) {
flow.emit(result)
}
}