Merge branch 'develop' into feature/fga/image_loading

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

1
changelog.d/122.feature Normal file
View file

@ -0,0 +1 @@
[Create and join rooms] Select a media from the camera

1
changelog.d/123.feature Normal file
View file

@ -0,0 +1 @@
[Create and join rooms] Select a media from the gallery

View file

@ -45,19 +45,23 @@ dependencies {
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources) implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.features.userlist.api) implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api)
implementation(libs.coil.compose)
implementation(projects.libraries.usersearch.impl)
api(projects.features.createroom.api) api(projects.features.createroom.api)
implementation(libs.coil.compose) // FIXME temp
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.test.mockk)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime) testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric) testImplementation(libs.test.robolectric)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.userlist.impl) testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.features.userlist.test) testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.usersearch.test)
androidTestImplementation(libs.test.junitext) androidTestImplementation(libs.test.junitext)

View file

@ -16,6 +16,7 @@
package io.element.android.features.createroom.impl package io.element.android.features.createroom.impl
import android.net.Uri
import io.element.android.features.createroom.impl.configureroom.RoomPrivacy import io.element.android.features.createroom.impl.configureroom.RoomPrivacy
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -24,7 +25,7 @@ import kotlinx.collections.immutable.persistentListOf
data class CreateRoomConfig( data class CreateRoomConfig(
val roomName: String? = null, val roomName: String? = null,
val topic: String? = null, val topic: String? = null,
val avatarUrl: String? = null, val avatarUri: Uri? = null,
val invites: ImmutableList<MatrixUser> = persistentListOf(), val invites: ImmutableList<MatrixUser> = persistentListOf(),
val privacy: RoomPrivacy? = null, val privacy: RoomPrivacy = RoomPrivacy.Private,
) )

View file

@ -16,14 +16,16 @@
package io.element.android.features.createroom.impl package io.element.android.features.createroom.impl
import android.net.Uri
import io.element.android.features.createroom.impl.configureroom.RoomPrivacy import io.element.android.features.createroom.impl.configureroom.RoomPrivacy
import io.element.android.features.createroom.impl.di.CreateRoomScope import io.element.android.features.createroom.impl.di.CreateRoomScope
import io.element.android.features.userlist.api.UserListDataStore import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import java.io.File
import javax.inject.Inject import javax.inject.Inject
@SingleIn(CreateRoomScope::class) @SingleIn(CreateRoomScope::class)
@ -32,6 +34,11 @@ class CreateRoomDataStore @Inject constructor(
) { ) {
private val createRoomConfigFlow: MutableStateFlow<CreateRoomConfig> = MutableStateFlow(CreateRoomConfig()) private val createRoomConfigFlow: MutableStateFlow<CreateRoomConfig> = MutableStateFlow(CreateRoomConfig())
private var cachedAvatarUri: Uri? = null
set(value) {
field?.path?.let { File(it) }?.delete()
field = value
}
fun getCreateRoomConfig(): Flow<CreateRoomConfig> = combine( fun getCreateRoomConfig(): Flow<CreateRoomConfig> = combine(
selectedUserListDataStore.selectedUsers(), selectedUserListDataStore.selectedUsers(),
@ -48,11 +55,16 @@ class CreateRoomDataStore @Inject constructor(
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(topic = topic?.takeIf { it.isNotEmpty() })) createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(topic = topic?.takeIf { it.isNotEmpty() }))
} }
fun setAvatarUrl(avatarUrl: String?) { fun setAvatarUri(uri: Uri?, cached: Boolean = false) {
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(avatarUrl = avatarUrl)) cachedAvatarUri = uri.takeIf { cached }
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(avatarUri = uri))
} }
fun setPrivacy(privacy: RoomPrivacy?) { fun setPrivacy(privacy: RoomPrivacy) {
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(privacy = privacy)) createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(privacy = privacy))
} }
fun clearCachedData() {
cachedAvatarUri = null
}
} }

View file

@ -18,18 +18,17 @@ package io.element.android.features.createroom.impl.addpeople
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import io.element.android.features.createroom.impl.CreateRoomDataStore import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.userlist.api.SelectionMode import io.element.android.features.createroom.impl.userlist.SelectionMode
import io.element.android.features.userlist.api.UserListDataSource import io.element.android.features.createroom.impl.userlist.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenter import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs
import io.element.android.features.userlist.api.UserListPresenterArgs import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.features.userlist.api.UserListState
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.usersearch.api.UserRepository
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Named
class AddPeoplePresenter @Inject constructor( class AddPeoplePresenter @Inject constructor(
private val userListPresenterFactory: UserListPresenter.Factory, private val userListPresenterFactory: UserListPresenter.Factory,
@Named("AllUsers") private val userListDataSource: UserListDataSource, private val userRepository: UserRepository,
private val dataStore: CreateRoomDataStore, private val dataStore: CreateRoomDataStore,
) : Presenter<UserListState> { ) : Presenter<UserListState> {
@ -37,10 +36,8 @@ class AddPeoplePresenter @Inject constructor(
userListPresenterFactory.create( userListPresenterFactory.create(
UserListPresenterArgs( UserListPresenterArgs(
selectionMode = SelectionMode.Multiple, selectionMode = SelectionMode.Multiple,
minimumSearchLength = 3,
searchDebouncePeriodMillis = UserListPresenterArgs.DEFAULT_DEBOUNCE
), ),
userListDataSource, userRepository,
dataStore.selectedUserListDataStore, dataStore.selectedUserListDataStore,
) )
} }

View file

@ -17,11 +17,11 @@
package io.element.android.features.createroom.impl.addpeople package io.element.android.features.createroom.impl.addpeople
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.userlist.api.SelectionMode import io.element.android.features.createroom.impl.userlist.SelectionMode
import io.element.android.features.userlist.api.UserListState import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.features.userlist.api.UserSearchResultState import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers
import io.element.android.features.userlist.api.aListOfSelectedUsers import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.features.userlist.api.aUserListState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -30,13 +30,13 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListSta
get() = sequenceOf( get() = sequenceOf(
aUserListState(), aUserListState(),
aUserListState().copy( aUserListState().copy(
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()), searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
selectedUsers = aListOfSelectedUsers(), selectedUsers = aListOfSelectedUsers(),
isSearchActive = false, isSearchActive = false,
selectionMode = SelectionMode.Multiple, selectionMode = SelectionMode.Multiple,
), ),
aUserListState().copy( aUserListState().copy(
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()), searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
selectedUsers = aListOfSelectedUsers(), selectedUsers = aListOfSelectedUsers(),
isSearchActive = true, isSearchActive = true,
selectionMode = SelectionMode.Multiple, selectionMode = SelectionMode.Multiple,

View file

@ -17,6 +17,8 @@
package io.element.android.features.createroom.impl.addpeople package io.element.android.features.createroom.impl.addpeople
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -30,8 +32,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import io.element.android.features.createroom.impl.R import io.element.android.features.createroom.impl.R
import io.element.android.features.userlist.api.UserListState import io.element.android.features.createroom.impl.components.UserListView
import io.element.android.features.userlist.api.components.UserListView import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@ -41,7 +43,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun AddPeopleView( fun AddPeopleView(
state: UserListState, state: UserListState,
@ -50,6 +52,7 @@ fun AddPeopleView(
onNextPressed: () -> Unit = {}, onNextPressed: () -> Unit = {},
) { ) {
Scaffold( Scaffold(
modifier = modifier,
topBar = { topBar = {
if (!state.isSearchActive) { if (!state.isSearchActive) {
AddPeopleViewTopBar( AddPeopleViewTopBar(
@ -61,12 +64,14 @@ fun AddPeopleView(
} }
) { padding -> ) { padding ->
Column( Column(
modifier = modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding), .padding(padding)
.consumeWindowInsets(padding),
) { ) {
UserListView( UserListView(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth(),
state = state, state = state,
) )
} }

View file

@ -54,6 +54,7 @@ fun LabelledTextField(
value = value, value = value,
placeholder = { Text(placeholder) }, placeholder = { Text(placeholder) },
onValueChange = onValueChange, onValueChange = onValueChange,
singleLine = maxLines == 1,
maxLines = maxLines, maxLines = maxLines,
) )
} }

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.userlist.api.components package io.element.android.features.createroom.impl.components
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.userlist.api.components package io.element.android.features.createroom.impl.components
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable

View file

@ -0,0 +1,95 @@
/*
* 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.features.createroom.impl.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import io.element.android.libraries.ui.strings.R
import kotlinx.collections.immutable.ImmutableList
@Composable
fun SearchUserBar(
query: String,
state: SearchBarResultState<ImmutableList<MatrixUser>>,
selectedUsers: ImmutableList<MatrixUser>,
active: Boolean,
isMultiSelectionEnabled: Boolean,
modifier: Modifier = Modifier,
placeHolderTitle: String = stringResource(R.string.common_search_for_someone),
onActiveChanged: (Boolean) -> Unit = {},
onTextChanged: (String) -> Unit = {},
onUserSelected: (MatrixUser) -> Unit = {},
onUserDeselected: (MatrixUser) -> Unit = {},
) {
SearchBar(
query = query,
onQueryChange = onTextChanged,
active = active,
onActiveChange = onActiveChanged,
modifier = modifier,
placeHolderTitle = placeHolderTitle,
contentPrefix = {
if (isMultiSelectionEnabled && active && selectedUsers.isNotEmpty()) {
SelectedUsersList(
contentPadding = PaddingValues(16.dp),
selectedUsers = selectedUsers,
autoScroll = true,
onUserRemoved = onUserDeselected,
)
}
},
resultState = state,
resultHandler = { users ->
LazyColumn {
if (isMultiSelectionEnabled) {
items(users) { matrixUser ->
SearchMultipleUsersResultItem(
modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser,
isUserSelected = selectedUsers.find { it.userId == matrixUser.userId } != null,
onCheckedChange = { checked ->
if (checked) {
onUserSelected(matrixUser)
} else {
onUserDeselected(matrixUser)
}
}
)
}
} else {
items(users) { matrixUser ->
SearchSingleUserResultItem(
modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser,
onClick = { onUserSelected(matrixUser) }
)
}
}
}
},
)
}

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.userlist.api.components package io.element.android.features.createroom.impl.components
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@ -24,12 +24,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.userlist.api.UserListEvents import io.element.android.features.createroom.impl.userlist.UserListEvents
import io.element.android.features.userlist.api.UserListState import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.features.userlist.api.UserListStateProvider import io.element.android.features.createroom.impl.userlist.UserListStateProvider
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
@Composable @Composable
fun UserListView( fun UserListView(

View file

@ -16,16 +16,16 @@
package io.element.android.features.createroom.impl.configureroom package io.element.android.features.createroom.impl.configureroom
import android.net.Uri
import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface ConfigureRoomEvents { sealed interface ConfigureRoomEvents {
data class RoomNameChanged(val name: String) : ConfigureRoomEvents data class RoomNameChanged(val name: String) : ConfigureRoomEvents
data class TopicChanged(val topic: String) : ConfigureRoomEvents data class TopicChanged(val topic: String) : ConfigureRoomEvents
data class AvatarUriChanged(val uri: Uri?) : ConfigureRoomEvents data class RoomPrivacyChanged(val privacy: RoomPrivacy) : ConfigureRoomEvents
data class RoomPrivacyChanged(val privacy: RoomPrivacy?) : ConfigureRoomEvents
data class RemoveFromSelection(val matrixUser: MatrixUser) : ConfigureRoomEvents data class RemoveFromSelection(val matrixUser: MatrixUser) : ConfigureRoomEvents
data class CreateRoom(val config: CreateRoomConfig) : ConfigureRoomEvents data class CreateRoom(val config: CreateRoomConfig) : ConfigureRoomEvents
data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents
object CancelCreateRoom : ConfigureRoomEvents object CancelCreateRoom : ConfigureRoomEvents
} }

View file

@ -16,6 +16,7 @@
package io.element.android.features.createroom.impl.configureroom package io.element.android.features.createroom.impl.configureroom
import android.net.Uri
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -26,14 +27,19 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute import io.element.android.libraries.architecture.execute
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.createroom.RoomPreset import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.createroom.RoomVisibility import io.element.android.libraries.matrix.api.createroom.RoomVisibility
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -41,14 +47,28 @@ import javax.inject.Inject
class ConfigureRoomPresenter @Inject constructor( class ConfigureRoomPresenter @Inject constructor(
private val dataStore: CreateRoomDataStore, private val dataStore: CreateRoomDataStore,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor,
) : Presenter<ConfigureRoomState> { ) : Presenter<ConfigureRoomState> {
@Composable @Composable
override fun present(): ConfigureRoomState { override fun present(): ConfigureRoomState {
val createRoomConfig = dataStore.getCreateRoomConfig().collectAsState(CreateRoomConfig()) val createRoomConfig = dataStore.getCreateRoomConfig().collectAsState(CreateRoomConfig())
val isCreateButtonEnabled by remember(createRoomConfig.value.roomName, createRoomConfig.value.privacy) {
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
onResult = { uri -> if (uri != null) dataStore.setAvatarUri(uri = uri, cached = true) },
)
val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
onResult = { uri -> if (uri != null) dataStore.setAvatarUri(uri = uri) }
)
val avatarActions by remember(createRoomConfig.value.avatarUri) {
derivedStateOf { derivedStateOf {
createRoomConfig.value.roomName.isNullOrEmpty().not() && createRoomConfig.value.privacy != null listOfNotNull(
AvatarAction.TakePhoto,
AvatarAction.ChoosePhoto,
AvatarAction.Remove.takeIf { createRoomConfig.value.avatarUri != null },
).toImmutableList()
} }
} }
@ -62,26 +82,37 @@ class ConfigureRoomPresenter @Inject constructor(
fun handleEvents(event: ConfigureRoomEvents) { fun handleEvents(event: ConfigureRoomEvents) {
when (event) { when (event) {
is ConfigureRoomEvents.AvatarUriChanged -> dataStore.setAvatarUrl(event.uri?.toString())
is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name) is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name)
is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic) is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic)
is ConfigureRoomEvents.RoomPrivacyChanged -> dataStore.setPrivacy(event.privacy) is ConfigureRoomEvents.RoomPrivacyChanged -> dataStore.setPrivacy(event.privacy)
is ConfigureRoomEvents.RemoveFromSelection -> dataStore.selectedUserListDataStore.removeUserFromSelection(event.matrixUser) is ConfigureRoomEvents.RemoveFromSelection -> dataStore.selectedUserListDataStore.removeUserFromSelection(event.matrixUser)
is ConfigureRoomEvents.CreateRoom -> createRoom(event.config) is ConfigureRoomEvents.CreateRoom -> createRoom(event.config)
is ConfigureRoomEvents.HandleAvatarAction -> {
when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
AvatarAction.TakePhoto -> cameraPhotoPicker.launch()
AvatarAction.Remove -> dataStore.setAvatarUri(uri = null)
}
}
ConfigureRoomEvents.CancelCreateRoom -> createRoomAction.value = Async.Uninitialized ConfigureRoomEvents.CancelCreateRoom -> createRoomAction.value = Async.Uninitialized
} }
} }
return ConfigureRoomState( return ConfigureRoomState(
config = createRoomConfig.value, config = createRoomConfig.value,
isCreateButtonEnabled = isCreateButtonEnabled, avatarActions = avatarActions,
createRoomAction = createRoomAction.value, createRoomAction = createRoomAction.value,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
private fun CoroutineScope.createRoom(config: CreateRoomConfig, createRoomAction: MutableState<Async<RoomId>>) = launch { private fun CoroutineScope.createRoom(
config: CreateRoomConfig,
createRoomAction: MutableState<Async<RoomId>>
) = launch {
suspend { suspend {
val avatarUrl = config.avatarUri?.let { uploadAvatar(it) }
val params = CreateRoomParameters( val params = CreateRoomParameters(
name = config.roomName, name = config.roomName,
topic = config.topic, topic = config.topic,
@ -90,9 +121,16 @@ class ConfigureRoomPresenter @Inject constructor(
visibility = if (config.privacy == RoomPrivacy.Public) RoomVisibility.PUBLIC else RoomVisibility.PRIVATE, visibility = if (config.privacy == RoomPrivacy.Public) RoomVisibility.PUBLIC else RoomVisibility.PRIVATE,
preset = if (config.privacy == RoomPrivacy.Public) RoomPreset.PUBLIC_CHAT else RoomPreset.PRIVATE_CHAT, preset = if (config.privacy == RoomPrivacy.Public) RoomPreset.PUBLIC_CHAT else RoomPreset.PRIVATE_CHAT,
invite = config.invites.map { it.userId }, invite = config.invites.map { it.userId },
avatar = config.avatarUrl, avatar = avatarUrl,
) )
matrixClient.createRoom(params).getOrThrow() matrixClient.createRoom(params).getOrThrow()
.also { dataStore.clearCachedData() }
}.execute(createRoomAction) }.execute(createRoomAction)
} }
private suspend fun uploadAvatar(avatarUri: Uri): String {
val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow()
val byteArray = preprocessed.file.readBytes()
return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray).getOrThrow()
}
} }

View file

@ -17,12 +17,16 @@
package io.element.android.features.createroom.impl.configureroom package io.element.android.features.createroom.impl.configureroom
import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList
data class ConfigureRoomState( data class ConfigureRoomState(
val config: CreateRoomConfig, val config: CreateRoomConfig,
val isCreateButtonEnabled: Boolean, val avatarActions: ImmutableList<AvatarAction>,
val createRoomAction: Async<RoomId>, val createRoomAction: Async<RoomId>,
val eventSink: (ConfigureRoomEvents) -> Unit val eventSink: (ConfigureRoomEvents) -> Unit
) ) {
val isCreateButtonEnabled: Boolean = config.roomName.isNullOrEmpty().not()
}

View file

@ -18,8 +18,9 @@ package io.element.android.features.createroom.impl.configureroom
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.userlist.api.aListOfSelectedUsers import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import kotlinx.collections.immutable.persistentListOf
open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> { open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> {
override val values: Sequence<ConfigureRoomState> override val values: Sequence<ConfigureRoomState>
@ -30,16 +31,15 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
roomName = "Room 101", roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines", topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
invites = aListOfSelectedUsers(), invites = aListOfSelectedUsers(),
privacy = RoomPrivacy.Private, privacy = RoomPrivacy.Public,
), ),
isCreateButtonEnabled = true,
), ),
) )
} }
fun aConfigureRoomState() = ConfigureRoomState( fun aConfigureRoomState() = ConfigureRoomState(
config = CreateRoomConfig(), config = CreateRoomConfig(),
isCreateButtonEnabled = false, avatarActions = persistentListOf(),
createRoomAction = Async.Uninitialized, createRoomAction = Async.Uninitialized,
eventSink = {} eventSink = { },
) )

View file

@ -14,37 +14,42 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.createroom.impl.configureroom package io.element.android.features.createroom.impl.configureroom
import android.net.Uri import android.net.Uri
import android.widget.Toast import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import io.element.android.features.createroom.impl.R import io.element.android.features.createroom.impl.R
import io.element.android.features.createroom.impl.components.Avatar import io.element.android.features.createroom.impl.components.Avatar
import io.element.android.features.createroom.impl.components.LabelledTextField import io.element.android.features.createroom.impl.components.LabelledTextField
import io.element.android.features.createroom.impl.components.RoomPrivacyOption import io.element.android.features.createroom.impl.components.RoomPrivacyOption
import io.element.android.features.userlist.api.components.SelectedUsersList import io.element.android.features.createroom.impl.configureroom.avatar.AvatarActionListView
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
@ -56,8 +61,11 @@ 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.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import kotlinx.coroutines.launch
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterialApi::class)
@Composable @Composable
fun ConfigureRoomView( fun ConfigureRoomView(
state: ConfigureRoomState, state: ConfigureRoomState,
@ -65,59 +73,96 @@ fun ConfigureRoomView(
onBackPressed: () -> Unit = {}, onBackPressed: () -> Unit = {},
onRoomCreated: (RoomId) -> Unit = {}, onRoomCreated: (RoomId) -> Unit = {},
) { ) {
val coroutineScope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val itemActionsBottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
)
if (state.createRoomAction is Async.Success) { if (state.createRoomAction is Async.Success) {
LaunchedEffect(state.createRoomAction) { LaunchedEffect(state.createRoomAction) {
onRoomCreated(state.createRoomAction.state) onRoomCreated(state.createRoomAction.state)
} }
} }
val context = LocalContext.current fun onAvatarClicked() {
focusManager.clearFocus()
coroutineScope.launch {
itemActionsBottomSheetState.show()
}
}
Scaffold( Scaffold(
modifier = modifier, modifier = modifier.clearFocusOnTap(focusManager),
topBar = { topBar = {
ConfigureRoomToolbar( ConfigureRoomToolbar(
isNextActionEnabled = state.isCreateButtonEnabled, isNextActionEnabled = state.isCreateButtonEnabled,
onBackPressed = onBackPressed, onBackPressed = onBackPressed,
onNextPressed = { onNextPressed = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) state.eventSink(ConfigureRoomEvents.CreateRoom(state.config))
}, },
) )
} }
) { padding -> ) { padding ->
Column( LazyColumn(
modifier = Modifier.padding(padding), modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp),
) { ) {
RoomNameWithAvatar( item {
modifier = Modifier.padding(horizontal = 16.dp), RoomNameWithAvatar(
avatarUri = state.config.avatarUrl?.toUri(), modifier = Modifier.padding(horizontal = 16.dp),
roomName = state.config.roomName.orEmpty(), avatarUri = state.config.avatarUri,
onAvatarClick = { Toast.makeText(context, "not implemented yet", Toast.LENGTH_SHORT).show() }, roomName = state.config.roomName.orEmpty(),
onRoomNameChanged = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) }, onAvatarClick = ::onAvatarClicked,
) onRoomNameChanged = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) },
RoomTopic( )
modifier = Modifier.padding(horizontal = 16.dp), }
topic = state.config.topic.orEmpty(), item {
onTopicChanged = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) }, RoomTopic(
) modifier = Modifier.padding(horizontal = 16.dp),
SelectedUsersList( topic = state.config.topic.orEmpty(),
contentPadding = PaddingValues(horizontal = 24.dp), onTopicChanged = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
selectedUsers = state.config.invites, )
onUserRemoved = { state.eventSink(ConfigureRoomEvents.RemoveFromSelection(it)) }, }
) if (state.config.invites.isNotEmpty()) {
Spacer(Modifier.weight(1f)) item {
RoomPrivacyOptions( SelectedUsersList(
modifier = Modifier.padding(bottom = 40.dp), contentPadding = PaddingValues(horizontal = 24.dp),
selected = state.config.privacy, selectedUsers = state.config.invites,
onOptionSelected = { state.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(it.privacy)) }, onUserRemoved = {
) focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RemoveFromSelection(it))
},
)
}
}
item {
RoomPrivacyOptions(
modifier = Modifier.padding(bottom = 40.dp),
selected = state.config.privacy,
onOptionSelected = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(it.privacy))
},
)
}
} }
} }
AvatarActionListView(
actions = state.avatarActions,
modalBottomSheetState = itemActionsBottomSheetState,
onActionSelected = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) }
)
when (state.createRoomAction) { when (state.createRoomAction) {
is Async.Loading -> { is Async.Loading -> {
ProgressDialog(text = stringResource(StringR.string.common_creating_room)) ProgressDialog(text = stringResource(StringR.string.common_creating_room))
} }
is Async.Failure -> { is Async.Failure -> {
RetryDialog( RetryDialog(
content = stringResource(R.string.screen_create_room_error_creating_room), content = stringResource(R.string.screen_create_room_error_creating_room),
@ -125,10 +170,12 @@ fun ConfigureRoomView(
onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) }, onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) },
) )
} }
else -> Unit else -> Unit
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ConfigureRoomToolbar( fun ConfigureRoomToolbar(
isNextActionEnabled: Boolean, isNextActionEnabled: Boolean,
@ -238,3 +285,11 @@ private fun ContentToPreview(state: ConfigureRoomState) {
state = state, state = state,
) )
} }
private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
pointerInput(Unit) {
detectTapGestures(onTap = {
focusManager.clearFocus()
})
}

View file

@ -0,0 +1,37 @@
/*
* 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.features.createroom.impl.configureroom.avatar
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.PhotoCamera
import androidx.compose.material.icons.outlined.PhotoLibrary
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.vector.ImageVector
import io.element.android.libraries.ui.strings.R
@Immutable
sealed class AvatarAction(
@StringRes val titleResId: Int,
val icon: ImageVector,
val destructive: Boolean = false,
) {
object TakePhoto : AvatarAction(titleResId = R.string.action_take_photo, icon = Icons.Outlined.PhotoCamera)
object ChoosePhoto : AvatarAction(titleResId = R.string.action_choose_photo, icon = Icons.Outlined.PhotoLibrary)
object Remove : AvatarAction(titleResId = R.string.action_remove, icon = Icons.Outlined.Delete, destructive = true)
}

View file

@ -0,0 +1,126 @@
/*
* 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.
*/
@file:OptIn(ExperimentalMaterialApi::class)
package io.element.android.features.createroom.impl.configureroom.avatar
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.Text
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
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.ModalBottomSheetLayout
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
@Composable
fun AvatarActionListView(
actions: ImmutableList<AvatarAction>,
modalBottomSheetState: ModalBottomSheetState,
modifier: Modifier = Modifier,
onActionSelected: (action: AvatarAction) -> Unit = {},
) {
val coroutineScope = rememberCoroutineScope()
fun onItemActionClicked(itemAction: AvatarAction) {
onActionSelected(itemAction)
coroutineScope.launch {
modalBottomSheetState.hide()
}
}
ModalBottomSheetLayout(
modifier = modifier,
sheetState = modalBottomSheetState,
sheetContent = {
SheetContent(
actions = actions,
onActionClicked = ::onItemActionClicked,
modifier = Modifier
.navigationBarsPadding()
.imePadding()
)
}
)
}
@Composable
private fun SheetContent(
actions: ImmutableList<AvatarAction>,
modifier: Modifier = Modifier,
onActionClicked: (AvatarAction) -> Unit = { },
) {
LazyColumn(
modifier = modifier.fillMaxWidth()
) {
items(
items = actions,
) { action ->
ListItem(
modifier = Modifier.clickable { onActionClicked(action) },
headlineContent = {
Text(
text = stringResource(action.titleResId),
color = if (action.destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
},
leadingContent = {
Icon(
imageVector = action.icon,
contentDescription = stringResource(action.titleResId),
tint = if (action.destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
}
)
}
}
}
@Preview
@Composable
fun SheetContentLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun SheetContentDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
AvatarActionListView(
actions = persistentListOf(AvatarAction.TakePhoto, AvatarAction.ChoosePhoto, AvatarAction.Remove),
modalBottomSheetState = ModalBottomSheetState(
initialValue = ModalBottomSheetValue.Expanded
),
)
}

View file

@ -21,25 +21,24 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.userlist.api.SelectionMode import io.element.android.features.createroom.impl.userlist.SelectionMode
import io.element.android.features.userlist.api.UserListDataSource import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.features.userlist.api.UserListDataStore import io.element.android.features.createroom.impl.userlist.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenter import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs
import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute import io.element.android.libraries.architecture.execute
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserRepository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Named
class CreateRoomRootPresenter @Inject constructor( class CreateRoomRootPresenter @Inject constructor(
private val presenterFactory: UserListPresenter.Factory, private val presenterFactory: UserListPresenter.Factory,
@Named("AllUsers") private val userListDataSource: UserListDataSource, private val userRepository: UserRepository,
private val userListDataStore: UserListDataStore, private val userListDataStore: UserListDataStore,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
) : Presenter<CreateRoomRootState> { ) : Presenter<CreateRoomRootState> {
@ -48,10 +47,8 @@ class CreateRoomRootPresenter @Inject constructor(
presenterFactory.create( presenterFactory.create(
UserListPresenterArgs( UserListPresenterArgs(
selectionMode = SelectionMode.Single, selectionMode = SelectionMode.Single,
minimumSearchLength = 3,
searchDebouncePeriodMillis = UserListPresenterArgs.DEFAULT_DEBOUNCE,
), ),
userListDataSource, userRepository,
userListDataStore, userListDataStore,
) )
} }

View file

@ -16,7 +16,7 @@
package io.element.android.features.createroom.impl.root package io.element.android.features.createroom.impl.root
import io.element.android.features.userlist.api.UserListState import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId

View file

@ -17,9 +17,10 @@
package io.element.android.features.createroom.impl.root package io.element.android.features.createroom.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.userlist.api.UserSearchResultState import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.features.userlist.api.aUserListState
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -32,7 +33,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
userListState = aMatrixUser().let { userListState = aMatrixUser().let {
aUserListState().copy( aUserListState().copy(
searchQuery = it.userId.value, searchQuery = it.userId.value,
searchResults = UserSearchResultState.Results(persistentListOf(it)), searchResults = SearchBarResultState.Results(persistentListOf(it)),
selectedUsers = persistentListOf(it), selectedUsers = persistentListOf(it),
isSearchActive = true, isSearchActive = true,
) )
@ -43,7 +44,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
userListState = aMatrixUser().let { userListState = aMatrixUser().let {
aUserListState().copy( aUserListState().copy(
searchQuery = it.userId.value, searchQuery = it.userId.value,
searchResults = UserSearchResultState.Results(persistentListOf(it)), searchResults = SearchBarResultState.Results(persistentListOf(it)),
selectedUsers = persistentListOf(it), selectedUsers = persistentListOf(it),
isSearchActive = true, isSearchActive = true,
) )

View file

@ -20,7 +20,9 @@ import androidx.annotation.DrawableRes
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -32,7 +34,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@ -40,7 +41,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import io.element.android.features.createroom.impl.R import io.element.android.features.createroom.impl.R
import io.element.android.features.userlist.api.components.UserListView import io.element.android.features.createroom.impl.components.UserListView
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
@ -55,7 +56,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.designsystem.R as DrawableR import io.element.android.libraries.designsystem.R as DrawableR
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun CreateRoomRootView( fun CreateRoomRootView(
state: CreateRoomRootState, state: CreateRoomRootState,
@ -79,10 +80,11 @@ fun CreateRoomRootView(
} }
) { paddingValues -> ) { paddingValues ->
Column( Column(
modifier = Modifier.padding(paddingValues), modifier = Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
val context = LocalContext.current
UserListView( UserListView(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
state = state.userListState, state = state.userListState,
@ -102,7 +104,7 @@ fun CreateRoomRootView(
when (state.startDmAction) { when (state.startDmAction) {
is Async.Loading -> { is Async.Loading -> {
ProgressDialog(text = stringResource(id = StringR.string.common_creating_room)) ProgressDialog(text = stringResource(id = StringR.string.common_starting_chat))
} }
is Async.Failure -> { is Async.Failure -> {

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.userlist.impl package io.element.android.features.createroom.impl.userlist
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -28,24 +28,16 @@ import com.squareup.anvil.annotations.ContributesBinding
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.userlist.api.UserListDataSource import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.features.userlist.api.UserListDataStore
import io.element.android.features.userlist.api.UserListEvents
import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.features.userlist.api.UserListState
import io.element.android.features.userlist.api.UserSearchResultState
import io.element.android.libraries.di.SessionScope 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.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.persistentListOf import io.element.android.libraries.usersearch.api.UserRepository
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
class DefaultUserListPresenter @AssistedInject constructor( class DefaultUserListPresenter @AssistedInject constructor(
@Assisted val args: UserListPresenterArgs, @Assisted val args: UserListPresenterArgs,
@Assisted val userListDataSource: UserListDataSource, @Assisted val userRepository: UserRepository,
@Assisted val userListDataStore: UserListDataStore, @Assisted val userListDataStore: UserListDataStore,
) : UserListPresenter { ) : UserListPresenter {
@ -54,7 +46,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
interface DefaultUserListFactory : UserListPresenter.Factory { interface DefaultUserListFactory : UserListPresenter.Factory {
override fun create( override fun create(
args: UserListPresenterArgs, args: UserListPresenterArgs,
userListDataSource: UserListDataSource, userRepository: UserRepository,
userListDataStore: UserListDataStore, userListDataStore: UserListDataStore,
): DefaultUserListPresenter ): DefaultUserListPresenter
} }
@ -64,24 +56,18 @@ class DefaultUserListPresenter @AssistedInject constructor(
var isSearchActive by rememberSaveable { mutableStateOf(false) } var isSearchActive by rememberSaveable { mutableStateOf(false) }
val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList()) val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
var searchQuery by rememberSaveable { mutableStateOf("") } var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults: UserSearchResultState by remember { var searchResults: SearchBarResultState<ImmutableList<MatrixUser>> by remember {
mutableStateOf(UserSearchResultState.NotSearching) mutableStateOf(SearchBarResultState.NotSearching())
} }
LaunchedEffect(searchQuery) { LaunchedEffect(searchQuery) {
// Clear the search results before performing the search, manually add a fake result with the matrixId, if any searchResults = SearchBarResultState.NotSearching()
searchResults = if (MatrixPatterns.isUserId(searchQuery)) {
UserSearchResultState.Results(persistentListOf(MatrixUser(UserId(searchQuery))))
} else {
UserSearchResultState.NotSearching
}
// Debounce userRepository.search(searchQuery).collect {
delay(args.searchDebouncePeriodMillis) searchResults = when {
it.isEmpty() -> SearchBarResultState.NoResults()
// Perform the search asynchronously else -> SearchBarResultState.Results(it.toImmutableList())
if (searchQuery.length >= args.minimumSearchLength) { }
searchResults = performSearch(searchQuery)
} }
} }
@ -101,15 +87,4 @@ class DefaultUserListPresenter @AssistedInject constructor(
}, },
) )
} }
private suspend fun performSearch(query: String): UserSearchResultState {
val isMatrixId = MatrixPatterns.isUserId(query)
val results = userListDataSource.search(query).toMutableList()
if (isMatrixId && results.none { it.userId.value == query }) {
val getProfileResult: MatrixUser? = userListDataSource.getProfile(UserId(query))
val profile = getProfileResult ?: MatrixUser(UserId(query))
results.add(0, profile)
}
return if (results.isEmpty()) UserSearchResultState.NoResults else UserSearchResultState.Results(results.toImmutableList())
}
} }

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.userlist.api package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.userlist.api package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser

View file

@ -14,16 +14,17 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.userlist.api package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.usersearch.api.UserRepository
interface UserListPresenter : Presenter<UserListState> { interface UserListPresenter : Presenter<UserListState> {
interface Factory { interface Factory {
fun create( fun create(
args: UserListPresenterArgs, args: UserListPresenterArgs,
userListDataSource: UserListDataSource, userRepository: UserRepository,
userListDataStore: UserListDataStore, userListDataStore: UserListDataStore,
): UserListPresenter ): UserListPresenter
} }

View file

@ -14,18 +14,11 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.userlist.api package io.element.android.features.createroom.impl.userlist
data class UserListPresenterArgs( data class UserListPresenterArgs(
val selectionMode: SelectionMode, val selectionMode: SelectionMode,
val minimumSearchLength: Int = 1, )
val searchDebouncePeriodMillis: Long = NO_DEBOUNCE,
) {
companion object {
const val NO_DEBOUNCE = 0L
const val DEFAULT_DEBOUNCE = 500L
}
}
enum class SelectionMode { enum class SelectionMode {
Single, Single,

View file

@ -14,14 +14,15 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.userlist.api package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class UserListState( data class UserListState(
val searchQuery: String, val searchQuery: String,
val searchResults: UserSearchResultState, val searchResults: SearchBarResultState<ImmutableList<MatrixUser>>,
val selectedUsers: ImmutableList<MatrixUser>, val selectedUsers: ImmutableList<MatrixUser>,
val isSearchActive: Boolean, val isSearchActive: Boolean,
val selectionMode: SelectionMode, val selectionMode: SelectionMode,
@ -29,14 +30,3 @@ data class UserListState(
) { ) {
val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple
} }
sealed interface UserSearchResultState {
/** No search results are available yet (e.g. because the user hasn't entered a (long enough) search term). */
object NotSearching : UserSearchResultState
/** The search has completed, but no results were found. */
object NoResults : UserSearchResultState
/** The search has completed, and some matching users were found. */
data class Results(val results: ImmutableList<MatrixUser>) : UserSearchResultState
}

View file

@ -14,9 +14,10 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.userlist.api package io.element.android.features.createroom.impl.userlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -37,19 +38,19 @@ open class UserListStateProvider : PreviewParameterProvider<UserListState> {
isSearchActive = true, isSearchActive = true,
searchQuery = "@someone:matrix.org", searchQuery = "@someone:matrix.org",
selectedUsers = aListOfSelectedUsers(), selectedUsers = aListOfSelectedUsers(),
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()), searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
), ),
aUserListState().copy( aUserListState().copy(
isSearchActive = true, isSearchActive = true,
searchQuery = "@someone:matrix.org", searchQuery = "@someone:matrix.org",
selectionMode = SelectionMode.Multiple, selectionMode = SelectionMode.Multiple,
selectedUsers = aListOfSelectedUsers(), selectedUsers = aListOfSelectedUsers(),
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()), searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
), ),
aUserListState().copy( aUserListState().copy(
isSearchActive = true, isSearchActive = true,
searchQuery = "something-with-no-results", searchQuery = "something-with-no-results",
searchResults = UserSearchResultState.NoResults searchResults = SearchBarResultState.NoResults()
), ),
) )
} }
@ -57,7 +58,7 @@ open class UserListStateProvider : PreviewParameterProvider<UserListState> {
fun aUserListState() = UserListState( fun aUserListState() = UserListState(
isSearchActive = false, isSearchActive = false,
searchQuery = "", searchQuery = "",
searchResults = UserSearchResultState.NotSearching, searchResults = SearchBarResultState.NotSearching(),
selectedUsers = persistentListOf(), selectedUsers = persistentListOf(),
selectionMode = SelectionMode.Single, selectionMode = SelectionMode.Single,
eventSink = {} eventSink = {}

View file

@ -21,9 +21,9 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.impl.CreateRoomDataStore import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.userlist.api.UserListDataStore import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
import io.element.android.features.userlist.test.FakeUserListDataSource import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.features.userlist.test.FakeUserListPresenterFactory import io.element.android.libraries.usersearch.test.FakeUserRepository
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@ -34,7 +34,11 @@ class AddPeoplePresenterTests {
@Before @Before
fun setup() { fun setup() {
presenter = AddPeoplePresenter(FakeUserListPresenterFactory(), FakeUserListDataSource(), CreateRoomDataStore(UserListDataStore())) presenter = AddPeoplePresenter(
FakeUserListPresenterFactory(),
FakeUserRepository(),
CreateRoomDataStore(UserListDataStore())
)
} }
@Test @Test
@ -42,6 +46,7 @@ class AddPeoplePresenterTests {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
// TODO This doesn't actually test anything...
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState) assertThat(initialState)
} }

View file

@ -23,7 +23,8 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.userlist.api.UserListDataStore import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_AVATAR_URL
@ -32,29 +33,57 @@ import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import java.io.File
private const val AN_URI_FROM_CAMERA = "content://uri_from_camera"
private const val AN_URI_FROM_GALLERY = "content://uri_from_gallery"
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class ConfigureRoomPresenterTests { class ConfigureRoomPresenterTests {
private lateinit var presenter: ConfigureRoomPresenter private lateinit var presenter: ConfigureRoomPresenter
private lateinit var userListDataStore: UserListDataStore private lateinit var userListDataStore: UserListDataStore
private lateinit var createRoomDataStore: CreateRoomDataStore
private lateinit var fakeMatrixClient: FakeMatrixClient private lateinit var fakeMatrixClient: FakeMatrixClient
private lateinit var fakePickerProvider: FakePickerProvider
private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor
@Before @Before
fun setup() { fun setup() {
fakeMatrixClient = FakeMatrixClient() fakeMatrixClient = FakeMatrixClient()
userListDataStore = UserListDataStore() userListDataStore = UserListDataStore()
createRoomDataStore = CreateRoomDataStore(userListDataStore)
fakePickerProvider = FakePickerProvider()
fakeMediaPreProcessor = FakeMediaPreProcessor()
presenter = ConfigureRoomPresenter( presenter = ConfigureRoomPresenter(
dataStore = CreateRoomDataStore(userListDataStore), dataStore = createRoomDataStore,
matrixClient = fakeMatrixClient matrixClient = fakeMatrixClient,
mediaPickerProvider = fakePickerProvider,
mediaPreProcessor = fakeMediaPreProcessor,
) )
mockkStatic(File::readBytes)
every { any<File>().readBytes() } returns byteArrayOf()
}
@After
fun tearDown() {
unmockkAll()
} }
@Test @Test
@ -67,8 +96,8 @@ class ConfigureRoomPresenterTests {
assertThat(initialState.config.roomName).isNull() assertThat(initialState.config.roomName).isNull()
assertThat(initialState.config.topic).isNull() assertThat(initialState.config.topic).isNull()
assertThat(initialState.config.invites).isEmpty() assertThat(initialState.config.invites).isEmpty()
assertThat(initialState.config.avatarUrl).isNull() assertThat(initialState.config.avatarUri).isNull()
assertThat(initialState.config.privacy).isNull() assertThat(initialState.config.privacy).isEqualTo(RoomPrivacy.Private)
} }
} }
@ -86,13 +115,6 @@ class ConfigureRoomPresenterTests {
var newState: ConfigureRoomState = awaitItem() var newState: ConfigureRoomState = awaitItem()
config = config.copy(roomName = A_ROOM_NAME) config = config.copy(roomName = A_ROOM_NAME)
assertThat(newState.config).isEqualTo(config) assertThat(newState.config).isEqualTo(config)
assertThat(newState.isCreateButtonEnabled).isFalse()
// Select privacy
newState.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(RoomPrivacy.Private))
newState = awaitItem()
config = config.copy(privacy = RoomPrivacy.Private)
assertThat(newState.config).isEqualTo(config)
assertThat(newState.isCreateButtonEnabled).isTrue() assertThat(newState.isCreateButtonEnabled).isTrue()
// Clear room name // Clear room name
@ -136,10 +158,28 @@ class ConfigureRoomPresenterTests {
assertThat(newState.config).isEqualTo(expectedConfig) assertThat(newState.config).isEqualTo(expectedConfig)
// Room avatar // Room avatar
val anUri = Uri.parse(AN_AVATAR_URL) // Pick avatar
newState.eventSink(ConfigureRoomEvents.AvatarUriChanged(anUri)) fakePickerProvider.givenResult(null)
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
// From gallery
val uriFromGallery = Uri.parse(AN_URI_FROM_GALLERY)
fakePickerProvider.givenResult(uriFromGallery)
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
newState = awaitItem() newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUrl = anUri.toString()) expectedConfig = expectedConfig.copy(avatarUri = uriFromGallery)
assertThat(newState.config).isEqualTo(expectedConfig)
// From camera
val uriFromCamera = Uri.parse(AN_URI_FROM_CAMERA)
fakePickerProvider.givenResult(uriFromCamera)
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera)
assertThat(newState.config).isEqualTo(expectedConfig)
// Remove
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.Remove))
newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = null)
assertThat(newState.config).isEqualTo(expectedConfig) assertThat(newState.config).isEqualTo(expectedConfig)
// Room privacy // Room privacy
@ -174,6 +214,30 @@ class ConfigureRoomPresenterTests {
} }
} }
@Test
fun `present - trigger create room with upload error and retry`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
createRoomDataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY))
fakeMediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk())))
fakeMatrixClient.givenUploadMediaResult(Result.failure(A_THROWABLE))
val initialState = awaitItem()
initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
val stateAfterCreateRoom = awaitItem()
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(Async.Failure::class.java)
fakeMatrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL))
stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Uninitialized::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Success::class.java)
}
}
@Test @Test
fun `present - trigger retry and cancel actions`() = runTest { fun `present - trigger retry and cancel actions`() = runTest {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {

View file

@ -20,11 +20,10 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.userlist.api.UserListDataStore import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter
import io.element.android.features.userlist.api.aUserListState import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
import io.element.android.features.userlist.test.FakeUserListDataSource import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.features.userlist.test.FakeUserListPresenter import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.features.userlist.test.FakeUserListPresenterFactory
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -32,6 +31,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.usersearch.test.FakeUserRepository
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
@ -39,7 +39,7 @@ import org.junit.Test
class CreateRoomRootPresenterTests { class CreateRoomRootPresenterTests {
private lateinit var userListDataSource: FakeUserListDataSource private lateinit var userRepository: FakeUserRepository
private lateinit var presenter: CreateRoomRootPresenter private lateinit var presenter: CreateRoomRootPresenter
private lateinit var fakeUserListPresenter: FakeUserListPresenter private lateinit var fakeUserListPresenter: FakeUserListPresenter
private lateinit var fakeMatrixClient: FakeMatrixClient private lateinit var fakeMatrixClient: FakeMatrixClient
@ -48,8 +48,8 @@ class CreateRoomRootPresenterTests {
fun setup() { fun setup() {
fakeUserListPresenter = FakeUserListPresenter() fakeUserListPresenter = FakeUserListPresenter()
fakeMatrixClient = FakeMatrixClient() fakeMatrixClient = FakeMatrixClient()
userListDataSource = FakeUserListDataSource() userRepository = FakeUserRepository()
presenter = CreateRoomRootPresenter(FakeUserListPresenterFactory(fakeUserListPresenter), userListDataSource, UserListDataStore(), fakeMatrixClient) presenter = CreateRoomRootPresenter(FakeUserListPresenterFactory(fakeUserListPresenter), userRepository, UserListDataStore(), fakeMatrixClient)
} }
@Test @Test

View file

@ -14,77 +14,78 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.userlist.impl package io.element.android.features.createroom.impl.userlist
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.userlist.api.SelectionMode import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.features.userlist.api.UserListDataStore
import io.element.android.features.userlist.api.UserListEvents
import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.features.userlist.api.UserSearchResultState
import io.element.android.features.userlist.test.FakeUserListDataSource
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.test.FakeUserRepository
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class DefaultUserListPresenterTests { class DefaultUserListPresenterTests {
private val userListDataSource = FakeUserListDataSource() private val userRepository = FakeUserRepository()
@Test @Test
fun `present - initial state for single selection`() = runTest { fun `present - initial state for single selection`() = runTest {
val presenter = DefaultUserListPresenter( val presenter =
UserListPresenterArgs(selectionMode = SelectionMode.Single), DefaultUserListPresenter(
userListDataSource, UserListPresenterArgs(selectionMode = SelectionMode.Single),
UserListDataStore(), userRepository,
) UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.searchQuery).isEmpty() assertThat(initialState.searchQuery).isEmpty()
assertThat(initialState.isMultiSelectionEnabled).isFalse() assertThat(initialState.isMultiSelectionEnabled).isFalse()
assertThat(initialState.isSearchActive).isFalse() assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.selectedUsers).isEmpty() assertThat(initialState.selectedUsers).isEmpty()
assertThat(initialState.searchResults).isEqualTo(UserSearchResultState.NotSearching) assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java)
} }
} }
@Test @Test
fun `present - initial state for multiple selection`() = runTest { fun `present - initial state for multiple selection`() = runTest {
val presenter = DefaultUserListPresenter( val presenter =
UserListPresenterArgs(selectionMode = SelectionMode.Multiple), DefaultUserListPresenter(
userListDataSource, UserListPresenterArgs(selectionMode = SelectionMode.Multiple),
UserListDataStore(), userRepository,
) UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.searchQuery).isEmpty() assertThat(initialState.searchQuery).isEmpty()
assertThat(initialState.isMultiSelectionEnabled).isTrue() assertThat(initialState.isMultiSelectionEnabled).isTrue()
assertThat(initialState.isSearchActive).isFalse() assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.selectedUsers).isEmpty() assertThat(initialState.selectedUsers).isEmpty()
assertThat(initialState.searchResults).isEqualTo(UserSearchResultState.NotSearching) assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java)
} }
} }
@Test @Test
fun `present - update search query`() = runTest { fun `present - update search query`() = runTest {
val presenter = DefaultUserListPresenter( val presenter =
UserListPresenterArgs(selectionMode = SelectionMode.Single), DefaultUserListPresenter(
userListDataSource, UserListPresenterArgs(selectionMode = SelectionMode.Single),
UserListDataStore(), userRepository,
) UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(UserListEvents.OnSearchActiveChanged(true)) initialState.eventSink(UserListEvents.OnSearchActiveChanged(true))
@ -93,12 +94,14 @@ class DefaultUserListPresenterTests {
val matrixIdQuery = "@name:matrix.org" val matrixIdQuery = "@name:matrix.org"
initialState.eventSink(UserListEvents.UpdateSearchQuery(matrixIdQuery)) initialState.eventSink(UserListEvents.UpdateSearchQuery(matrixIdQuery))
assertThat(awaitItem().searchQuery).isEqualTo(matrixIdQuery) assertThat(awaitItem().searchQuery).isEqualTo(matrixIdQuery)
assertThat(awaitItem().searchResults).isEqualTo(UserSearchResultState.Results(persistentListOf(MatrixUser(UserId(matrixIdQuery))))) assertThat(userRepository.providedQuery).isEqualTo(matrixIdQuery)
skipItems(1)
val notMatrixIdQuery = "name" val notMatrixIdQuery = "name"
initialState.eventSink(UserListEvents.UpdateSearchQuery(notMatrixIdQuery)) initialState.eventSink(UserListEvents.UpdateSearchQuery(notMatrixIdQuery))
assertThat(awaitItem().searchQuery).isEqualTo(notMatrixIdQuery) assertThat(awaitItem().searchQuery).isEqualTo(notMatrixIdQuery)
assertThat(awaitItem().searchResults).isEqualTo(UserSearchResultState.NoResults) assertThat(userRepository.providedQuery).isEqualTo(notMatrixIdQuery)
skipItems(1)
initialState.eventSink(UserListEvents.OnSearchActiveChanged(false)) initialState.eventSink(UserListEvents.OnSearchActiveChanged(false))
assertThat(awaitItem().isSearchActive).isFalse() assertThat(awaitItem().isSearchActive).isFalse()
@ -106,39 +109,83 @@ class DefaultUserListPresenterTests {
} }
@Test @Test
fun `present - searches when minimum length exceeded`() = runTest { fun `present - presents search results`() = runTest {
val presenter = DefaultUserListPresenter( val presenter =
UserListPresenterArgs(selectionMode = SelectionMode.Single, minimumSearchLength = 3), DefaultUserListPresenter(
userListDataSource, UserListPresenterArgs(
UserListDataStore(), selectionMode = SelectionMode.Single,
) ),
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
// When the search term is too short, nothing happens
initialState.eventSink(UserListEvents.UpdateSearchQuery("al"))
assertThat(awaitItem().searchResults).isEqualTo(UserSearchResultState.NotSearching)
// When it reaches the minimum length, a search is performed asynchronously
userListDataSource.givenSearchResult(listOf(aMatrixUser()))
initialState.eventSink(UserListEvents.UpdateSearchQuery("alice")) initialState.eventSink(UserListEvents.UpdateSearchQuery("alice"))
assertThat(awaitItem().searchResults).isEqualTo(UserSearchResultState.NotSearching) assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java)
assertThat(awaitItem().searchResults).isEqualTo(UserSearchResultState.Results(persistentListOf(aMatrixUser()))) assertThat(userRepository.providedQuery).isEqualTo("alice")
skipItems(2)
// When the user repository emits a result, it's copied to the state
userRepository.emitResult(listOf(aMatrixUser()))
assertThat(awaitItem().searchResults).isEqualTo(
SearchBarResultState.Results(
persistentListOf(aMatrixUser())
)
)
// When the user repository emits another result, it replaces the previous value
userRepository.emitResult(aMatrixUserList())
assertThat(awaitItem().searchResults).isEqualTo(
SearchBarResultState.Results(
aMatrixUserList()
)
)
}
}
@Test
fun `present - presents search results when not found`() = runTest {
val presenter =
DefaultUserListPresenter(
UserListPresenterArgs(
selectionMode = SelectionMode.Single,
),
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(UserListEvents.UpdateSearchQuery("alice"))
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java)
assertThat(userRepository.providedQuery).isEqualTo("alice")
skipItems(2)
// When the results list is empty, the state is set to NoResults
userRepository.emitResult(emptyList())
assertThat(awaitItem().searchResults).isInstanceOf(SearchBarResultState.NoResults::class.java)
} }
} }
@Test @Test
fun `present - select a user`() = runTest { fun `present - select a user`() = runTest {
val presenter = DefaultUserListPresenter( val presenter =
UserListPresenterArgs(selectionMode = SelectionMode.Single), DefaultUserListPresenter(
userListDataSource, UserListPresenterArgs(selectionMode = SelectionMode.Single),
UserListDataStore(), userRepository,
) UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
val userA = aMatrixUser("@userA:domain", "A") val userA = aMatrixUser("@userA:domain", "A")

View file

@ -14,12 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.userlist.test package io.element.android.features.createroom.impl.userlist
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListState
import io.element.android.features.userlist.api.aUserListState
class FakeUserListPresenter : UserListPresenter { class FakeUserListPresenter : UserListPresenter {

View file

@ -14,12 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.userlist.test package io.element.android.features.createroom.impl.userlist
import io.element.android.features.userlist.api.UserListDataSource import io.element.android.libraries.usersearch.api.UserRepository
import io.element.android.features.userlist.api.UserListDataStore
import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenterArgs
class FakeUserListPresenterFactory( class FakeUserListPresenterFactory(
private val fakeUserListPresenter: FakeUserListPresenter = FakeUserListPresenter() private val fakeUserListPresenter: FakeUserListPresenter = FakeUserListPresenter()
@ -27,7 +24,7 @@ class FakeUserListPresenterFactory(
override fun create( override fun create(
args: UserListPresenterArgs, args: UserListPresenterArgs,
userListDataSource: UserListDataSource, userRepository: UserRepository,
userListDataStore: UserListDataStore, userListDataStore: UserListDataStore,
): UserListPresenter = fakeUserListPresenter ): UserListPresenter = fakeUserListPresenter
} }

View file

@ -17,7 +17,9 @@
package io.element.android.features.invitelist.impl package io.element.android.features.invitelist.impl
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -103,7 +105,7 @@ fun InviteListView(
} }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun InviteListContent( fun InviteListContent(
state: InviteListState, state: InviteListState,
@ -124,7 +126,9 @@ fun InviteListContent(
}, },
content = { padding -> content = { padding ->
Column( Column(
modifier = Modifier.padding(padding) modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
) { ) {
if (state.inviteList.isEmpty()) { if (state.inviteList.isEmpty()) {
Spacer(Modifier.size(80.dp)) Spacer(Modifier.size(80.dp))

View file

@ -20,7 +20,9 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -81,7 +83,7 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalTextApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalTextApi::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun ChangeServerView( fun ChangeServerView(
state: ChangeServerState, state: ChangeServerState,
@ -121,6 +123,7 @@ fun ChangeServerView(
.fillMaxSize() .fillMaxSize()
.imePadding() .imePadding()
.padding(padding) .padding(padding)
.consumeWindowInsets(padding)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier

View file

@ -21,8 +21,10 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -86,7 +88,7 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun LoginRootView( fun LoginRootView(
state: LoginRootState, state: LoginRootState,
@ -113,6 +115,7 @@ fun LoginRootView(
.fillMaxSize() .fillMaxSize()
.imePadding() .imePadding()
.padding(padding) .padding(padding)
.consumeWindowInsets(padding)
) { ) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()

View file

@ -14,19 +14,16 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(
ExperimentalMaterial3Api::class,
ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class,
)
package io.element.android.features.messages.impl package io.element.android.features.messages.impl
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
@ -90,6 +87,7 @@ import timber.log.Timber
import io.element.android.libraries.ui.strings.R as StringsR import io.element.android.libraries.ui.strings.R as StringsR
@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun MessagesView( fun MessagesView(
state: MessagesState, state: MessagesState,
@ -198,7 +196,9 @@ fun MessagesView(
content = { padding -> content = { padding ->
MessagesViewContent( MessagesViewContent(
state = state, state = state,
modifier = Modifier.padding(padding), modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding),
onMessageClicked = ::onMessageClicked, onMessageClicked = ::onMessageClicked,
onMessageLongClicked = ::onMessageLongClicked onMessageLongClicked = ::onMessageLongClicked
) )
@ -264,6 +264,7 @@ fun MessagesViewContent(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MessagesViewTopBar( fun MessagesViewTopBar(
roomTitle: String?, roomTitle: String?,

View file

@ -14,8 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalMaterialApi::class)
package io.element.android.features.messages.impl.actionlist package io.element.android.features.messages.impl.actionlist
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -49,6 +47,7 @@ import io.element.android.libraries.designsystem.theme.components.ModalBottomShe
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun ActionListView( fun ActionListView(
state: ActionListState, state: ActionListState,
@ -90,6 +89,7 @@ fun ActionListView(
) )
} }
@OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
private fun SheetContent( private fun SheetContent(
state: ActionListState, state: ActionListState,
@ -145,6 +145,7 @@ fun SheetContentLightPreview(@PreviewParameter(ActionListStateProvider::class) s
fun SheetContentDarkPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) = fun SheetContentDarkPreview(@PreviewParameter(ActionListStateProvider::class) state: ActionListState) =
ElementPreviewDark { ContentToPreview(state) } ElementPreviewDark { ContentToPreview(state) }
@OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
private fun ContentToPreview(state: ActionListState) { private fun ContentToPreview(state: ActionListState) {
ActionListView( ActionListView(

View file

@ -14,8 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.messages.textcomposer package io.element.android.features.messages.textcomposer
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionClock
@ -46,13 +44,13 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -308,7 +306,7 @@ class MessageComposerPresenterTest {
mimetype = null, mimetype = null,
size = null, size = null,
thumbnailInfo = null, thumbnailInfo = null,
thumbnailUrl = null, thumbnailSource = null,
blurhash = null, blurhash = null,
), ),
thumbnailInfo = ThumbnailProcessingInfo( thumbnailInfo = ThumbnailProcessingInfo(
@ -351,7 +349,7 @@ class MessageComposerPresenterTest {
duration = null, duration = null,
size = null, size = null,
thumbnailInfo = null, thumbnailInfo = null,
thumbnailUrl = null, thumbnailSource = null,
blurhash = null, blurhash = null,
), ),
thumbnailInfo = ThumbnailProcessingInfo( thumbnailInfo = ThumbnailProcessingInfo(
@ -490,7 +488,12 @@ class MessageComposerPresenterTest {
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor, mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher, snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
) = MessageComposerPresenter( ) = MessageComposerPresenter(
coroutineScope, room, pickerProvider, featureFlagService, mediaPreProcessor, snackbarDispatcher coroutineScope,
room,
pickerProvider,
featureFlagService,
mediaPreProcessor,
snackbarDispatcher
) )
} }

View file

@ -41,7 +41,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.HorizontalPagerIndicator import com.google.accompanist.pager.HorizontalPagerIndicator
import com.google.accompanist.pager.rememberPagerState import com.google.accompanist.pager.rememberPagerState
@ -53,9 +52,7 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalPagerApi::class)
@Composable @Composable
fun OnBoardingScreen( fun OnBoardingScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,

View file

@ -85,7 +85,7 @@ class RageshakeDetectionPresenterTest {
} }
@Test @Test
fun `present - screenshot with success then dismiss`() = runTest { fun `present - screenshot with success then dismiss`() = runTest(timeout = 30.seconds) {
val screenshotHolder = FakeScreenshotHolder(screenshotUri = null) val screenshotHolder = FakeScreenshotHolder(screenshotUri = null)
val rageshake = FakeRageShake(isAvailableValue = true) val rageshake = FakeRageShake(isAvailableValue = true)
val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true) val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true)
@ -99,7 +99,7 @@ class RageshakeDetectionPresenterTest {
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test(timeout = 30.seconds) { }.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isStarted).isFalse() assertThat(initialState.isStarted).isFalse()

View file

@ -42,6 +42,7 @@ dependencies {
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
api(projects.features.roomdetails.api) api(projects.features.roomdetails.api)
api(projects.libraries.usersearch.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
@ -50,7 +51,6 @@ dependencies {
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.userlist.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)

View file

@ -35,7 +35,6 @@ import io.element.android.libraries.architecture.animation.rememberDefaultTransi
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)

View file

@ -19,8 +19,10 @@ package io.element.android.features.roomdetails.impl
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -43,8 +45,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.roomdetails.blockuser.BlockUserDialogs import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
import io.element.android.features.roomdetails.blockuser.BlockUserSection import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection
import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection
import io.element.android.features.roomdetails.impl.members.details.RoomMemberMainActionsSection import io.element.android.features.roomdetails.impl.members.details.RoomMemberMainActionsSection
import io.element.android.libraries.architecture.isLoading import io.element.android.libraries.architecture.isLoading
@ -68,7 +70,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun RoomDetailsView( fun RoomDetailsView(
state: RoomDetailsState, state: RoomDetailsState,
@ -91,6 +93,7 @@ fun RoomDetailsView(
) { padding -> ) { padding ->
Column(modifier = Modifier Column(modifier = Modifier
.padding(padding) .padding(padding)
.consumeWindowInsets(padding)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
when (state.roomType) { when (state.roomType) {

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.roomdetails.blockuser package io.element.android.features.roomdetails.impl.blockuser
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Block import androidx.compose.material.icons.outlined.Block

View file

@ -26,6 +26,7 @@ import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -41,7 +42,7 @@ class RoomMemberListPresenter @Inject constructor(
var roomMembers by remember { mutableStateOf<Async<RoomMembers>>(Async.Loading()) } var roomMembers by remember { mutableStateOf<Async<RoomMembers>>(Async.Loading()) }
var searchQuery by rememberSaveable { mutableStateOf("") } var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults by remember { var searchResults by remember {
mutableStateOf<RoomMemberSearchResultState>(RoomMemberSearchResultState.NotSearching) mutableStateOf<SearchBarResultState<RoomMembers>>(SearchBarResultState.NotSearching())
} }
var isSearchActive by rememberSaveable { mutableStateOf(false) } var isSearchActive by rememberSaveable { mutableStateOf(false) }
@ -60,11 +61,11 @@ class RoomMemberListPresenter @Inject constructor(
LaunchedEffect(searchQuery) { LaunchedEffect(searchQuery) {
withContext(coroutineDispatchers.io) { withContext(coroutineDispatchers.io) {
searchResults = if (searchQuery.isEmpty()) { searchResults = if (searchQuery.isEmpty()) {
RoomMemberSearchResultState.NotSearching SearchBarResultState.NotSearching()
} else { } else {
val results = roomMemberListDataSource.search(searchQuery).groupBy { it.membership } val results = roomMemberListDataSource.search(searchQuery).groupBy { it.membership }
if (results.isEmpty()) RoomMemberSearchResultState.NoResults if (results.isEmpty()) SearchBarResultState.NoResults()
else RoomMemberSearchResultState.Results( else SearchBarResultState.Results(
RoomMembers( RoomMembers(
invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(), invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList()).toImmutableList(), joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList()).toImmutableList(),

View file

@ -17,13 +17,14 @@
package io.element.android.features.roomdetails.impl.members package io.element.android.features.roomdetails.impl.members
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class RoomMemberListState( data class RoomMemberListState(
val roomMembers: Async<RoomMembers>, val roomMembers: Async<RoomMembers>,
val searchQuery: String, val searchQuery: String,
val searchResults: RoomMemberSearchResultState, val searchResults: SearchBarResultState<RoomMembers>,
val isSearchActive: Boolean, val isSearchActive: Boolean,
val eventSink: (RoomMemberListEvents) -> Unit, val eventSink: (RoomMemberListEvents) -> Unit,
) )
@ -32,14 +33,3 @@ data class RoomMembers(
val invited: ImmutableList<RoomMember>, val invited: ImmutableList<RoomMember>,
val joined: ImmutableList<RoomMember> val joined: ImmutableList<RoomMember>
) )
sealed interface RoomMemberSearchResultState {
/** No search results are available yet (e.g. because the user hasn't entered a (long enough) search term). */
object NotSearching : RoomMemberSearchResultState
/** The search has completed, but no results were found. */
object NoResults : RoomMemberSearchResultState
/** The search has completed, and some matching users were found. */
data class Results(val results: RoomMembers) : RoomMemberSearchResultState
}

View file

@ -18,11 +18,11 @@ package io.element.android.features.roomdetails.impl.members
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMemberListState> { internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMemberListState> {
override val values: Sequence<RoomMemberListState> override val values: Sequence<RoomMemberListState>
@ -42,7 +42,7 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
aRoomMemberListState().copy( aRoomMemberListState().copy(
isSearchActive = true, isSearchActive = true,
searchQuery = "@someone:matrix.org", searchQuery = "@someone:matrix.org",
searchResults = RoomMemberSearchResultState.Results( searchResults = SearchBarResultState.Results(
RoomMembers( RoomMembers(
invited = persistentListOf(aVictor()), invited = persistentListOf(aVictor()),
joined = persistentListOf(anAlice()), joined = persistentListOf(anAlice()),
@ -52,14 +52,14 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
aRoomMemberListState().copy( aRoomMemberListState().copy(
isSearchActive = true, isSearchActive = true,
searchQuery = "something-with-no-results", searchQuery = "something-with-no-results",
searchResults = RoomMemberSearchResultState.NoResults searchResults = SearchBarResultState.NoResults()
), ),
) )
} }
internal fun aRoomMemberListState( internal fun aRoomMemberListState(
roomMembers: Async<RoomMembers> = Async.Uninitialized, roomMembers: Async<RoomMembers> = Async.Uninitialized,
searchResults: RoomMemberSearchResultState = RoomMemberSearchResultState.NotSearching, searchResults: SearchBarResultState<RoomMembers> = SearchBarResultState.NotSearching(),
) = RoomMemberListState( ) = RoomMemberListState(
roomMembers = roomMembers, roomMembers = roomMembers,
searchQuery = "", searchQuery = "",

View file

@ -20,27 +20,20 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -59,10 +52,9 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
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.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SearchBar import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
@ -71,7 +63,7 @@ import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun RoomMemberListView( fun RoomMemberListView(
state: RoomMemberListState, state: RoomMemberListState,
@ -94,27 +86,27 @@ fun RoomMemberListView(
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(padding), .padding(padding)
.consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Column { RoomMemberSearchBar(
RoomMemberSearchBar( query = state.searchQuery,
query = state.searchQuery, state = state.searchResults,
state = state.searchResults, active = state.isSearchActive,
active = state.isSearchActive, placeHolderTitle = stringResource(StringR.string.common_search_for_someone),
placeHolderTitle = stringResource(StringR.string.common_search_for_someone), onActiveChanged = { state.eventSink(RoomMemberListEvents.OnSearchActiveChanged(it)) },
onActiveChanged = { state.eventSink(RoomMemberListEvents.OnSearchActiveChanged(it)) }, onTextChanged = { state.eventSink(RoomMemberListEvents.UpdateSearchQuery(it)) },
onTextChanged = { state.eventSink(RoomMemberListEvents.UpdateSearchQuery(it)) }, onUserSelected = ::onUserSelected,
onUserSelected = ::onUserSelected, modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth() )
)
}
if (!state.isSearchActive) { if (!state.isSearchActive) {
if (state.roomMembers is Async.Success) { if (state.roomMembers is Async.Success) {
RoomMemberList( RoomMemberList(
roomMembers = state.roomMembers.state, roomMembers = state.roomMembers.state,
onUserSelected = ::onUserSelected, showMembersCount = true,
onUserSelected = ::onUserSelected
) )
} else if (state.roomMembers.isLoading()) { } else if (state.roomMembers.isLoading()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@ -129,6 +121,7 @@ fun RoomMemberListView(
@Composable @Composable
private fun RoomMemberList( private fun RoomMemberList(
roomMembers: RoomMembers, roomMembers: RoomMembers,
showMembersCount: Boolean,
onUserSelected: (RoomMember) -> Unit, onUserSelected: (RoomMember) -> Unit,
) { ) {
LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) { LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
@ -140,9 +133,15 @@ private fun RoomMemberList(
) )
} }
if (roomMembers.joined.isNotEmpty()) { if (roomMembers.joined.isNotEmpty()) {
val memberCount = roomMembers.joined.count()
roomMemberListSection( roomMemberListSection(
headerText = { pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount) }, headerText = {
if (showMembersCount) {
val memberCount = roomMembers.joined.count()
pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount)
} else {
stringResource(id = R.string.screen_room_member_list_room_members_header_title)
}
},
members = roomMembers.joined, members = roomMembers.joined,
onMemberSelected = { onUserSelected(it) } onMemberSelected = { onUserSelected(it) }
) )
@ -209,11 +208,10 @@ private fun RoomMemberListTopBar(
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun RoomMemberSearchBar( private fun RoomMemberSearchBar(
query: String, query: String,
state: RoomMemberSearchResultState, state: SearchBarResultState<RoomMembers>,
active: Boolean, active: Boolean,
placeHolderTitle: String, placeHolderTitle: String,
onActiveChanged: (Boolean) -> Unit, onActiveChanged: (Boolean) -> Unit,
@ -221,71 +219,21 @@ private fun RoomMemberSearchBar(
onUserSelected: (RoomMember) -> Unit, onUserSelected: (RoomMember) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val focusManager = LocalFocusManager.current
if (!active) {
onTextChanged("")
focusManager.clearFocus()
}
SearchBar( SearchBar(
query = query, query = query,
onQueryChange = onTextChanged, onQueryChange = onTextChanged,
onSearch = { focusManager.clearFocus() },
active = active, active = active,
onActiveChange = onActiveChanged, onActiveChange = onActiveChanged,
modifier = modifier modifier = modifier,
.padding(horizontal = if (!active) 16.dp else 0.dp), placeHolderTitle = placeHolderTitle,
placeholder = { resultState = state,
Text( resultHandler = { results ->
text = placeHolderTitle, RoomMemberList(
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine) roomMembers = results,
showMembersCount = false,
onUserSelected = { onUserSelected(it) }
) )
}, },
leadingIcon = if (active) {
{ BackButton(onClick = { onActiveChanged(false) }) }
} else {
null
},
trailingIcon = when {
active && query.isNotEmpty() -> {
{
IconButton(onClick = { onTextChanged("") }) {
Icon(Icons.Default.Close, stringResource(StringR.string.action_clear))
}
}
}
!active -> {
{
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(StringR.string.action_search),
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
)
}
}
else -> null
},
colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent),
content = {
if (state is RoomMemberSearchResultState.Results) {
RoomMemberList(
roomMembers = state.results,
onUserSelected = { onUserSelected(it) }
)
} else if (state is RoomMemberSearchResultState.NoResults) {
Spacer(Modifier.size(80.dp))
Text(
text = stringResource(StringR.string.common_no_results),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.fillMaxWidth()
)
}
},
) )
} }

View file

@ -19,8 +19,10 @@ package io.element.android.features.roomdetails.impl.members.details
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -39,8 +41,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.roomdetails.blockuser.BlockUserDialogs import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
import io.element.android.features.roomdetails.blockuser.BlockUserSection import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection
import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.avatar.Avatar 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.AvatarData
@ -57,7 +59,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun RoomMemberDetailsView( fun RoomMemberDetailsView(
state: RoomMemberDetailsState, state: RoomMemberDetailsState,
@ -74,6 +76,7 @@ fun RoomMemberDetailsView(
Column( Column(
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.consumeWindowInsets(padding)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
RoomMemberHeaderSection( RoomMemberHeaderSection(

View file

@ -4,10 +4,13 @@
<item quantity="one">"1 person"</item> <item quantity="one">"1 person"</item>
<item quantity="other">"%1$d people"</item> <item quantity="other">"%1$d people"</item>
</plurals> </plurals>
<string name="screen_room_details_already_a_member">"Already a member"</string>
<string name="screen_room_details_already_invited">"Already invited"</string>
<string name="screen_room_details_encryption_enabled_subtitle">"Messages are secured with locks. Only you and the recipients have the unique keys to unlock them."</string> <string name="screen_room_details_encryption_enabled_subtitle">"Messages are secured with locks. Only you and the recipients have the unique keys to unlock them."</string>
<string name="screen_room_details_encryption_enabled_title">"Message encryption enabled"</string> <string name="screen_room_details_encryption_enabled_title">"Message encryption enabled"</string>
<string name="screen_room_details_share_room_title">"Share room"</string> <string name="screen_room_details_share_room_title">"Share room"</string>
<string name="screen_room_member_list_pending_header_title">"Pending"</string> <string name="screen_room_member_list_pending_header_title">"Pending"</string>
<string name="screen_room_member_list_room_members_header_title">"Room members"</string>
<string name="screen_dm_details_block_alert_action">"Block"</string> <string name="screen_dm_details_block_alert_action">"Block"</string>
<string name="screen_dm_details_block_alert_description">"Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime."</string> <string name="screen_dm_details_block_alert_description">"Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime."</string>
<string name="screen_dm_details_block_user">"Block user"</string> <string name="screen_dm_details_block_user">"Block user"</string>

View file

@ -24,12 +24,12 @@ import io.element.android.features.roomdetails.aMatrixRoom
import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource
import io.element.android.features.roomdetails.impl.members.RoomMemberListEvents import io.element.android.features.roomdetails.impl.members.RoomMemberListEvents
import io.element.android.features.roomdetails.impl.members.RoomMemberListPresenter import io.element.android.features.roomdetails.impl.members.RoomMemberListPresenter
import io.element.android.features.roomdetails.impl.members.RoomMemberSearchResultState
import io.element.android.features.roomdetails.impl.members.aRoomMemberList import io.element.android.features.roomdetails.impl.members.aRoomMemberList
import io.element.android.features.roomdetails.impl.members.aVictor import io.element.android.features.roomdetails.impl.members.aVictor
import io.element.android.features.roomdetails.impl.members.aWalter import io.element.android.features.roomdetails.impl.members.aWalter
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
@ -49,7 +49,7 @@ class RoomMemberListPresenterTests {
val initialState = awaitItem() val initialState = awaitItem()
Truth.assertThat(initialState.roomMembers).isInstanceOf(Async.Loading::class.java) Truth.assertThat(initialState.roomMembers).isInstanceOf(Async.Loading::class.java)
Truth.assertThat(initialState.searchQuery).isEmpty() Truth.assertThat(initialState.searchQuery).isEmpty()
Truth.assertThat(initialState.searchResults).isEqualTo(RoomMemberSearchResultState.NotSearching) Truth.assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java)
Truth.assertThat(initialState.isSearchActive).isFalse() Truth.assertThat(initialState.isSearchActive).isFalse()
val loadedState = awaitItem() val loadedState = awaitItem()
@ -89,7 +89,7 @@ class RoomMemberListPresenterTests {
val searchQueryUpdatedState = awaitItem() val searchQueryUpdatedState = awaitItem()
Truth.assertThat((searchQueryUpdatedState.searchQuery)).isEqualTo("something") Truth.assertThat((searchQueryUpdatedState.searchQuery)).isEqualTo("something")
val searchSearchResultDelivered = awaitItem() val searchSearchResultDelivered = awaitItem()
Truth.assertThat((searchSearchResultDelivered.searchResults)).isInstanceOf(RoomMemberSearchResultState.NoResults::class.java) Truth.assertThat(searchSearchResultDelivered.searchResults).isInstanceOf(SearchBarResultState.NoResults::class.java)
} }
} }
@ -107,8 +107,8 @@ class RoomMemberListPresenterTests {
val searchQueryUpdatedState = awaitItem() val searchQueryUpdatedState = awaitItem()
Truth.assertThat((searchQueryUpdatedState.searchQuery)).isEqualTo("Alice") Truth.assertThat((searchQueryUpdatedState.searchQuery)).isEqualTo("Alice")
val searchSearchResultDelivered = awaitItem() val searchSearchResultDelivered = awaitItem()
Truth.assertThat((searchSearchResultDelivered.searchResults)).isInstanceOf(RoomMemberSearchResultState.Results::class.java) Truth.assertThat((searchSearchResultDelivered.searchResults)).isInstanceOf(SearchBarResultState.Results::class.java)
Truth.assertThat((searchSearchResultDelivered.searchResults as RoomMemberSearchResultState.Results).results.joined.first().displayName) Truth.assertThat((searchSearchResultDelivered.searchResults as SearchBarResultState.Results).results.joined.first().displayName)
.isEqualTo("Alice") .isEqualTo("Alice")
} }

View file

@ -22,9 +22,11 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -116,7 +118,7 @@ fun RoomListView(
} }
} }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun RoomListContent( fun RoomListContent(
state: RoomListState, state: RoomListState,
@ -190,6 +192,7 @@ fun RoomListContent(
Column( Column(
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.consumeWindowInsets(padding)
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier

View file

@ -14,8 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.roomlist.impl.components package io.element.android.features.roomlist.impl.components
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler

View file

@ -20,7 +20,8 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@ -30,7 +31,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -60,6 +60,7 @@ import io.element.android.libraries.designsystem.modifiers.applyIf
import io.element.android.libraries.designsystem.theme.components.Icon 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.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.copy import io.element.android.libraries.designsystem.utils.copy
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -90,7 +91,7 @@ internal fun RoomListSearchResultView(
} }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
internal fun RoomListSearchResultContent( internal fun RoomListSearchResultContent(
state: RoomListState, state: RoomListState,
@ -128,12 +129,14 @@ internal fun RoomListSearchResultContent(
.focusRequester(focusRequester), .focusRequester(focusRequester),
value = filter, value = filter,
onValueChange = { state.eventSink(RoomListEvents.UpdateFilter(it)) }, onValueChange = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
colors = TextFieldDefaults.textFieldColors( colors = TextFieldDefaults.colors(
containerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent, focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent, errorIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent
), ),
trailingIcon = { trailingIcon = {
if (filter.isNotEmpty()) { if (filter.isNotEmpty()) {
@ -182,6 +185,7 @@ internal fun RoomListSearchResultContent(
Column( Column(
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.consumeWindowInsets(padding)
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier

View file

@ -1,163 +0,0 @@
/*
* 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.features.userlist.api.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.MaterialTheme
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.features.userlist.api.UserSearchResultState
import io.element.android.libraries.designsystem.components.button.BackButton
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.SearchBar
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.R
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchUserBar(
query: String,
state: UserSearchResultState,
selectedUsers: ImmutableList<MatrixUser>,
active: Boolean,
isMultiSelectionEnabled: Boolean,
modifier: Modifier = Modifier,
placeHolderTitle: String = stringResource(R.string.common_search_for_someone),
onActiveChanged: (Boolean) -> Unit = {},
onTextChanged: (String) -> Unit = {},
onUserSelected: (MatrixUser) -> Unit = {},
onUserDeselected: (MatrixUser) -> Unit = {},
) {
val focusManager = LocalFocusManager.current
if (!active) {
onTextChanged("")
focusManager.clearFocus()
}
SearchBar(
query = query,
onQueryChange = onTextChanged,
onSearch = { focusManager.clearFocus() },
active = active,
onActiveChange = onActiveChanged,
modifier = modifier
.padding(horizontal = if (!active) 16.dp else 0.dp),
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 = { onActiveChanged(false) }) }
} else {
null
},
trailingIcon = when {
active && query.isNotEmpty() -> {
{
IconButton(onClick = { onTextChanged("") }) {
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
},
colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent),
content = {
if (isMultiSelectionEnabled && active && selectedUsers.isNotEmpty()) {
SelectedUsersList(
contentPadding = PaddingValues(16.dp),
selectedUsers = selectedUsers,
autoScroll = true,
onUserRemoved = onUserDeselected,
)
}
if (state is UserSearchResultState.Results) {
LazyColumn {
if (isMultiSelectionEnabled) {
items(state.results) { matrixUser ->
SearchMultipleUsersResultItem(
modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser,
isUserSelected = selectedUsers.find { it.userId == matrixUser.userId } != null,
onCheckedChange = { checked ->
if (checked) {
onUserSelected(matrixUser)
} else {
onUserDeselected(matrixUser)
}
}
)
}
} else {
items(state.results) { matrixUser ->
SearchSingleUserResultItem(
modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser,
onClick = { onUserSelected(matrixUser) }
)
}
}
}
} else if (state is UserSearchResultState.NoResults) {
Spacer(Modifier.size(80.dp))
Text(
text = stringResource(R.string.common_no_results),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.fillMaxWidth()
)
}
},
)
}

View file

@ -20,7 +20,7 @@ startup = "1.1.1"
media3 = "1.0.1" media3 = "1.0.1"
# Compose # Compose
compose_bom = "2023.04.01" compose_bom = "2023.05.01"
composecompiler = "1.4.7" composecompiler = "1.4.7"
# Coroutines # Coroutines
@ -33,7 +33,7 @@ accompanist = "0.30.1"
test_core = "1.5.0" test_core = "1.5.0"
#other #other
coil = "2.3.0" coil = "2.4.0"
datetime = "0.4.0" datetime = "0.4.0"
serialization_json = "1.5.0" serialization_json = "1.5.0"
showkase = "1.0.0-beta18" showkase = "1.0.0-beta18"
@ -119,7 +119,7 @@ test_orchestrator = "androidx.test:orchestrator:1.4.2"
test_turbine = "app.cash.turbine:turbine:0.12.3" test_turbine = "app.cash.turbine:turbine:0.12.3"
test_truth = "com.google.truth:truth:1.1.3" test_truth = "com.google.truth:truth:1.1.3"
test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.11" test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.11"
test_robolectric = "org.robolectric:robolectric:4.10.2" test_robolectric = "org.robolectric:robolectric:4.10.3"
test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" } test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" }
# Others # Others
@ -133,7 +133,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
timber = "com.jakewharton.timber:timber:5.0.1" timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.13" matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.14"
sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" } sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" } sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" }

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.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding 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.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun PreferenceView( fun PreferenceView(
title: String, title: String,
@ -70,6 +72,7 @@ fun PreferenceView(
Column( Column(
modifier = Modifier modifier = Modifier
.padding(it) .padding(it)
.consumeWindowInsets(it)
.verticalScroll( .verticalScroll(
state = scrollState, state = scrollState,
) )

View file

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

View file

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

View file

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

View file

@ -23,10 +23,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.TextFieldColors import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi 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.allBooleans
import io.element.android.libraries.designsystem.utils.asInt import io.element.android.libraries.designsystem.utils.asInt
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable @Composable
fun OutlinedTextField( fun OutlinedTextField(
value: String, value: String,
@ -70,8 +68,8 @@ fun OutlinedTextField(
singleLine: Boolean = false, singleLine: Boolean = false,
maxLines: Int = Int.MAX_VALUE, maxLines: Int = Int.MAX_VALUE,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = TextFieldDefaults.outlinedShape, shape: Shape = OutlinedTextFieldDefaults.shape,
colors: TextFieldColors = TextFieldDefaults.outlinedTextFieldColors() colors: TextFieldColors = OutlinedTextFieldDefaults.colors()
) { ) {
androidx.compose.material3.OutlinedTextField( androidx.compose.material3.OutlinedTextField(
value = value, 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.PaddingValues
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FabPosition import androidx.compose.material3.FabPosition
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.ScaffoldDefaults
@ -27,7 +26,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun Scaffold( fun Scaffold(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,

View file

@ -14,77 +14,218 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.libraries.designsystem.theme.components package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets 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.ExperimentalMaterial3Api
import androidx.compose.material3.SearchBarColors import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.SearchBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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.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.tooling.preview.Preview
import androidx.compose.ui.unit.Dp 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.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.ui.strings.R
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SearchBar( fun <T> SearchBar(
query: String, query: String,
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
onSearch: (String) -> Unit,
active: Boolean, active: Boolean,
onActiveChange: (Boolean) -> Unit, onActiveChange: (Boolean) -> Unit,
placeHolderTitle: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
placeholder: @Composable (() -> Unit)? = null, resultState: SearchBarResultState<T> = SearchBarResultState.NotSearching(),
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
shape: Shape = SearchBarDefaults.inputFieldShape, shape: Shape = SearchBarDefaults.inputFieldShape,
colors: SearchBarColors = SearchBarDefaults.colors(),
tonalElevation: Dp = SearchBarDefaults.Elevation, tonalElevation: Dp = SearchBarDefaults.Elevation,
windowInsets: WindowInsets = SearchBarDefaults.windowInsets, windowInsets: WindowInsets = SearchBarDefaults.windowInsets,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, 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( androidx.compose.material3.SearchBar(
query = query, query = query,
onQueryChange = onQueryChange, onQueryChange = onQueryChange,
onSearch = onSearch, onSearch = { focusManager.clearFocus() },
active = active, active = active,
onActiveChange = onActiveChange, onActiveChange = onActiveChange,
modifier = modifier, modifier = modifier.padding(horizontal = if (!active) 16.dp else 0.dp),
enabled = enabled, enabled = enabled,
placeholder = placeholder, placeholder = {
leadingIcon = leadingIcon, Text(
trailingIcon = trailingIcon, 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, shape = shape,
colors = colors, colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent),
tonalElevation = tonalElevation, tonalElevation = tonalElevation,
windowInsets = windowInsets, windowInsets = windowInsets,
interactionSource = interactionSource, 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) @Preview(group = PreviewGroup.Search)
@Composable @Composable
internal fun DockedSearchBarPreview() = ElementThemedPreview { ContentToPreview() } internal fun SearchBarPreviewActiveWithQuery() = ElementThemedPreview {
ContentToPreview(
@Composable query = "search term",
private fun ContentToPreview() { active = true,
SearchBar( )
query = "Some text", }
onQueryChange = {},
onSearch = {}, @Preview(group = PreviewGroup.Search)
active = false, @Composable
onActiveChange = {}, internal fun SearchBarPreviewActiveWithNoResults() = ElementThemedPreview {
content = {}, 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.layout.padding
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.TextFieldColors import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults 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.allBooleans
import io.element.android.libraries.designsystem.utils.asInt import io.element.android.libraries.designsystem.utils.asInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TextField( fun TextField(
value: String, value: String,
@ -71,8 +69,8 @@ fun TextField(
singleLine: Boolean = false, singleLine: Boolean = false,
maxLines: Int = Int.MAX_VALUE, maxLines: Int = Int.MAX_VALUE,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = TextFieldDefaults.filledShape, shape: Shape = TextFieldDefaults.shape,
colors: TextFieldColors = TextFieldDefaults.textFieldColors() colors: TextFieldColors = TextFieldDefaults.colors()
) { ) {
androidx.compose.material3.TextField( androidx.compose.material3.TextField(
value = value, value = value,

View file

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

View file

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

View file

@ -14,8 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.libraries.matrix.impl package io.element.android.libraries.matrix.impl
import io.element.android.libraries.core.coroutine.CoroutineDispatchers 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 io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.filter 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 sessionVerificationService(): SessionVerificationService = verificationService
override fun pushersService(): PushersService = pushersService 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() { override fun onSlidingSyncUpdate() {
if (!verificationService.isReady.value) { if (!verificationService.isReady.value) {
try { try {
@ -353,13 +364,6 @@ class RustMatrixClient constructor(
override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver 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 { private fun File.deleteSessionDirectory(userID: String): Boolean {
// Rust sanitises the user ID replacing invalid characters with an _ // Rust sanitises the user ID replacing invalid characters with an _
val sanitisedUserID = userID.replace(":", "_") val sanitisedUserID = userID.replace(":", "_")

View file

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

View file

@ -60,6 +60,7 @@ class FakeMatrixClient(
private val getRoomResults = mutableMapOf<RoomId, MatrixRoom>() private val getRoomResults = mutableMapOf<RoomId, MatrixRoom>()
private val searchUserResults = mutableMapOf<String, Result<MatrixSearchUserResults>>() private val searchUserResults = mutableMapOf<String, Result<MatrixSearchUserResults>>()
private val getProfileResults = mutableMapOf<UserId, Result<MatrixUser>>() private val getProfileResults = mutableMapOf<UserId, Result<MatrixUser>>()
private var uploadMediaResult: Result<String> = Result.success(AN_AVATAR_URL)
override fun getRoom(roomId: RoomId): MatrixRoom? { override fun getRoom(roomId: RoomId): MatrixRoom? {
return getRoomResults[roomId] return getRoomResults[roomId]
@ -92,6 +93,10 @@ class FakeMatrixClient(
return getProfileResults[userId] ?: Result.failure(IllegalStateException("No profile found for $userId")) 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 startSync() = Unit
override fun stopSync() = Unit override fun stopSync() = Unit
@ -111,6 +116,10 @@ class FakeMatrixClient(
return userAvatarURLString return userAvatarURLString
} }
override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String> {
return uploadMediaResult
}
override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService
override fun pushersService(): PushersService = pushersService override fun pushersService(): PushersService = pushersService
@ -123,10 +132,6 @@ class FakeMatrixClient(
return RoomMembershipObserver() return RoomMembershipObserver()
} }
override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> {
return searchUserResults[searchTerm] ?: Result.failure(IllegalStateException("No response defined for $searchTerm"))
}
// Mocks // Mocks
fun givenLogoutError(failure: Throwable?) { fun givenLogoutError(failure: Throwable?) {
@ -168,4 +173,8 @@ class FakeMatrixClient(
fun givenGetProfileResult(userId: UserId, result: Result<MatrixUser>) { fun givenGetProfileResult(userId: UserId, result: Result<MatrixUser>) {
getProfileResults[userId] = result 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.matrix.api)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.uiStrings)
implementation(libs.coil.compose) implementation(libs.coil.compose)
ksp(libs.showkase.processor) 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.semantics.Role
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter 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.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Checkbox import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.matrix.api.user.MatrixUser 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 @Composable
fun CheckableMatrixUserRow( fun CheckableMatrixUserRow(
@ -40,18 +43,39 @@ fun CheckableMatrixUserRow(
avatarSize: AvatarSize = AvatarSize.MEDIUM, avatarSize: AvatarSize = AvatarSize.MEDIUM,
onCheckedChange: (Boolean) -> Unit = {}, onCheckedChange: (Boolean) -> Unit = {},
enabled: Boolean = true, 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( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(role = Role.Checkbox) { onCheckedChange(!checked) }, .clickable(role = Role.Checkbox, enabled = enabled) {
onCheckedChange(!checked)
},
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
MatrixUserRow( UserRow(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
matrixUser = matrixUser, avatarData = avatarData,
avatarSize = avatarSize, name = name,
subtext = subtext,
) )
Checkbox( Checkbox(
@ -77,5 +101,7 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
Column { Column {
CheckableMatrixUserRow(checked = true, matrixUser) CheckableMatrixUserRow(checked = true, matrixUser)
CheckableMatrixUserRow(checked = false, 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.components.avatar.Avatar 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.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@ -46,6 +47,19 @@ fun MatrixUserRow(
matrixUser: MatrixUser, matrixUser: MatrixUser,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
avatarSize: AvatarSize = AvatarSize.MEDIUM, 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( Row(
modifier = modifier modifier = modifier
@ -54,9 +68,7 @@ fun MatrixUserRow(
.height(IntrinsicSize.Min), .height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Avatar( Avatar(avatarData)
matrixUser.getAvatarData(size = avatarSize),
)
Column( Column(
modifier = Modifier modifier = Modifier
.padding(start = 12.dp), .padding(start = 12.dp),
@ -65,15 +77,15 @@ fun MatrixUserRow(
Text( Text(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
text = matrixUser.getBestName(), text = name,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
// Id // Id
if (matrixUser.displayName.isNullOrEmpty().not()) { subtext?.let {
Text( Text(
text = matrixUser.userId.value, text = subtext,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
fontSize = 14.sp, fontSize = 14.sp,
maxLines = 1, maxLines = 1,

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.userlist.api.components package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -41,10 +41,9 @@ 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.IconButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.ui.strings.R import io.element.android.libraries.ui.strings.R as StringR
@Composable @Composable
fun SelectedUser( fun SelectedUser(
@ -74,7 +73,7 @@ fun SelectedUser(
) { ) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = stringResource(id = R.string.action_remove), contentDescription = stringResource(id = StringR.string.action_remove),
tint = MaterialTheme.colorScheme.onPrimary, tint = MaterialTheme.colorScheme.onPrimary,
) )
} }

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.userlist.api.components package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@ -30,11 +30,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.userlist.api.aListOfSelectedUsers
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@Composable @Composable
fun SelectedUsersList( fun SelectedUsersList(
@ -82,6 +82,6 @@ internal fun SelectedUsersListDarkPreview() = ElementPreviewDark { ContentToPrev
@Composable @Composable
private fun ContentToPreview() { private fun ContentToPreview() {
SelectedUsersList( SelectedUsersList(
selectedUsers = aListOfSelectedUsers(), selectedUsers = aMatrixUserList().take(6).toImmutableList(),
) )
} }

View file

@ -27,6 +27,11 @@ interface PickerProvider {
onResult: (uri: Uri?, mimeType: String?) -> Unit onResult: (uri: Uri?, mimeType: String?) -> Unit
): PickerLauncher<PickVisualMediaRequest, Uri?> ): PickerLauncher<PickVisualMediaRequest, Uri?>
@Composable
fun registerGalleryImagePicker(
onResult: (Uri?) -> Unit
): PickerLauncher<PickVisualMediaRequest, Uri?>
@Composable @Composable
fun registerFilePicker( fun registerFilePicker(
mimeType: String, mimeType: String,
@ -38,5 +43,4 @@ interface PickerProvider {
@Composable @Composable
fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> 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 getContract(): ActivityResultContract<Input, Output>
fun getDefaultRequest(): Input 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?> { object ImageAndVideo : PickerType<PickVisualMediaRequest, Uri?> {
override fun getContract() = ActivityResultContracts.PickVisualMedia() override fun getContract() = ActivityResultContracts.PickVisualMedia()
override fun getDefaultRequest(): PickVisualMediaRequest { 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. * 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. * [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) } return NoOpPickerLauncher { onResult(result, mimeType) }
} }
@Composable
override fun registerGalleryImagePicker(onResult: (uri: Uri?) -> Unit): PickerLauncher<PickVisualMediaRequest, Uri?> {
return NoOpPickerLauncher { onResult(result) }
}
@Composable @Composable
override fun registerFilePicker(mimeType: String, onResult: (Uri?) -> Unit): PickerLauncher<String, Uri?> { override fun registerFilePicker(mimeType: String, onResult: (Uri?) -> Unit): PickerLauncher<String, Uri?> {
return NoOpPickerLauncher { onResult(result) } return NoOpPickerLauncher { onResult(result) }

View file

@ -24,10 +24,13 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
import java.io.File import java.io.File
sealed interface MediaUploadInfo { 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 val file: File
data class Audio(val file: File, val info: AudioInfo) : MediaUploadInfo
data class AnyFile(val file: File, val info: FileInfo) : MediaUploadInfo 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( data class ThumbnailProcessingInfo(

View file

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

View file

@ -63,6 +63,7 @@
<string name="common_developer_options">"Developer options"</string> <string name="common_developer_options">"Developer options"</string>
<string name="common_edited_suffix">"(edited)"</string> <string name="common_edited_suffix">"(edited)"</string>
<string name="common_editing">"Editing"</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_encryption_enabled">"Encryption enabled"</string>
<string name="common_error">"Error"</string> <string name="common_error">"Error"</string>
<string name="common_file">"File"</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_a_bug">"Report a bug"</string>
<string name="common_report_submitted">"Report submitted"</string> <string name="common_report_submitted">"Report submitted"</string>
<string name="common_search_for_someone">"Search for someone"</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_security">"Security"</string>
<string name="common_select_your_server">"Select your server"</string> <string name="common_select_your_server">"Select your server"</string>
<string name="common_sending">"Sending…"</string> <string name="common_sending">"Sending…"</string>
<string name="common_server_not_supported">"Server not supported"</string> <string name="common_server_not_supported">"Server not supported"</string>
<string name="common_server_url">"Server URL"</string> <string name="common_server_url">"Server URL"</string>
<string name="common_settings">"Settings"</string> <string name="common_settings">"Settings"</string>
<string name="common_starting_chat">"Starting chat…"</string>
<string name="common_sticker">"Sticker"</string> <string name="common_sticker">"Sticker"</string>
<string name="common_success">"Success"</string> <string name="common_success">"Success"</string>
<string name="common_suggestions">"Suggestions"</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">"You can read all our terms %1$s."</string>
<string name="screen_analytics_settings_read_terms_content_link">"here"</string> <string name="screen_analytics_settings_read_terms_content_link">"here"</string>
<string name="screen_report_content_block_user">"Block user"</string> <string name="screen_report_content_block_user">"Block user"</string>
</resources> </resources>

View file

@ -13,20 +13,16 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
plugins { plugins {
id("io.element.android-compose-library") id("io.element.android-library")
alias(libs.plugins.ksp)
} }
android { android {
namespace = "io.element.android.features.userlist.api" namespace = "io.element.android.libraries.usersearch.api"
} }
dependencies { dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
ksp(libs.showkase.processor)
} }

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.userlist.api package io.element.android.libraries.usersearch.api
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser

View file

@ -14,22 +14,12 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.createroom.impl.di package io.element.android.libraries.usersearch.api
import com.squareup.anvil.annotations.ContributesTo import io.element.android.libraries.matrix.api.user.MatrixUser
import dagger.Binds import kotlinx.coroutines.flow.Flow
import dagger.Module
import io.element.android.features.createroom.impl.AllMatrixUsersDataSource
import io.element.android.features.userlist.api.UserListDataSource
import io.element.android.libraries.di.SessionScope
import javax.inject.Named
@Module interface UserRepository {
@ContributesTo(SessionScope::class)
interface CreateRoomModule {
@Binds
@Named("AllUsers")
fun bindAllUserListDataSource(dataSource: AllMatrixUsersDataSource): UserListDataSource
suspend fun search(query: String): Flow<List<MatrixUser>>
} }

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2022 New Vector Ltd * Copyright (c) 2023 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -15,13 +15,12 @@
*/ */
plugins { plugins {
id("io.element.android-compose-library") id("io.element.android-library")
alias(libs.plugins.anvil) alias(libs.plugins.anvil)
alias(libs.plugins.ksp)
} }
android { android {
namespace = "io.element.android.features.userlist.impl" namespace = "io.element.android.libraries.usersearch.impl"
} }
anvil { anvil {
@ -29,28 +28,18 @@ anvil {
} }
dependencies { dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.di)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.elementresources) api(projects.libraries.usersearch.api)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
api(projects.features.userlist.api)
ksp(libs.showkase.processor)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
testImplementation(libs.coroutines.core)
testImplementation(libs.molecule.runtime) testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(libs.test.mockk)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.userlist.test) testImplementation(projects.libraries.usersearch.test)
androidTestImplementation(libs.test.junitext)
} }

View file

@ -14,15 +14,18 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.createroom.impl package io.element.android.libraries.usersearch.impl
import io.element.android.features.userlist.api.UserListDataSource 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.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserListDataSource
import javax.inject.Inject import javax.inject.Inject
class AllMatrixUsersDataSource @Inject constructor( @ContributesBinding(SessionScope::class)
class MatrixUserListDataSource @Inject constructor(
private val client: MatrixClient private val client: MatrixClient
) : UserListDataSource { ) : UserListDataSource {
override suspend fun search(query: String): List<MatrixUser> { override suspend fun search(query: String): List<MatrixUser> {

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

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.createroom.impl package io.element.android.libraries.usersearch.impl
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -28,7 +28,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
internal class AllMatrixUsersDataSourceTest { internal class MatrixUserListDataSourceTest {
@Test @Test
fun `search - returns users on success`() = runTest { fun `search - returns users on success`() = runTest {
@ -37,15 +37,21 @@ internal class AllMatrixUsersDataSourceTest {
searchTerm = "test", searchTerm = "test",
result = Result.success( result = Result.success(
MatrixSearchUserResults( MatrixSearchUserResults(
results = listOf(aMatrixUserProfile(), aMatrixUserProfile(userId = A_USER_ID_2)), results = listOf(
aMatrixUserProfile(),
aMatrixUserProfile(userId = A_USER_ID_2)
),
limited = false limited = false
) )
) )
) )
val dataSource = AllMatrixUsersDataSource(matrixClient) val dataSource = MatrixUserListDataSource(matrixClient)
val results = dataSource.search("test") val results = dataSource.search("test")
Truth.assertThat(results).containsExactly(aMatrixUserProfile(), aMatrixUserProfile(userId = A_USER_ID_2)) Truth.assertThat(results).containsExactly(
aMatrixUserProfile(),
aMatrixUserProfile(userId = A_USER_ID_2)
)
} }
@Test @Test
@ -55,7 +61,7 @@ internal class AllMatrixUsersDataSourceTest {
searchTerm = "test", searchTerm = "test",
result = Result.failure(Throwable("Ruhroh")) result = Result.failure(Throwable("Ruhroh"))
) )
val dataSource = AllMatrixUsersDataSource(matrixClient) val dataSource = MatrixUserListDataSource(matrixClient)
val results = dataSource.search("test") val results = dataSource.search("test")
Truth.assertThat(results).isEmpty() Truth.assertThat(results).isEmpty()
@ -68,7 +74,7 @@ internal class AllMatrixUsersDataSourceTest {
userId = A_USER_ID, userId = A_USER_ID,
result = Result.success(aMatrixUserProfile()) result = Result.success(aMatrixUserProfile())
) )
val dataSource = AllMatrixUsersDataSource(matrixClient) val dataSource = MatrixUserListDataSource(matrixClient)
val result = dataSource.getProfile(A_USER_ID) val result = dataSource.getProfile(A_USER_ID)
Truth.assertThat(result).isEqualTo(aMatrixUserProfile()) Truth.assertThat(result).isEqualTo(aMatrixUserProfile())
@ -81,7 +87,7 @@ internal class AllMatrixUsersDataSourceTest {
userId = A_USER_ID, userId = A_USER_ID,
result = Result.failure(Throwable("Ruhroh")) result = Result.failure(Throwable("Ruhroh"))
) )
val dataSource = AllMatrixUsersDataSource(matrixClient) val dataSource = MatrixUserListDataSource(matrixClient)
val result = dataSource.getProfile(A_USER_ID) val result = dataSource.getProfile(A_USER_ID)
Truth.assertThat(result).isNull() Truth.assertThat(result).isNull()

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

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2022 New Vector Ltd * Copyright (c) 2023 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -13,18 +13,18 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
plugins { plugins {
id("io.element.android-compose-library") id("io.element.android-library")
} }
android { android {
namespace = "io.element.android.features.userlist.test" namespace = "io.element.android.libraries.usersearch"
} }
dependencies { dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
api(projects.features.userlist.api) api(projects.libraries.usersearch.api)
api(libs.coroutines.core)
} }

View file

@ -14,11 +14,11 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.userlist.test package io.element.android.libraries.usersearch.test
import io.element.android.features.userlist.api.UserListDataSource
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserListDataSource
class FakeUserListDataSource : UserListDataSource { class FakeUserListDataSource : UserListDataSource {

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

View file

@ -43,9 +43,7 @@ fun DependencyHandlerScope.composeDependencies(libs: LibrariesForLibs) {
androidTestImplementation(composeBom) androidTestImplementation(composeBom)
implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material:material") implementation("androidx.compose.material:material")
// Override BOM version, SearchBar is not available in the actual version implementation("androidx.compose.material3:material3")
// do not use latest version because of clashes on androidx lifecycle dependency
implementation("androidx.compose.material3:material3:1.1.0-alpha04")
implementation("androidx.compose.material:material-icons-extended") implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.ui:ui-tooling-preview")
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
@ -92,6 +90,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
implementation(project(":libraries:statemachine")) implementation(project(":libraries:statemachine"))
implementation(project(":libraries:mediapickers:impl")) implementation(project(":libraries:mediapickers:impl"))
implementation(project(":libraries:mediaupload:impl")) implementation(project(":libraries:mediaupload:impl"))
implementation(project(":libraries:usersearch:impl"))
} }
fun DependencyHandlerScope.allServicesImpl() { fun DependencyHandlerScope.allServicesImpl() {

View file

@ -14,15 +14,12 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.samples.minimal package io.element.android.samples.minimal
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue

View file

@ -14,12 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.tests.testutils package io.element.android.tests.testutils
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestCoroutineScheduler import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher

Some files were not shown because too many files have changed in this diff Show more