Merge develop into feature/fga/image_loading
This commit is contained in:
commit
affb928801
602 changed files with 3434 additions and 1627 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -53,6 +53,8 @@ captures/
|
||||||
# Android Studio 3 in .gitignore file.
|
# Android Studio 3 in .gitignore file.
|
||||||
.idea/caches
|
.idea/caches
|
||||||
.idea/inspectionProfiles
|
.idea/inspectionProfiles
|
||||||
|
# Shelved changes in the IDE
|
||||||
|
.idea/shelf
|
||||||
|
|
||||||
# Keystore files
|
# Keystore files
|
||||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||||
|
|
|
||||||
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="KotlinJpsPluginSettings">
|
<component name="KotlinJpsPluginSettings">
|
||||||
<option name="version" value="1.8.20" />
|
<option name="version" value="1.8.21" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
|
|
@ -69,4 +69,6 @@ dependencies {
|
||||||
testImplementation(projects.services.appnavstate.test)
|
testImplementation(projects.services.appnavstate.test)
|
||||||
testImplementation(libs.test.appyx.junit)
|
testImplementation(libs.test.appyx.junit)
|
||||||
testImplementation(libs.test.arch.core)
|
testImplementation(libs.test.arch.core)
|
||||||
|
|
||||||
|
ksp(libs.showkase.processor)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import org.jetbrains.kotlin.cli.common.toBooleanLenient
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.20")
|
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.21")
|
||||||
classpath("com.google.gms:google-services:4.3.15")
|
classpath("com.google.gms:google-services:4.3.15")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
changelog.d/238.feature
Normal file
1
changelog.d/238.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
[Create and join rooms] New invites are now marked with a badge
|
||||||
1
changelog.d/254.feature
Normal file
1
changelog.d/254.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
[Create and join rooms] Improve user search results calling the "profile" API
|
||||||
1
changelog.d/360.feature
Normal file
1
changelog.d/360.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Add media pickers to the room screen.
|
||||||
1
changelog.d/95.feature
Normal file
1
changelog.d/95.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
[Create and join rooms] Search for users to start a DM
|
||||||
|
|
@ -426,6 +426,7 @@ Rageshake can be very useful to get logs from a release version of the applicati
|
||||||
- When this is possible, prefer using `sealed interface` instead of `sealed class`;
|
- When this is possible, prefer using `sealed interface` instead of `sealed class`;
|
||||||
- When writing temporary code, using the string "DO NOT COMMIT" in a comment can help to avoid committing things by mistake. If committed and pushed, the CI
|
- When writing temporary code, using the string "DO NOT COMMIT" in a comment can help to avoid committing things by mistake. If committed and pushed, the CI
|
||||||
will detect this String and will warn the user about it. (TODO Not supported yet!)
|
will detect this String and will warn the user about it. (TODO Not supported yet!)
|
||||||
|
- Very occasionally the gradle cache misbehaves and causes problems with Dagger. Try building with `--no-build-cache` if Dagger isn't behaving how you expect.
|
||||||
|
|
||||||
## Happy coding!
|
## Happy coding!
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,17 +17,24 @@
|
||||||
package io.element.android.features.createroom.impl
|
package io.element.android.features.createroom.impl
|
||||||
|
|
||||||
import io.element.android.features.userlist.api.UserListDataSource
|
import io.element.android.features.userlist.api.UserListDataSource
|
||||||
|
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.ui.model.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
// TODO this is empty as we currently don't have an endpoint to perform user search
|
class AllMatrixUsersDataSource @Inject constructor(
|
||||||
class AllMatrixUsersDataSource @Inject constructor() : UserListDataSource {
|
private val client: MatrixClient
|
||||||
|
) : UserListDataSource {
|
||||||
override suspend fun search(query: String): List<MatrixUser> {
|
override suspend fun search(query: String): List<MatrixUser> {
|
||||||
return emptyList()
|
val res = client.searchUsers(query, MAX_SEARCH_RESULTS)
|
||||||
|
return res.getOrNull()?.results.orEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getProfile(userId: UserId): MatrixUser? {
|
override suspend fun getProfile(userId: UserId): MatrixUser? {
|
||||||
return null
|
return client.getProfile(userId).getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MAX_SEARCH_RESULTS = 5L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
package io.element.android.features.createroom.impl
|
package io.element.android.features.createroom.impl
|
||||||
|
|
||||||
import io.element.android.features.createroom.impl.configureroom.RoomPrivacy
|
import io.element.android.features.createroom.impl.configureroom.RoomPrivacy
|
||||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,11 @@ class AddPeoplePresenter @Inject constructor(
|
||||||
|
|
||||||
private val userListPresenter by lazy {
|
private val userListPresenter by lazy {
|
||||||
userListPresenterFactory.create(
|
userListPresenterFactory.create(
|
||||||
UserListPresenterArgs(selectionMode = SelectionMode.Multiple),
|
UserListPresenterArgs(
|
||||||
|
selectionMode = SelectionMode.Multiple,
|
||||||
|
minimumSearchLength = 3,
|
||||||
|
searchDebouncePeriodMillis = UserListPresenterArgs.DEFAULT_DEBOUNCE
|
||||||
|
),
|
||||||
userListDataSource,
|
userListDataSource,
|
||||||
dataStore.selectedUserListDataStore,
|
dataStore.selectedUserListDataStore,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ 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.userlist.api.SelectionMode
|
||||||
import io.element.android.features.userlist.api.UserListState
|
import io.element.android.features.userlist.api.UserListState
|
||||||
|
import io.element.android.features.userlist.api.UserSearchResultState
|
||||||
import io.element.android.features.userlist.api.aListOfSelectedUsers
|
import io.element.android.features.userlist.api.aListOfSelectedUsers
|
||||||
import io.element.android.features.userlist.api.aUserListState
|
import io.element.android.features.userlist.api.aUserListState
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||||
|
|
@ -29,13 +30,13 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListSta
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aUserListState(),
|
aUserListState(),
|
||||||
aUserListState().copy(
|
aUserListState().copy(
|
||||||
searchResults = aMatrixUserList().toImmutableList(),
|
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()),
|
||||||
selectedUsers = aListOfSelectedUsers(),
|
selectedUsers = aListOfSelectedUsers(),
|
||||||
isSearchActive = false,
|
isSearchActive = false,
|
||||||
selectionMode = SelectionMode.Multiple,
|
selectionMode = SelectionMode.Multiple,
|
||||||
),
|
),
|
||||||
aUserListState().copy(
|
aUserListState().copy(
|
||||||
searchResults = aMatrixUserList().toImmutableList(),
|
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()),
|
||||||
selectedUsers = aListOfSelectedUsers(),
|
selectedUsers = aListOfSelectedUsers(),
|
||||||
isSearchActive = true,
|
isSearchActive = true,
|
||||||
selectionMode = SelectionMode.Multiple,
|
selectionMode = SelectionMode.Multiple,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ package io.element.android.features.createroom.impl.configureroom
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import io.element.android.features.createroom.impl.CreateRoomConfig
|
import io.element.android.features.createroom.impl.CreateRoomConfig
|
||||||
import io.element.android.libraries.matrix.ui.model.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
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||||
isDirect = false,
|
isDirect = false,
|
||||||
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.id },
|
invite = config.invites.map { it.userId },
|
||||||
avatar = config.avatarUrl,
|
avatar = config.avatarUrl,
|
||||||
)
|
)
|
||||||
matrixClient.createRoom(params).getOrThrow()
|
matrixClient.createRoom(params).getOrThrow()
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.createroom.impl.configureroom
|
package io.element.android.features.createroom.impl.configureroom
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
|
||||||
data class ConfigureRoomPresenterArgs(
|
data class ConfigureRoomPresenterArgs(
|
||||||
val selectedUsers: List<MatrixUser>,
|
val selectedUsers: List<MatrixUser>,
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,11 @@ import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import io.element.android.features.createroom.impl.AllMatrixUsersDataSource
|
import io.element.android.features.createroom.impl.AllMatrixUsersDataSource
|
||||||
import io.element.android.features.userlist.api.UserListDataSource
|
import io.element.android.features.userlist.api.UserListDataSource
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
import javax.inject.Named
|
import javax.inject.Named
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@ContributesTo(AppScope::class)
|
@ContributesTo(SessionScope::class)
|
||||||
interface CreateRoomModule {
|
interface CreateRoomModule {
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.createroom.impl.root
|
package io.element.android.features.createroom.impl.root
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
|
||||||
sealed interface CreateRoomRootEvents {
|
sealed interface CreateRoomRootEvents {
|
||||||
object InvitePeople : CreateRoomRootEvents
|
object InvitePeople : CreateRoomRootEvents
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ 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.ui.model.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -46,7 +46,11 @@ class CreateRoomRootPresenter @Inject constructor(
|
||||||
|
|
||||||
private val presenter by lazy {
|
private val presenter by lazy {
|
||||||
presenterFactory.create(
|
presenterFactory.create(
|
||||||
UserListPresenterArgs(selectionMode = SelectionMode.Single),
|
UserListPresenterArgs(
|
||||||
|
selectionMode = SelectionMode.Single,
|
||||||
|
minimumSearchLength = 3,
|
||||||
|
searchDebouncePeriodMillis = UserListPresenterArgs.DEFAULT_DEBOUNCE,
|
||||||
|
),
|
||||||
userListDataSource,
|
userListDataSource,
|
||||||
userListDataStore,
|
userListDataStore,
|
||||||
)
|
)
|
||||||
|
|
@ -61,7 +65,7 @@ class CreateRoomRootPresenter @Inject constructor(
|
||||||
|
|
||||||
fun startDm(matrixUser: MatrixUser) {
|
fun startDm(matrixUser: MatrixUser) {
|
||||||
startDmAction.value = Async.Uninitialized
|
startDmAction.value = Async.Uninitialized
|
||||||
val existingDM = matrixClient.findDM(matrixUser.id)
|
val existingDM = matrixClient.findDM(matrixUser.userId)
|
||||||
if (existingDM == null) {
|
if (existingDM == null) {
|
||||||
localCoroutineScope.createDM(matrixUser, startDmAction)
|
localCoroutineScope.createDM(matrixUser, startDmAction)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -86,7 +90,7 @@ class CreateRoomRootPresenter @Inject constructor(
|
||||||
|
|
||||||
private fun CoroutineScope.createDM(user: MatrixUser, startDmAction: MutableState<Async<RoomId>>) = launch {
|
private fun CoroutineScope.createDM(user: MatrixUser, startDmAction: MutableState<Async<RoomId>>) = launch {
|
||||||
suspend {
|
suspend {
|
||||||
matrixClient.createDM(user.id).getOrThrow()
|
matrixClient.createDM(user.userId).getOrThrow()
|
||||||
}.execute(startDmAction)
|
}.execute(startDmAction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
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.userlist.api.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.matrix.ui.components.aMatrixUser
|
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||||
|
|
@ -30,8 +31,8 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
|
||||||
startDmAction = Async.Loading(),
|
startDmAction = Async.Loading(),
|
||||||
userListState = aMatrixUser().let {
|
userListState = aMatrixUser().let {
|
||||||
aUserListState().copy(
|
aUserListState().copy(
|
||||||
searchQuery = it.id.value,
|
searchQuery = it.userId.value,
|
||||||
searchResults = persistentListOf(it),
|
searchResults = UserSearchResultState.Results(persistentListOf(it)),
|
||||||
selectedUsers = persistentListOf(it),
|
selectedUsers = persistentListOf(it),
|
||||||
isSearchActive = true,
|
isSearchActive = true,
|
||||||
)
|
)
|
||||||
|
|
@ -41,8 +42,8 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
|
||||||
startDmAction = Async.Failure(Throwable()),
|
startDmAction = Async.Failure(Throwable()),
|
||||||
userListState = aMatrixUser().let {
|
userListState = aMatrixUser().let {
|
||||||
aUserListState().copy(
|
aUserListState().copy(
|
||||||
searchQuery = it.id.value,
|
searchQuery = it.userId.value,
|
||||||
searchResults = persistentListOf(it),
|
searchResults = UserSearchResultState.Results(persistentListOf(it)),
|
||||||
selectedUsers = persistentListOf(it),
|
selectedUsers = persistentListOf(it),
|
||||||
isSearchActive = true,
|
isSearchActive = true,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
|
|
||||||
package io.element.android.features.createroom.impl.root
|
package io.element.android.features.createroom.impl.root
|
||||||
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.annotation.DrawableRes
|
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
|
||||||
|
|
@ -88,10 +87,7 @@ fun CreateRoomRootView(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
state = state.userListState,
|
state = state.userListState,
|
||||||
onUserSelected = {
|
onUserSelected = {
|
||||||
// Fixme disabled DM creation since it can break the account data which is not correctly synced
|
state.eventSink(CreateRoomRootEvents.StartDM(it))
|
||||||
// uncomment to enable it again or move behind a feature flag
|
|
||||||
Toast.makeText(context, "Create DM feature is disabled.", Toast.LENGTH_SHORT).show()
|
|
||||||
// state.eventSink(CreateRoomRootEvents.StartDM(it))
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -108,6 +104,7 @@ fun CreateRoomRootView(
|
||||||
is Async.Loading -> {
|
is Async.Loading -> {
|
||||||
ProgressDialog(text = stringResource(id = StringR.string.common_creating_room))
|
ProgressDialog(text = stringResource(id = StringR.string.common_creating_room))
|
||||||
}
|
}
|
||||||
|
|
||||||
is Async.Failure -> {
|
is Async.Failure -> {
|
||||||
RetryDialog(
|
RetryDialog(
|
||||||
content = stringResource(id = R.string.screen_start_chat_error_starting_chat),
|
content = stringResource(id = R.string.screen_start_chat_error_starting_chat),
|
||||||
|
|
@ -120,6 +117,7 @@ fun CreateRoomRootView(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
<string name="screen_create_room_action_create_room">"Neuer Raum"</string>
|
<string name="screen_create_room_action_create_room">"Neuer Raum"</string>
|
||||||
|
<string name="screen_create_room_add_people_title">"Personen hinzufügen"</string>
|
||||||
<string name="screen_create_room_private_option_title">"Privater Raum (nur auf Einladung)"</string>
|
<string name="screen_create_room_private_option_title">"Privater Raum (nur auf Einladung)"</string>
|
||||||
<string name="screen_create_room_room_name_label">"Raumname"</string>
|
<string name="screen_create_room_room_name_label">"Raumname"</string>
|
||||||
<string name="screen_create_room_topic_label">"Thema (optional)"</string>
|
<string name="screen_create_room_topic_label">"Thema (optional)"</string>
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,9 @@
|
||||||
<string name="screen_create_room_public_option_title">"Public room (anyone)"</string>
|
<string name="screen_create_room_public_option_title">"Public room (anyone)"</string>
|
||||||
<string name="screen_create_room_room_name_label">"Room name"</string>
|
<string name="screen_create_room_room_name_label">"Room name"</string>
|
||||||
<string name="screen_create_room_room_name_placeholder">"e.g. Product Sprint"</string>
|
<string name="screen_create_room_room_name_placeholder">"e.g. Product Sprint"</string>
|
||||||
<string name="screen_create_room_title">"Create a room"</string>
|
|
||||||
<string name="screen_create_room_topic_label">"Topic (optional)"</string>
|
<string name="screen_create_room_topic_label">"Topic (optional)"</string>
|
||||||
<string name="screen_create_room_topic_placeholder">"What is this room about?"</string>
|
<string name="screen_create_room_topic_placeholder">"What is this room about?"</string>
|
||||||
<string name="screen_start_chat_error_starting_chat">"An error occurred when trying to start a chat"</string>
|
<string name="screen_start_chat_error_starting_chat">"An error occurred when trying to start a chat"</string>
|
||||||
<string name="screen_start_chat_unknown_profile">"We can’t validate this user’s Matrix ID. The invite might not be received."</string>
|
<string name="screen_start_chat_unknown_profile">"We can’t validate this user’s Matrix ID. The invite might not be received."</string>
|
||||||
|
<string name="screen_create_room_title">"Create a room"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
internal class AllMatrixUsersDataSourceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search - returns users on success`() = runTest {
|
||||||
|
val matrixClient = FakeMatrixClient()
|
||||||
|
matrixClient.givenSearchUsersResult(
|
||||||
|
searchTerm = "test",
|
||||||
|
result = Result.success(
|
||||||
|
MatrixSearchUserResults(
|
||||||
|
results = listOf(aMatrixUserProfile(), aMatrixUserProfile(userId = A_USER_ID_2)),
|
||||||
|
limited = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val dataSource = AllMatrixUsersDataSource(matrixClient)
|
||||||
|
|
||||||
|
val results = dataSource.search("test")
|
||||||
|
Truth.assertThat(results).containsExactly(aMatrixUserProfile(), aMatrixUserProfile(userId = A_USER_ID_2))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `search - returns empty list on error`() = runTest {
|
||||||
|
val matrixClient = FakeMatrixClient()
|
||||||
|
matrixClient.givenSearchUsersResult(
|
||||||
|
searchTerm = "test",
|
||||||
|
result = Result.failure(Throwable("Ruhroh"))
|
||||||
|
)
|
||||||
|
val dataSource = AllMatrixUsersDataSource(matrixClient)
|
||||||
|
|
||||||
|
val results = dataSource.search("test")
|
||||||
|
Truth.assertThat(results).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `get profile - returns user on success`() = runTest {
|
||||||
|
val matrixClient = FakeMatrixClient()
|
||||||
|
matrixClient.givenGetProfileResult(
|
||||||
|
userId = A_USER_ID,
|
||||||
|
result = Result.success(aMatrixUserProfile())
|
||||||
|
)
|
||||||
|
val dataSource = AllMatrixUsersDataSource(matrixClient)
|
||||||
|
|
||||||
|
val result = dataSource.getProfile(A_USER_ID)
|
||||||
|
Truth.assertThat(result).isEqualTo(aMatrixUserProfile())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `get profile - returns null on error`() = runTest {
|
||||||
|
val matrixClient = FakeMatrixClient()
|
||||||
|
matrixClient.givenGetProfileResult(
|
||||||
|
userId = A_USER_ID,
|
||||||
|
result = Result.failure(Throwable("Ruhroh"))
|
||||||
|
)
|
||||||
|
val dataSource = AllMatrixUsersDataSource(matrixClient)
|
||||||
|
|
||||||
|
val result = dataSource.getProfile(A_USER_ID)
|
||||||
|
Truth.assertThat(result).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun aMatrixUserProfile(
|
||||||
|
userId: UserId = A_USER_ID,
|
||||||
|
displayName: String = A_USER_NAME,
|
||||||
|
avatarUrl: String = AN_AVATAR_URL
|
||||||
|
) = MatrixUser(userId, displayName, avatarUrl)
|
||||||
|
}
|
||||||
|
|
@ -30,10 +30,10 @@ 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
|
||||||
|
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.matrix.ui.model.MatrixUser
|
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.invitelist.api
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface SeenInvitesStore {
|
||||||
|
fun seenRoomIds(): Flow<Set<RoomId>>
|
||||||
|
suspend fun markAsSeen(roomIds: Set<RoomId>)
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,7 @@ dependencies {
|
||||||
implementation(projects.anvilannotations)
|
implementation(projects.anvilannotations)
|
||||||
anvil(projects.anvilcodegen)
|
anvil(projects.anvilcodegen)
|
||||||
api(projects.features.invitelist.api)
|
api(projects.features.invitelist.api)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
|
|
@ -48,6 +49,7 @@ 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.invitelist.test)
|
||||||
|
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
/*
|
||||||
|
* 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.invitelist.impl
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.features.invitelist.api.SeenInvitesStore
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_seeninvites")
|
||||||
|
private val seenInvitesKey = stringSetPreferencesKey("seenInvites")
|
||||||
|
|
||||||
|
|
||||||
|
@ContributesBinding(SessionScope::class)
|
||||||
|
class DefaultSeenInvitesStore @Inject constructor(
|
||||||
|
@ApplicationContext context: Context
|
||||||
|
) : SeenInvitesStore {
|
||||||
|
|
||||||
|
private val store = context.dataStore
|
||||||
|
|
||||||
|
override fun seenRoomIds(): Flow<Set<RoomId>> =
|
||||||
|
store.data.map { prefs ->
|
||||||
|
prefs[seenInvitesKey]
|
||||||
|
.orEmpty()
|
||||||
|
.map { RoomId(it) }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun markAsSeen(roomIds: Set<RoomId>) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
prefs[seenInvitesKey] = roomIds.map { it.value }.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -17,12 +17,15 @@
|
||||||
package io.element.android.features.invitelist.impl
|
package io.element.android.features.invitelist.impl
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
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 androidx.compose.runtime.setValue
|
||||||
|
import io.element.android.features.invitelist.api.SeenInvitesStore
|
||||||
import io.element.android.features.invitelist.impl.model.InviteListInviteSummary
|
import io.element.android.features.invitelist.impl.model.InviteListInviteSummary
|
||||||
import io.element.android.features.invitelist.impl.model.InviteSender
|
import io.element.android.features.invitelist.impl.model.InviteSender
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
|
|
@ -34,11 +37,13 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.room.RoomSummary
|
import io.element.android.libraries.matrix.api.room.RoomSummary
|
||||||
import kotlinx.collections.immutable.toPersistentList
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class InviteListPresenter @Inject constructor(
|
class InviteListPresenter @Inject constructor(
|
||||||
private val client: MatrixClient,
|
private val client: MatrixClient,
|
||||||
|
private val store: SeenInvitesStore,
|
||||||
) : Presenter<InviteListState> {
|
) : Presenter<InviteListState> {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -48,6 +53,21 @@ class InviteListPresenter @Inject constructor(
|
||||||
.roomSummaries()
|
.roomSummaries()
|
||||||
.collectAsState()
|
.collectAsState()
|
||||||
|
|
||||||
|
var seenInvites by remember { mutableStateOf<Set<RoomId>>(emptySet()) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
seenInvites = store.seenRoomIds().first()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(invites) {
|
||||||
|
store.markAsSeen(
|
||||||
|
invites
|
||||||
|
.filterIsInstance<RoomSummary.Filled>()
|
||||||
|
.map { it.details.roomId }
|
||||||
|
.toSet()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
val acceptedAction: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) }
|
val acceptedAction: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) }
|
||||||
val declinedAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
|
val declinedAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
|
||||||
|
|
@ -86,8 +106,17 @@ class InviteListPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val inviteList = remember(seenInvites, invites) {
|
||||||
|
invites
|
||||||
|
.filterIsInstance<RoomSummary.Filled>()
|
||||||
|
.map {
|
||||||
|
it.toInviteSummary(seenInvites.contains(it.details.roomId))
|
||||||
|
}
|
||||||
|
.toPersistentList()
|
||||||
|
}
|
||||||
|
|
||||||
return InviteListState(
|
return InviteListState(
|
||||||
inviteList = invites.mapNotNull(::toInviteSummary).toPersistentList(),
|
inviteList = inviteList,
|
||||||
declineConfirmationDialog = decliningInvite.value?.let {
|
declineConfirmationDialog = decliningInvite.value?.let {
|
||||||
InviteDeclineConfirmationDialog.Visible(
|
InviteDeclineConfirmationDialog.Visible(
|
||||||
isDirect = it.isDirect,
|
isDirect = it.isDirect,
|
||||||
|
|
@ -113,49 +142,44 @@ class InviteListPresenter @Inject constructor(
|
||||||
}.execute(declinedAction)
|
}.execute(declinedAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun toInviteSummary(roomSummary: RoomSummary): InviteListInviteSummary? {
|
private fun RoomSummary.Filled.toInviteSummary(seen: Boolean) = details.run {
|
||||||
return when (roomSummary) {
|
val i = inviter
|
||||||
is RoomSummary.Filled -> roomSummary.details.run {
|
val avatarData = if (isDirect && i != null)
|
||||||
val i = inviter
|
AvatarData(
|
||||||
val avatarData = if (isDirect && i != null)
|
id = i.userId.value,
|
||||||
AvatarData(
|
name = i.displayName,
|
||||||
id = i.userId.value,
|
url = i.avatarUrl,
|
||||||
name = i.displayName,
|
)
|
||||||
url = i.avatarUrl,
|
else
|
||||||
)
|
AvatarData(
|
||||||
else
|
id = roomId.value,
|
||||||
AvatarData(
|
name = name,
|
||||||
id = roomId.value,
|
url = avatarURLString
|
||||||
name = name,
|
)
|
||||||
url = avatarURLString
|
|
||||||
)
|
|
||||||
|
|
||||||
val alias = if (isDirect)
|
val alias = if (isDirect)
|
||||||
inviter?.userId?.value
|
inviter?.userId?.value
|
||||||
else
|
else
|
||||||
canonicalAlias
|
canonicalAlias
|
||||||
|
|
||||||
InviteListInviteSummary(
|
InviteListInviteSummary(
|
||||||
roomId = roomId,
|
roomId = roomId,
|
||||||
roomName = name,
|
roomName = name,
|
||||||
roomAlias = alias,
|
roomAlias = alias,
|
||||||
roomAvatarData = avatarData,
|
roomAvatarData = avatarData,
|
||||||
isDirect = isDirect,
|
isDirect = isDirect,
|
||||||
sender = if (isDirect) null else inviter?.let {
|
isNew = !seen,
|
||||||
InviteSender(
|
sender = if (isDirect) null else inviter?.run {
|
||||||
userId = it.userId,
|
InviteSender(
|
||||||
displayName = it.displayName ?: "",
|
userId = userId,
|
||||||
avatarData = AvatarData(
|
displayName = displayName ?: "",
|
||||||
id = it.userId.value,
|
avatarData = AvatarData(
|
||||||
name = it.displayName,
|
id = userId.value,
|
||||||
url = it.avatarUrl,
|
name = displayName,
|
||||||
),
|
url = avatarUrl,
|
||||||
)
|
),
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
|
)
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.invitelist.impl.components
|
package io.element.android.features.invitelist.impl.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
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.IntrinsicSize
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
|
@ -27,13 +28,17 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.text.InlineTextContent
|
import androidx.compose.foundation.text.InlineTextContent
|
||||||
import androidx.compose.foundation.text.appendInlineContent
|
import androidx.compose.foundation.text.appendInlineContent
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
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.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.Placeholder
|
import androidx.compose.ui.text.Placeholder
|
||||||
|
|
@ -59,6 +64,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Button
|
import io.element.android.libraries.designsystem.theme.components.Button
|
||||||
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||||
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.roomListUnreadIndicator
|
||||||
import kotlinx.collections.immutable.persistentMapOf
|
import kotlinx.collections.immutable.persistentMapOf
|
||||||
import io.element.android.libraries.ui.strings.R as StringR
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
|
|
@ -73,8 +79,8 @@ internal fun InviteSummaryRow(
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = minHeight)
|
.heightIn(min = minHeight)
|
||||||
) {
|
) {
|
||||||
DefaultInviteSummaryRow(
|
DefaultInviteSummaryRow(
|
||||||
invite = invite,
|
invite = invite,
|
||||||
|
|
@ -92,19 +98,20 @@ internal fun DefaultInviteSummaryRow(
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
.height(IntrinsicSize.Min),
|
.height(IntrinsicSize.Min),
|
||||||
verticalAlignment = Alignment.Top
|
verticalAlignment = Alignment.Top
|
||||||
) {
|
) {
|
||||||
Avatar(
|
Avatar(
|
||||||
invite.roomAvatarData,
|
invite.roomAvatarData,
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(start = 12.dp, end = 4.dp)
|
.padding(start = 12.dp, end = 4.dp)
|
||||||
.alignByBaseline()
|
.alignByBaseline()
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
) {
|
) {
|
||||||
// Name
|
// Name
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -152,6 +159,15 @@ internal fun DefaultInviteSummaryRow(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val unreadIndicatorColor = if (invite.isNew) MaterialTheme.roomListUnreadIndicator() else Color.Transparent
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(12.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(unreadIndicatorColor)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,7 +199,11 @@ private fun SenderRow(sender: InviteSender) {
|
||||||
Placeholder(20.dp.toSp(), 20.dp.toSp(), PlaceholderVerticalAlign.Center)
|
Placeholder(20.dp.toSp(), 20.dp.toSp(), PlaceholderVerticalAlign.Center)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Box(Modifier.fillMaxHeight().padding(end = 4.dp)) {
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(end = 4.dp)
|
||||||
|
) {
|
||||||
Avatar(
|
Avatar(
|
||||||
avatarData = sender.avatarData.copy(size = AvatarSize.Custom(16.dp)),
|
avatarData = sender.avatarData.copy(size = AvatarSize.Custom(16.dp)),
|
||||||
modifier = Modifier.align(Alignment.Center)
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,8 @@ data class InviteListInviteSummary(
|
||||||
val roomAlias: String? = null,
|
val roomAlias: String? = null,
|
||||||
val roomAvatarData: AvatarData = AvatarData(roomId.value, roomName),
|
val roomAvatarData: AvatarData = AvatarData(roomId.value, roomName),
|
||||||
val sender: InviteSender? = null,
|
val sender: InviteSender? = null,
|
||||||
val isDirect: Boolean = false
|
val isDirect: Boolean = false,
|
||||||
|
val isNew: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class InviteSender(
|
data class InviteSender(
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ open class InviteListInviteSummaryProvider : PreviewParameterProvider<InviteList
|
||||||
aInviteListInviteSummary(),
|
aInviteListInviteSummary(),
|
||||||
aInviteListInviteSummary().copy(roomAlias = "#someroom:example.com"),
|
aInviteListInviteSummary().copy(roomAlias = "#someroom:example.com"),
|
||||||
aInviteListInviteSummary().copy(roomName = "Alice", sender = null),
|
aInviteListInviteSummary().copy(roomName = "Alice", sender = null),
|
||||||
|
aInviteListInviteSummary().copy(isNew = true)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ 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
|
import com.google.common.truth.Truth
|
||||||
|
import io.element.android.features.invitelist.test.FakeSeenInvitesStore
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
@ -29,6 +30,7 @@ import io.element.android.libraries.matrix.api.room.RoomSummary
|
||||||
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
|
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
|
||||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
|
@ -50,7 +52,8 @@ class InviteListPresenterTests {
|
||||||
FakeMatrixClient(
|
FakeMatrixClient(
|
||||||
sessionId = A_SESSION_ID,
|
sessionId = A_SESSION_ID,
|
||||||
invitesDataSource = invitesDataSource,
|
invitesDataSource = invitesDataSource,
|
||||||
)
|
),
|
||||||
|
FakeSeenInvitesStore(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -58,21 +61,7 @@ class InviteListPresenterTests {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
Truth.assertThat(initialState.inviteList).isEmpty()
|
Truth.assertThat(initialState.inviteList).isEmpty()
|
||||||
|
|
||||||
invitesDataSource.postRoomSummary(
|
invitesDataSource.postRoomSummary(listOf(aRoomSummary()))
|
||||||
listOf(
|
|
||||||
RoomSummary.Filled(
|
|
||||||
RoomSummaryDetails(
|
|
||||||
roomId = A_ROOM_ID,
|
|
||||||
name = A_ROOM_NAME,
|
|
||||||
avatarURLString = null,
|
|
||||||
isDirect = false,
|
|
||||||
lastMessage = null,
|
|
||||||
lastMessageTimestamp = null,
|
|
||||||
unreadNotificationCount = 0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val withInviteState = awaitItem()
|
val withInviteState = awaitItem()
|
||||||
Truth.assertThat(withInviteState.inviteList.size).isEqualTo(1)
|
Truth.assertThat(withInviteState.inviteList.size).isEqualTo(1)
|
||||||
|
|
@ -88,7 +77,8 @@ class InviteListPresenterTests {
|
||||||
FakeMatrixClient(
|
FakeMatrixClient(
|
||||||
sessionId = A_SESSION_ID,
|
sessionId = A_SESSION_ID,
|
||||||
invitesDataSource = invitesDataSource,
|
invitesDataSource = invitesDataSource,
|
||||||
)
|
),
|
||||||
|
FakeSeenInvitesStore(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -117,7 +107,8 @@ class InviteListPresenterTests {
|
||||||
FakeMatrixClient(
|
FakeMatrixClient(
|
||||||
sessionId = A_SESSION_ID,
|
sessionId = A_SESSION_ID,
|
||||||
invitesDataSource = invitesDataSource,
|
invitesDataSource = invitesDataSource,
|
||||||
)
|
),
|
||||||
|
FakeSeenInvitesStore(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -144,7 +135,8 @@ class InviteListPresenterTests {
|
||||||
FakeMatrixClient(
|
FakeMatrixClient(
|
||||||
sessionId = A_SESSION_ID,
|
sessionId = A_SESSION_ID,
|
||||||
invitesDataSource = invitesDataSource,
|
invitesDataSource = invitesDataSource,
|
||||||
)
|
),
|
||||||
|
FakeSeenInvitesStore(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -169,7 +161,8 @@ class InviteListPresenterTests {
|
||||||
FakeMatrixClient(
|
FakeMatrixClient(
|
||||||
sessionId = A_SESSION_ID,
|
sessionId = A_SESSION_ID,
|
||||||
invitesDataSource = invitesDataSource,
|
invitesDataSource = invitesDataSource,
|
||||||
)
|
),
|
||||||
|
FakeSeenInvitesStore(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -194,7 +187,8 @@ class InviteListPresenterTests {
|
||||||
FakeMatrixClient(
|
FakeMatrixClient(
|
||||||
sessionId = A_SESSION_ID,
|
sessionId = A_SESSION_ID,
|
||||||
invitesDataSource = invitesDataSource,
|
invitesDataSource = invitesDataSource,
|
||||||
)
|
),
|
||||||
|
FakeSeenInvitesStore(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -219,7 +213,7 @@ class InviteListPresenterTests {
|
||||||
invitesDataSource = invitesDataSource,
|
invitesDataSource = invitesDataSource,
|
||||||
)
|
)
|
||||||
val room = FakeMatrixRoom()
|
val room = FakeMatrixRoom()
|
||||||
val presenter = InviteListPresenter(client)
|
val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
|
||||||
client.givenGetRoomResult(A_ROOM_ID, room)
|
client.givenGetRoomResult(A_ROOM_ID, room)
|
||||||
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
|
@ -246,7 +240,7 @@ class InviteListPresenterTests {
|
||||||
invitesDataSource = invitesDataSource,
|
invitesDataSource = invitesDataSource,
|
||||||
)
|
)
|
||||||
val room = FakeMatrixRoom()
|
val room = FakeMatrixRoom()
|
||||||
val presenter = InviteListPresenter(client)
|
val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
|
||||||
val ex = Throwable("Ruh roh!")
|
val ex = Throwable("Ruh roh!")
|
||||||
room.givenRejectInviteResult(Result.failure(ex))
|
room.givenRejectInviteResult(Result.failure(ex))
|
||||||
client.givenGetRoomResult(A_ROOM_ID, room)
|
client.givenGetRoomResult(A_ROOM_ID, room)
|
||||||
|
|
@ -278,7 +272,7 @@ class InviteListPresenterTests {
|
||||||
invitesDataSource = invitesDataSource,
|
invitesDataSource = invitesDataSource,
|
||||||
)
|
)
|
||||||
val room = FakeMatrixRoom()
|
val room = FakeMatrixRoom()
|
||||||
val presenter = InviteListPresenter(client)
|
val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
|
||||||
val ex = Throwable("Ruh roh!")
|
val ex = Throwable("Ruh roh!")
|
||||||
room.givenRejectInviteResult(Result.failure(ex))
|
room.givenRejectInviteResult(Result.failure(ex))
|
||||||
client.givenGetRoomResult(A_ROOM_ID, room)
|
client.givenGetRoomResult(A_ROOM_ID, room)
|
||||||
|
|
@ -311,7 +305,7 @@ class InviteListPresenterTests {
|
||||||
invitesDataSource = invitesDataSource,
|
invitesDataSource = invitesDataSource,
|
||||||
)
|
)
|
||||||
val room = FakeMatrixRoom()
|
val room = FakeMatrixRoom()
|
||||||
val presenter = InviteListPresenter(client)
|
val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
|
||||||
client.givenGetRoomResult(A_ROOM_ID, room)
|
client.givenGetRoomResult(A_ROOM_ID, room)
|
||||||
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
|
@ -335,7 +329,7 @@ class InviteListPresenterTests {
|
||||||
invitesDataSource = invitesDataSource,
|
invitesDataSource = invitesDataSource,
|
||||||
)
|
)
|
||||||
val room = FakeMatrixRoom()
|
val room = FakeMatrixRoom()
|
||||||
val presenter = InviteListPresenter(client)
|
val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
|
||||||
val ex = Throwable("Ruh roh!")
|
val ex = Throwable("Ruh roh!")
|
||||||
room.givenAcceptInviteResult(Result.failure(ex))
|
room.givenAcceptInviteResult(Result.failure(ex))
|
||||||
client.givenGetRoomResult(A_ROOM_ID, room)
|
client.givenGetRoomResult(A_ROOM_ID, room)
|
||||||
|
|
@ -361,7 +355,7 @@ class InviteListPresenterTests {
|
||||||
invitesDataSource = invitesDataSource,
|
invitesDataSource = invitesDataSource,
|
||||||
)
|
)
|
||||||
val room = FakeMatrixRoom()
|
val room = FakeMatrixRoom()
|
||||||
val presenter = InviteListPresenter(client)
|
val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
|
||||||
val ex = Throwable("Ruh roh!")
|
val ex = Throwable("Ruh roh!")
|
||||||
room.givenAcceptInviteResult(Result.failure(ex))
|
room.givenAcceptInviteResult(Result.failure(ex))
|
||||||
client.givenGetRoomResult(A_ROOM_ID, room)
|
client.givenGetRoomResult(A_ROOM_ID, room)
|
||||||
|
|
@ -381,6 +375,71 @@ class InviteListPresenterTests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - stores seen invites when received`() = runTest {
|
||||||
|
val invitesDataSource = FakeRoomSummaryDataSource()
|
||||||
|
val store = FakeSeenInvitesStore()
|
||||||
|
val presenter = InviteListPresenter(
|
||||||
|
FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
invitesDataSource = invitesDataSource,
|
||||||
|
),
|
||||||
|
store,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
awaitItem()
|
||||||
|
|
||||||
|
// When one invite is received, that ID is saved
|
||||||
|
invitesDataSource.postRoomSummary(listOf(aRoomSummary()))
|
||||||
|
|
||||||
|
awaitItem()
|
||||||
|
Truth.assertThat(store.getProvidedRoomIds()).isEqualTo(setOf(A_ROOM_ID))
|
||||||
|
|
||||||
|
// When a second is added, both are saved
|
||||||
|
invitesDataSource.postRoomSummary(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2)))
|
||||||
|
|
||||||
|
awaitItem()
|
||||||
|
Truth.assertThat(store.getProvidedRoomIds()).isEqualTo(setOf(A_ROOM_ID, A_ROOM_ID_2))
|
||||||
|
|
||||||
|
// When they're both dismissed, an empty set is saved
|
||||||
|
invitesDataSource.postRoomSummary(listOf())
|
||||||
|
|
||||||
|
awaitItem()
|
||||||
|
Truth.assertThat(store.getProvidedRoomIds()).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - marks invite as new if they're unseen`() = runTest {
|
||||||
|
val invitesDataSource = FakeRoomSummaryDataSource()
|
||||||
|
val store = FakeSeenInvitesStore()
|
||||||
|
store.publishRoomIds(setOf(A_ROOM_ID))
|
||||||
|
val presenter = InviteListPresenter(
|
||||||
|
FakeMatrixClient(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
invitesDataSource = invitesDataSource,
|
||||||
|
),
|
||||||
|
store,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
awaitItem()
|
||||||
|
|
||||||
|
invitesDataSource.postRoomSummary(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2)))
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
val withInviteState = awaitItem()
|
||||||
|
Truth.assertThat(withInviteState.inviteList.size).isEqualTo(2)
|
||||||
|
Truth.assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID)
|
||||||
|
Truth.assertThat(withInviteState.inviteList[0].isNew).isFalse()
|
||||||
|
Truth.assertThat(withInviteState.inviteList[1].roomId).isEqualTo(A_ROOM_ID_2)
|
||||||
|
Truth.assertThat(withInviteState.inviteList[1].isNew).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun FakeRoomSummaryDataSource.withRoomInvitation(): FakeRoomSummaryDataSource {
|
private suspend fun FakeRoomSummaryDataSource.withRoomInvitation(): FakeRoomSummaryDataSource {
|
||||||
postRoomSummary(
|
postRoomSummary(
|
||||||
listOf(
|
listOf(
|
||||||
|
|
@ -438,4 +497,17 @@ class InviteListPresenterTests {
|
||||||
)
|
)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun aRoomSummary(id: RoomId = A_ROOM_ID) = RoomSummary.Filled(
|
||||||
|
RoomSummaryDetails(
|
||||||
|
roomId = id,
|
||||||
|
name = A_ROOM_NAME,
|
||||||
|
avatarURLString = null,
|
||||||
|
isDirect = false,
|
||||||
|
lastMessage = null,
|
||||||
|
lastMessageTimestamp = null,
|
||||||
|
unreadNotificationCount = 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
features/invitelist/test/build.gradle.kts
Normal file
29
features/invitelist/test/build.gradle.kts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-library")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.features.invitelist.test"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.coroutines.core)
|
||||||
|
implementation(projects.libraries.matrix.api)
|
||||||
|
api(projects.features.invitelist.api)
|
||||||
|
}
|
||||||
|
|
@ -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.features.invitelist.test
|
||||||
|
|
||||||
|
import io.element.android.features.invitelist.api.SeenInvitesStore
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
|
class FakeSeenInvitesStore : SeenInvitesStore {
|
||||||
|
|
||||||
|
private var existing = MutableStateFlow(emptySet<RoomId>())
|
||||||
|
private var provided: Set<RoomId>? = null
|
||||||
|
|
||||||
|
fun publishRoomIds(invites: Set<RoomId>) {
|
||||||
|
existing.value = invites
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getProvidedRoomIds() = provided
|
||||||
|
|
||||||
|
override fun seenRoomIds(): Flow<Set<RoomId>> = existing
|
||||||
|
|
||||||
|
override suspend fun markAsSeen(roomIds: Set<RoomId>) {
|
||||||
|
provided = roomIds.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
*/
|
*/
|
||||||
plugins {
|
plugins {
|
||||||
id("io.element.android-compose-library")
|
id("io.element.android-compose-library")
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
@ -25,4 +26,6 @@ dependencies {
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
|
|
||||||
|
ksp(libs.showkase.processor)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
<string name="screen_signout_confirmation_dialog_content">"Are you sure you want to sign out?"</string>
|
<string name="screen_signout_confirmation_dialog_content">"Are you sure you want to sign out?"</string>
|
||||||
<string name="screen_signout_confirmation_dialog_submit">"Sign out"</string>
|
|
||||||
<string name="screen_signout_confirmation_dialog_title">"Sign out"</string>
|
<string name="screen_signout_confirmation_dialog_title">"Sign out"</string>
|
||||||
<string name="screen_signout_in_progress_dialog_content">"Signing out…"</string>
|
<string name="screen_signout_in_progress_dialog_content">"Signing out…"</string>
|
||||||
|
<string name="screen_signout_confirmation_dialog_submit">"Sign out"</string>
|
||||||
<string name="screen_signout_preference_item">"Sign out"</string>
|
<string name="screen_signout_preference_item">"Sign out"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
@ -33,6 +33,7 @@ dependencies {
|
||||||
implementation(projects.anvilannotations)
|
implementation(projects.anvilannotations)
|
||||||
anvil(projects.anvilcodegen)
|
anvil(projects.anvilcodegen)
|
||||||
api(projects.features.messages.api)
|
api(projects.features.messages.api)
|
||||||
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,6 @@ class MessagesPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
val networkConnectionStatus by networkMonitor.connectivity.collectAsState(initial = networkMonitor.currentConnectivityStatus)
|
val networkConnectionStatus by networkMonitor.connectivity.collectAsState(initial = networkMonitor.currentConnectivityStatus)
|
||||||
println(networkConnectionStatus)
|
|
||||||
|
|
||||||
LaunchedEffect(syncUpdateFlow) {
|
LaunchedEffect(syncUpdateFlow) {
|
||||||
roomAvatar.value =
|
roomAvatar.value =
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ package io.element.android.features.messages.impl
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.messages.impl.actionlist.anActionListState
|
import io.element.android.features.messages.impl.actionlist.anActionListState
|
||||||
|
import io.element.android.features.messages.impl.textcomposer.AttachmentSourcePicker
|
||||||
import io.element.android.features.messages.impl.textcomposer.aMessageComposerState
|
import io.element.android.features.messages.impl.textcomposer.aMessageComposerState
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineItemContent
|
import io.element.android.features.messages.impl.timeline.aTimelineItemContent
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineItemList
|
import io.element.android.features.messages.impl.timeline.aTimelineItemList
|
||||||
|
|
@ -32,6 +33,8 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aMessagesState(),
|
aMessagesState(),
|
||||||
aMessagesState().copy(hasNetworkConnection = false),
|
aMessagesState().copy(hasNetworkConnection = false),
|
||||||
|
aMessagesState().copy(composerState = aMessageComposerState().copy(attachmentSourcePicker = AttachmentSourcePicker.AllMedia)),
|
||||||
|
aMessagesState().copy(composerState = aMessageComposerState().copy(attachmentSourcePicker = AttachmentSourcePicker.Camera)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl
|
package io.element.android.features.messages.impl
|
||||||
|
|
||||||
|
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.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
|
@ -35,6 +36,7 @@ import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
|
import androidx.compose.material.ListItem
|
||||||
import androidx.compose.material.ModalBottomSheetValue
|
import androidx.compose.material.ModalBottomSheetValue
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
|
@ -43,11 +45,14 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
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.LocalFocusManager
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
|
@ -57,16 +62,20 @@ import androidx.compose.ui.unit.sp
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListView
|
import io.element.android.features.messages.impl.actionlist.ActionListView
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
|
import io.element.android.features.messages.impl.textcomposer.AttachmentSourcePicker
|
||||||
|
import io.element.android.features.messages.impl.textcomposer.MessageComposerEvents
|
||||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerView
|
import io.element.android.features.messages.impl.textcomposer.MessageComposerView
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineView
|
import io.element.android.features.messages.impl.timeline.TimelineView
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
|
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
|
||||||
|
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
||||||
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
|
||||||
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.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.ModalBottomSheetLayout
|
||||||
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.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
|
||||||
|
|
@ -87,9 +96,24 @@ fun MessagesView(
|
||||||
initialValue = ModalBottomSheetValue.Hidden,
|
initialValue = ModalBottomSheetValue.Hidden,
|
||||||
)
|
)
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val focusManager = LocalFocusManager.current
|
val composerState = state.composerState
|
||||||
|
val initialBottomSheetState = if (LocalInspectionMode.current && composerState.attachmentSourcePicker != null) {
|
||||||
|
ModalBottomSheetValue.Expanded
|
||||||
|
} else {
|
||||||
|
ModalBottomSheetValue.Hidden
|
||||||
|
}
|
||||||
|
val bottomSheetState = rememberModalBottomSheetState(initialValue = initialBottomSheetState)
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
BackHandler(enabled = bottomSheetState.isVisible) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
bottomSheetState.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose
|
||||||
|
val localView = LocalView.current
|
||||||
|
|
||||||
LogCompositions(tag = "MessagesScreen", msg = "Content")
|
LogCompositions(tag = "MessagesScreen", msg = "Content")
|
||||||
|
|
||||||
fun onMessageClicked(event: TimelineItem.Event) {
|
fun onMessageClicked(event: TimelineItem.Event) {
|
||||||
|
|
@ -99,7 +123,7 @@ fun MessagesView(
|
||||||
|
|
||||||
fun onMessageLongClicked(event: TimelineItem.Event) {
|
fun onMessageLongClicked(event: TimelineItem.Event) {
|
||||||
Timber.v("OnMessageLongClicked= ${event.id}")
|
Timber.v("OnMessageLongClicked= ${event.id}")
|
||||||
focusManager.clearFocus(force = true)
|
localView.hideKeyboard()
|
||||||
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event))
|
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event))
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
itemActionsBottomSheetState.show()
|
itemActionsBottomSheetState.show()
|
||||||
|
|
@ -110,41 +134,67 @@ fun MessagesView(
|
||||||
state.eventSink(MessagesEvents.HandleAction(action, event))
|
state.eventSink(MessagesEvents.HandleAction(action, event))
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
LaunchedEffect(composerState.attachmentSourcePicker) {
|
||||||
modifier = modifier,
|
if (composerState.attachmentSourcePicker != null) {
|
||||||
contentWindowInsets = WindowInsets.statusBars,
|
// We need to use this instead of `LocalFocusManager.clearFocus()` to hide the keyboard when focus is on an Android View
|
||||||
topBar = {
|
localView.hideKeyboard()
|
||||||
Column {
|
bottomSheetState.show()
|
||||||
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
|
} else {
|
||||||
MessagesViewTopBar(
|
bottomSheetState.hide()
|
||||||
roomTitle = state.roomName,
|
}
|
||||||
roomAvatar = state.roomAvatar,
|
}
|
||||||
onBackPressed = onBackPressed,
|
// Send 'DismissAttachmentMenu' event when the bottomsheet was just hidden
|
||||||
onRoomDetailsClicked = onRoomDetailsClicked,
|
LaunchedEffect(bottomSheetState.isVisible) {
|
||||||
|
if (!bottomSheetState.isVisible) {
|
||||||
|
composerState.eventSink(MessageComposerEvents.DismissAttachmentMenu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ModalBottomSheetLayout(
|
||||||
|
sheetState = bottomSheetState,
|
||||||
|
displayHandle = true,
|
||||||
|
sheetContent = {
|
||||||
|
MediaPickerMenu(
|
||||||
|
addAttachmentSourcePicker = composerState.attachmentSourcePicker,
|
||||||
|
eventSink = composerState.eventSink
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
contentWindowInsets = WindowInsets.statusBars,
|
||||||
|
topBar = {
|
||||||
|
Column {
|
||||||
|
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
|
||||||
|
MessagesViewTopBar(
|
||||||
|
roomTitle = state.roomName,
|
||||||
|
roomAvatar = state.roomAvatar,
|
||||||
|
onBackPressed = onBackPressed,
|
||||||
|
onRoomDetailsClicked = onRoomDetailsClicked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content = { padding ->
|
||||||
|
MessagesViewContent(
|
||||||
|
state = state,
|
||||||
|
modifier = Modifier.padding(padding),
|
||||||
|
onMessageClicked = ::onMessageClicked,
|
||||||
|
onMessageLongClicked = ::onMessageLongClicked
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
},
|
snackbarHost = {
|
||||||
content = { padding ->
|
SnackbarHost(
|
||||||
MessagesViewContent(
|
snackbarHostState,
|
||||||
state = state,
|
modifier = Modifier.navigationBarsPadding()
|
||||||
modifier = Modifier.padding(padding),
|
)
|
||||||
onMessageClicked = ::onMessageClicked,
|
},
|
||||||
onMessageLongClicked = ::onMessageLongClicked
|
)
|
||||||
)
|
|
||||||
},
|
|
||||||
snackbarHost = {
|
|
||||||
SnackbarHost(
|
|
||||||
snackbarHostState,
|
|
||||||
modifier = Modifier.navigationBarsPadding()
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
ActionListView(
|
ActionListView(
|
||||||
state = state.actionListState,
|
state = state.actionListState,
|
||||||
modalBottomSheetState = itemActionsBottomSheetState,
|
modalBottomSheetState = itemActionsBottomSheetState,
|
||||||
onActionSelected = ::onActionSelected
|
onActionSelected = ::onActionSelected
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -218,6 +268,53 @@ fun MessagesViewTopBar(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun MediaPickerMenu(
|
||||||
|
addAttachmentSourcePicker: AttachmentSourcePicker?,
|
||||||
|
eventSink: (MessageComposerEvents) -> Unit,
|
||||||
|
) {
|
||||||
|
when (addAttachmentSourcePicker) {
|
||||||
|
null -> return
|
||||||
|
AttachmentSourcePicker.AllMedia -> AllMediaSourcePickerMenu(eventSink = eventSink)
|
||||||
|
AttachmentSourcePicker.Camera -> CameraSourcePickerMenu(eventSink = eventSink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun AllMediaSourcePickerMenu(
|
||||||
|
eventSink: (MessageComposerEvents) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(modifier) {
|
||||||
|
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) }) {
|
||||||
|
Text(stringResource(R.string.screen_room_attachment_source_gallery))
|
||||||
|
}
|
||||||
|
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) }) {
|
||||||
|
Text(stringResource(R.string.screen_room_attachment_source_files))
|
||||||
|
}
|
||||||
|
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromCamera) }) {
|
||||||
|
Text(stringResource(R.string.screen_room_attachment_source_camera))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun CameraSourcePickerMenu(
|
||||||
|
eventSink: (MessageComposerEvents) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(modifier) {
|
||||||
|
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickCameraAttachmentSource.Photo) }) {
|
||||||
|
Text(stringResource(R.string.screen_room_attachment_source_camera_photo))
|
||||||
|
}
|
||||||
|
ListItem(Modifier.clickable { eventSink(MessageComposerEvents.PickCameraAttachmentSource.Video) }) {
|
||||||
|
Text(stringResource(R.string.screen_room_attachment_source_camera_video))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MessagesViewLightPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) =
|
internal fun MessagesViewLightPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) =
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,15 @@ sealed interface MessageComposerEvents {
|
||||||
object CloseSpecialMode : MessageComposerEvents
|
object CloseSpecialMode : MessageComposerEvents
|
||||||
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
|
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
|
||||||
data class UpdateText(val text: CharSequence) : MessageComposerEvents
|
data class UpdateText(val text: CharSequence) : MessageComposerEvents
|
||||||
|
object AddAttachment : MessageComposerEvents
|
||||||
object TakePhoto : MessageComposerEvents
|
object DismissAttachmentMenu : MessageComposerEvents
|
||||||
|
sealed interface PickAttachmentSource : MessageComposerEvents {
|
||||||
|
object FromGallery : PickAttachmentSource
|
||||||
|
object FromCamera : PickAttachmentSource
|
||||||
|
object FromFiles : PickAttachmentSource
|
||||||
|
}
|
||||||
|
sealed interface PickCameraAttachmentSource : MessageComposerEvents {
|
||||||
|
object Photo : PickCameraAttachmentSource
|
||||||
|
object Video : PickCameraAttachmentSource
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,17 @@ package io.element.android.features.messages.impl.textcomposer
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
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 androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.data.StableCharSequence
|
import io.element.android.libraries.core.data.StableCharSequence
|
||||||
import io.element.android.libraries.core.data.toStableCharSequence
|
import io.element.android.libraries.core.data.toStableCharSequence
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.di.SingleIn
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
|
@ -36,6 +40,7 @@ import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@SingleIn(RoomScope::class)
|
||||||
class MessageComposerPresenter @Inject constructor(
|
class MessageComposerPresenter @Inject constructor(
|
||||||
private val appCoroutineScope: CoroutineScope,
|
private val appCoroutineScope: CoroutineScope,
|
||||||
private val room: MatrixRoom,
|
private val room: MatrixRoom,
|
||||||
|
|
@ -47,11 +52,22 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
override fun present(): MessageComposerState {
|
override fun present(): MessageComposerState {
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
// Example usage of custom pickers
|
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri ->
|
||||||
|
Timber.d("Media picked from $uri")
|
||||||
|
})
|
||||||
|
|
||||||
|
val filesPicker = mediaPickerProvider.registerFilePicker(onResult = { uri ->
|
||||||
|
Timber.d("File picked from $uri")
|
||||||
|
})
|
||||||
|
|
||||||
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(onResult = { uri ->
|
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(onResult = { uri ->
|
||||||
Timber.d("Photo saved at $uri")
|
Timber.d("Photo saved at $uri")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker(onResult = { uri ->
|
||||||
|
Timber.d("Video saved at $uri")
|
||||||
|
})
|
||||||
|
|
||||||
val isFullScreen = rememberSaveable {
|
val isFullScreen = rememberSaveable {
|
||||||
mutableStateOf(false)
|
mutableStateOf(false)
|
||||||
}
|
}
|
||||||
|
|
@ -62,6 +78,8 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
mutableStateOf(MessageComposerMode.Normal(""))
|
mutableStateOf(MessageComposerMode.Normal(""))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var attachmentSourcePicker: AttachmentSourcePicker? by remember { mutableStateOf(null) }
|
||||||
|
|
||||||
LaunchedEffect(composerMode.value) {
|
LaunchedEffect(composerMode.value) {
|
||||||
when (val modeValue = composerMode.value) {
|
when (val modeValue = composerMode.value) {
|
||||||
is MessageComposerMode.Edit -> text.value = modeValue.defaultContent.toStableCharSequence()
|
is MessageComposerMode.Edit -> text.value = modeValue.defaultContent.toStableCharSequence()
|
||||||
|
|
@ -80,21 +98,47 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
|
|
||||||
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(event.message, composerMode, text)
|
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(event.message, composerMode, text)
|
||||||
is MessageComposerEvents.SetMode -> composerMode.value = event.composerMode
|
is MessageComposerEvents.SetMode -> composerMode.value = event.composerMode
|
||||||
MessageComposerEvents.TakePhoto -> localCoroutineScope.launch {
|
MessageComposerEvents.AddAttachment -> localCoroutineScope.ifMediaPickersEnabled {
|
||||||
if (featureFlagService.isFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow)) {
|
attachmentSourcePicker = AttachmentSourcePicker.AllMedia
|
||||||
cameraPhotoPicker.launch()
|
}
|
||||||
}
|
MessageComposerEvents.DismissAttachmentMenu -> attachmentSourcePicker = null
|
||||||
}}
|
MessageComposerEvents.PickAttachmentSource.FromGallery -> localCoroutineScope.ifMediaPickersEnabled {
|
||||||
|
attachmentSourcePicker = null
|
||||||
|
galleryMediaPicker.launch()
|
||||||
|
}
|
||||||
|
MessageComposerEvents.PickAttachmentSource.FromFiles -> localCoroutineScope.ifMediaPickersEnabled {
|
||||||
|
attachmentSourcePicker = null
|
||||||
|
filesPicker.launch()
|
||||||
|
}
|
||||||
|
MessageComposerEvents.PickAttachmentSource.FromCamera -> localCoroutineScope.ifMediaPickersEnabled {
|
||||||
|
attachmentSourcePicker = AttachmentSourcePicker.Camera
|
||||||
|
}
|
||||||
|
MessageComposerEvents.PickCameraAttachmentSource.Photo -> localCoroutineScope.ifMediaPickersEnabled {
|
||||||
|
attachmentSourcePicker = null
|
||||||
|
cameraPhotoPicker.launch()
|
||||||
|
}
|
||||||
|
MessageComposerEvents.PickCameraAttachmentSource.Video -> localCoroutineScope.ifMediaPickersEnabled {
|
||||||
|
attachmentSourcePicker = null
|
||||||
|
cameraVideoPicker.launch()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return MessageComposerState(
|
return MessageComposerState(
|
||||||
text = text.value,
|
text = text.value,
|
||||||
isFullScreen = isFullScreen.value,
|
isFullScreen = isFullScreen.value,
|
||||||
mode = composerMode.value,
|
mode = composerMode.value,
|
||||||
|
attachmentSourcePicker = attachmentSourcePicker,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.ifMediaPickersEnabled(action: suspend () -> Unit) = launch {
|
||||||
|
if (featureFlagService.isFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow)) {
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun MutableState<MessageComposerMode>.setToNormal() {
|
private fun MutableState<MessageComposerMode>.setToNormal() {
|
||||||
value = MessageComposerMode.Normal("")
|
value = MessageComposerMode.Normal("")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,13 @@ data class MessageComposerState(
|
||||||
val text: StableCharSequence?,
|
val text: StableCharSequence?,
|
||||||
val isFullScreen: Boolean,
|
val isFullScreen: Boolean,
|
||||||
val mode: MessageComposerMode,
|
val mode: MessageComposerMode,
|
||||||
|
val attachmentSourcePicker: AttachmentSourcePicker?,
|
||||||
val eventSink: (MessageComposerEvents) -> Unit
|
val eventSink: (MessageComposerEvents) -> Unit
|
||||||
) {
|
) {
|
||||||
val isSendButtonVisible: Boolean = text?.charSequence.isNullOrEmpty().not()
|
val isSendButtonVisible: Boolean = text?.charSequence.isNullOrEmpty().not()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sealed interface AttachmentSourcePicker {
|
||||||
|
object AllMedia : AttachmentSourcePicker
|
||||||
|
object Camera : AttachmentSourcePicker
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,5 +31,6 @@ fun aMessageComposerState() = MessageComposerState(
|
||||||
text = StableCharSequence(""),
|
text = StableCharSequence(""),
|
||||||
isFullScreen = false,
|
isFullScreen = false,
|
||||||
mode = MessageComposerMode.Normal(content = ""),
|
mode = MessageComposerMode.Normal(content = ""),
|
||||||
|
attachmentSourcePicker = null,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ fun MessageComposerView(
|
||||||
onCloseSpecialMode = ::onCloseSpecialMode,
|
onCloseSpecialMode = ::onCloseSpecialMode,
|
||||||
onComposerTextChange = ::onComposerTextChange,
|
onComposerTextChange = ::onComposerTextChange,
|
||||||
onAddAttachment = {
|
onAddAttachment = {
|
||||||
state.eventSink(MessageComposerEvents.TakePhoto)
|
state.eventSink(MessageComposerEvents.AddAttachment)
|
||||||
},
|
},
|
||||||
composerCanSendMessage = state.isSendButtonVisible,
|
composerCanSendMessage = state.isSendButtonVisible,
|
||||||
composerText = state.text?.charSequence?.toString(),
|
composerText = state.text?.charSequence?.toString(),
|
||||||
|
|
|
||||||
9
features/messages/impl/src/main/res/values/localazy.xml
Normal file
9
features/messages/impl/src/main/res/values/localazy.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<string name="screen_room_attachment_source_camera">"Camera"</string>
|
||||||
|
<string name="screen_room_attachment_source_camera_photo">"Take photo"</string>
|
||||||
|
<string name="screen_room_attachment_source_camera_video">"Record a video"</string>
|
||||||
|
<string name="screen_room_attachment_source_files">"Attachment"</string>
|
||||||
|
<string name="screen_room_attachment_source_gallery">"Photo & Video Library"</string>
|
||||||
|
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
|
||||||
|
</resources>
|
||||||
|
|
@ -22,7 +22,9 @@ import app.cash.molecule.RecompositionClock
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.ReceiveTurbine
|
import app.cash.turbine.ReceiveTurbine
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.messages.impl.textcomposer.AttachmentSourcePicker
|
||||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerEvents
|
import io.element.android.features.messages.impl.textcomposer.MessageComposerEvents
|
||||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter
|
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter
|
||||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerState
|
import io.element.android.features.messages.impl.textcomposer.MessageComposerState
|
||||||
|
|
@ -279,6 +281,103 @@ class MessageComposerPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - Open attachments menu`() = runTest {
|
||||||
|
val fakeMatrixRoom = FakeMatrixRoom()
|
||||||
|
val presenter = MessageComposerPresenter(
|
||||||
|
this,
|
||||||
|
fakeMatrixRoom,
|
||||||
|
pickerProvider,
|
||||||
|
featureFlagService,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessageComposerEvents.AddAttachment)
|
||||||
|
|
||||||
|
assertThat(awaitItem().attachmentSourcePicker).isEqualTo(AttachmentSourcePicker.AllMedia)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - Open camera attachments menu`() = runTest {
|
||||||
|
val fakeMatrixRoom = FakeMatrixRoom()
|
||||||
|
val presenter = MessageComposerPresenter(
|
||||||
|
this,
|
||||||
|
fakeMatrixRoom,
|
||||||
|
pickerProvider,
|
||||||
|
featureFlagService,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromCamera)
|
||||||
|
|
||||||
|
assertThat(awaitItem().attachmentSourcePicker).isEqualTo(AttachmentSourcePicker.Camera)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - Dismiss attachments menu`() = runTest {
|
||||||
|
val fakeMatrixRoom = FakeMatrixRoom()
|
||||||
|
val presenter = MessageComposerPresenter(
|
||||||
|
this,
|
||||||
|
fakeMatrixRoom,
|
||||||
|
pickerProvider,
|
||||||
|
featureFlagService,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessageComposerEvents.AddAttachment)
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
initialState.eventSink(MessageComposerEvents.DismissAttachmentMenu)
|
||||||
|
assertThat(awaitItem().attachmentSourcePicker).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - Pick media from gallery`() = runTest {
|
||||||
|
val fakeMatrixRoom = FakeMatrixRoom()
|
||||||
|
val presenter = MessageComposerPresenter(
|
||||||
|
this,
|
||||||
|
fakeMatrixRoom,
|
||||||
|
pickerProvider,
|
||||||
|
featureFlagService,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
|
||||||
|
|
||||||
|
// TODO verify some post processing of the selected media is done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - Pick file from storage`() = runTest {
|
||||||
|
val fakeMatrixRoom = FakeMatrixRoom()
|
||||||
|
val presenter = MessageComposerPresenter(
|
||||||
|
this,
|
||||||
|
fakeMatrixRoom,
|
||||||
|
pickerProvider,
|
||||||
|
featureFlagService,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
|
||||||
|
|
||||||
|
// TODO verify some post processing of the selected media is done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - Take photo`() = runTest {
|
fun `present - Take photo`() = runTest {
|
||||||
val fakeMatrixRoom = FakeMatrixRoom()
|
val fakeMatrixRoom = FakeMatrixRoom()
|
||||||
|
|
@ -292,11 +391,30 @@ class MessageComposerPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(MessageComposerEvents.TakePhoto)
|
initialState.eventSink(MessageComposerEvents.PickCameraAttachmentSource.Photo)
|
||||||
|
|
||||||
// TODO verify some post processing of the captured image is done
|
// TODO verify some post processing of the captured image is done
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - Record video`() = runTest {
|
||||||
|
val fakeMatrixRoom = FakeMatrixRoom()
|
||||||
|
val presenter = MessageComposerPresenter(
|
||||||
|
this,
|
||||||
|
fakeMatrixRoom,
|
||||||
|
pickerProvider,
|
||||||
|
featureFlagService,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessageComposerEvents.PickCameraAttachmentSource.Video)
|
||||||
|
|
||||||
|
// TODO verify some post processing of the captured video is done
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE)
|
fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("io.element.android-compose-library")
|
id("io.element.android-compose-library")
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
@ -26,4 +27,6 @@ dependencies {
|
||||||
implementation(libs.coroutines.core)
|
implementation(libs.coroutines.core)
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
|
|
||||||
|
ksp(libs.showkase.processor)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ package io.element.android.features.preferences.impl.root
|
||||||
import io.element.android.features.logout.api.LogoutPreferenceState
|
import io.element.android.features.logout.api.LogoutPreferenceState
|
||||||
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
|
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
|
||||||
data class PreferencesRootState(
|
data class PreferencesRootState(
|
||||||
val logoutState: LogoutPreferenceState,
|
val logoutState: LogoutPreferenceState,
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ import androidx.compose.material.icons.filled.DeveloperMode
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
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.features.logout.api.LogoutPreferenceView
|
import io.element.android.features.logout.api.LogoutPreferenceView
|
||||||
import io.element.android.features.preferences.impl.user.UserPreferences
|
import io.element.android.features.preferences.impl.user.UserPreferences
|
||||||
|
|
@ -32,8 +31,9 @@ import io.element.android.libraries.designsystem.components.preferences.Preferen
|
||||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceView
|
import io.element.android.libraries.designsystem.components.preferences.PreferenceView
|
||||||
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.preview.LargeHeightPreview
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.ui.components.MatrixUserProvider
|
import io.element.android.libraries.matrix.ui.components.MatrixUserProvider
|
||||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
|
||||||
import io.element.android.libraries.ui.strings.R as StringR
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -76,12 +76,12 @@ fun DeveloperPreferencesView(onOpenDeveloperSettings: () -> Unit) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@LargeHeightPreview
|
||||||
@Composable
|
@Composable
|
||||||
fun PreferencesRootViewLightPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) =
|
fun PreferencesRootViewLightPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) =
|
||||||
ElementPreviewLight { ContentToPreview(matrixUser) }
|
ElementPreviewLight { ContentToPreview(matrixUser) }
|
||||||
|
|
||||||
@Preview
|
@LargeHeightPreview
|
||||||
@Composable
|
@Composable
|
||||||
fun PreferencesRootViewDarkPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) =
|
fun PreferencesRootViewDarkPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) =
|
||||||
ElementPreviewDark { ContentToPreview(matrixUser) }
|
ElementPreviewDark { ContentToPreview(matrixUser) }
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,9 @@ import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
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.ui.components.MatrixUserHeader
|
import io.element.android.libraries.matrix.ui.components.MatrixUserHeader
|
||||||
import io.element.android.libraries.matrix.ui.components.MatrixUserWithNullProvider
|
import io.element.android.libraries.matrix.ui.components.MatrixUserWithNullProvider
|
||||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UserPreferences(
|
fun UserPreferences(
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
*/
|
*/
|
||||||
plugins {
|
plugins {
|
||||||
id("io.element.android-compose-library")
|
id("io.element.android-compose-library")
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
@ -26,4 +27,6 @@ dependencies {
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
|
|
||||||
|
ksp(libs.showkase.processor)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,3 +80,4 @@ private fun ContentToPreview() {
|
||||||
state = aCrashDetectionState().copy(crashDetected = true)
|
state = aCrashDetectionState().copy(crashDetected = true)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<string name="screen_bug_report_attach_screenshot">"Bildschirmfoto anhängen"</string>
|
||||||
<string name="screen_bug_report_editor_placeholder">"Beschreibe den Fehler…"</string>
|
<string name="screen_bug_report_editor_placeholder">"Beschreibe den Fehler…"</string>
|
||||||
<string name="screen_bug_report_include_crash_logs">"Absturzprotokolle senden"</string>
|
<string name="screen_bug_report_include_crash_logs">"Absturzprotokolle senden"</string>
|
||||||
<string name="screen_bug_report_include_screenshot">"Bildschirmfoto senden"</string>
|
<string name="screen_bug_report_include_screenshot">"Bildschirmfoto senden"</string>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.features.roomdetails.impl
|
package io.element.android.features.roomdetails.impl
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.features.roomdetails.impl.members.details.aRoomMemberDetailsState
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
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
|
||||||
|
|
@ -30,8 +31,8 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
|
||||||
aRoomDetailsState().copy(isEncrypted = false),
|
aRoomDetailsState().copy(isEncrypted = false),
|
||||||
aRoomDetailsState().copy(roomAlias = null),
|
aRoomDetailsState().copy(roomAlias = null),
|
||||||
aRoomDetailsState().copy(memberCount = Async.Failure(Throwable())),
|
aRoomDetailsState().copy(memberCount = Async.Failure(Throwable())),
|
||||||
aRoomDetailsState().copy(roomType = RoomDetailsType.Dm(aDmRoomMember()), roomName = "Daniel"),
|
aDmRoomDetailsState().copy(roomName = "Daniel"),
|
||||||
aRoomDetailsState().copy(roomType = RoomDetailsType.Dm(aDmRoomMember(isIgnored = true)), roomName = "Daniel"),
|
aDmRoomDetailsState(isDmMemberIgnored = true).copy(roomName = "Daniel"),
|
||||||
// Add other state here
|
// Add other state here
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -74,3 +75,7 @@ fun aRoomDetailsState() = RoomDetailsState(
|
||||||
roomMemberDetailsState = null,
|
roomMemberDetailsState = null,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun aDmRoomDetailsState(isDmMemberIgnored: Boolean = false) = aRoomDetailsState().copy(
|
||||||
|
roomType = RoomDetailsType.Dm(aDmRoomMember(isIgnored = isDmMemberIgnored)), roomMemberDetailsState = aRoomMemberDetailsState()
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,10 @@
|
||||||
|
|
||||||
package io.element.android.features.roomdetails.impl
|
package io.element.android.features.roomdetails.impl
|
||||||
|
|
||||||
|
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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
|
@ -39,26 +41,26 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.res.vectorResource
|
import androidx.compose.ui.res.vectorResource
|
||||||
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.roomdetails.blockuser.BlockUserDialogs
|
import io.element.android.features.roomdetails.blockuser.BlockUserDialogs
|
||||||
import io.element.android.features.roomdetails.blockuser.BlockUserSection
|
import io.element.android.features.roomdetails.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.RoomMemberShareSection
|
import io.element.android.features.roomdetails.impl.members.details.RoomMemberMainActionsSection
|
||||||
import io.element.android.libraries.architecture.Async
|
|
||||||
import io.element.android.libraries.architecture.isLoading
|
import io.element.android.libraries.architecture.isLoading
|
||||||
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
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
|
import io.element.android.libraries.designsystem.components.button.MainActionButton
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
|
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
|
||||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
|
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
|
||||||
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.preview.LargeHeightPreview
|
||||||
import io.element.android.libraries.designsystem.theme.LocalColors
|
import io.element.android.libraries.designsystem.theme.LocalColors
|
||||||
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.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
|
@ -99,7 +101,7 @@ fun RoomDetailsView(
|
||||||
roomName = state.roomName,
|
roomName = state.roomName,
|
||||||
roomAlias = state.roomAlias
|
roomAlias = state.roomAlias
|
||||||
)
|
)
|
||||||
RoomShareSection(onShareRoom = onShareRoom)
|
MainActionsSection(onShareRoom = onShareRoom)
|
||||||
}
|
}
|
||||||
is RoomDetailsType.Dm -> {
|
is RoomDetailsType.Dm -> {
|
||||||
val member = state.roomType.roomMember
|
val member = state.roomType.roomMember
|
||||||
|
|
@ -108,9 +110,10 @@ fun RoomDetailsView(
|
||||||
userId = member.userId.value,
|
userId = member.userId.value,
|
||||||
userName = state.roomName
|
userName = state.roomName
|
||||||
)
|
)
|
||||||
RoomMemberShareSection(onShareUser = ::onShareMember)
|
RoomMemberMainActionsSection(onShareUser = ::onShareMember)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(26.dp))
|
||||||
|
|
||||||
if (state.roomTopic != null) {
|
if (state.roomTopic != null) {
|
||||||
TopicSection(roomTopic = state.roomTopic)
|
TopicSection(roomTopic = state.roomTopic)
|
||||||
|
|
@ -129,21 +132,16 @@ fun RoomDetailsView(
|
||||||
SecuritySection()
|
SecuritySection()
|
||||||
}
|
}
|
||||||
|
|
||||||
when (state.roomType) {
|
if (state.roomType is RoomDetailsType.Dm && state.roomMemberDetailsState != null) {
|
||||||
RoomDetailsType.Room -> {
|
val roomMemberState = state.roomMemberDetailsState
|
||||||
OtherActionsSection(onLeaveRoom = {
|
BlockUserSection(roomMemberState)
|
||||||
state.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
|
BlockUserDialogs(roomMemberState)
|
||||||
})
|
|
||||||
}
|
|
||||||
is RoomDetailsType.Dm -> {
|
|
||||||
if (state.roomMemberDetailsState != null) {
|
|
||||||
val roomMemberState = state.roomMemberDetailsState
|
|
||||||
BlockUserSection(roomMemberState)
|
|
||||||
BlockUserDialogs(roomMemberState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OtherActionsSection(onLeaveRoom = {
|
||||||
|
state.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
|
||||||
|
})
|
||||||
|
|
||||||
if (state.displayLeaveRoomWarning != null) {
|
if (state.displayLeaveRoomWarning != null) {
|
||||||
ConfirmLeaveRoomDialog(
|
ConfirmLeaveRoomDialog(
|
||||||
leaveRoomWarning = state.displayLeaveRoomWarning,
|
leaveRoomWarning = state.displayLeaveRoomWarning,
|
||||||
|
|
@ -163,13 +161,9 @@ fun RoomDetailsView(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RoomShareSection(onShareRoom: () -> Unit, modifier: Modifier = Modifier) {
|
internal fun MainActionsSection(onShareRoom: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
PreferenceCategory(modifier = modifier) {
|
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||||
PreferenceText(
|
MainActionButton(title = stringResource(R.string.screen_room_details_share_room_title), icon = Icons.Outlined.Share, onClick = onShareRoom)
|
||||||
title = stringResource(R.string.screen_room_details_share_room_title),
|
|
||||||
icon = Icons.Outlined.Share,
|
|
||||||
onClick = onShareRoom,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,12 +270,12 @@ internal fun ConfirmLeaveRoomDialog(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@LargeHeightPreview
|
||||||
@Composable
|
@Composable
|
||||||
fun RoomDetailsLightPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) =
|
fun RoomDetailsLightPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) =
|
||||||
ElementPreviewLight { ContentToPreview(state) }
|
ElementPreviewLight { ContentToPreview(state) }
|
||||||
|
|
||||||
@Preview
|
@LargeHeightPreview
|
||||||
@Composable
|
@Composable
|
||||||
fun RoomDetailsDarkPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) =
|
fun RoomDetailsDarkPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) =
|
||||||
ElementPreviewDark { ContentToPreview(state) }
|
ElementPreviewDark { ContentToPreview(state) }
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.roomdetails.impl.members
|
package io.element.android.features.roomdetails.impl.members
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
|
||||||
sealed interface RoomMemberListEvents {
|
sealed interface RoomMemberListEvents {
|
||||||
data class SelectUser(val user: MatrixUser) : RoomMemberListEvents
|
data class SelectUser(val user: MatrixUser) : RoomMemberListEvents
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ 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.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.ui.model.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
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,7 @@ package io.element.android.features.roomdetails.impl.members
|
||||||
|
|
||||||
import io.element.android.features.userlist.api.UserListState
|
import io.element.android.features.userlist.api.UserListState
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
data class RoomMemberListState(
|
data class RoomMemberListState(
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,11 @@
|
||||||
package io.element.android.features.roomdetails.impl.members
|
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.features.userlist.api.UserSearchResultState
|
||||||
import io.element.android.features.userlist.api.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.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.model.MatrixUser
|
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
|
|
@ -33,7 +34,7 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun aRoomMemberListState(
|
internal fun aRoomMemberListState(
|
||||||
searchResults: ImmutableList<MatrixUser> = persistentListOf(),
|
searchResults: UserSearchResultState = UserSearchResultState.NotSearching,
|
||||||
allUsers: Async<ImmutableList<MatrixUser>> = Async.Uninitialized,
|
allUsers: Async<ImmutableList<MatrixUser>> = Async.Uninitialized,
|
||||||
) =
|
) =
|
||||||
RoomMemberListState(
|
RoomMemberListState(
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre
|
||||||
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.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.ui.model.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -64,7 +64,7 @@ fun RoomMemberListView(
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun onUserSelected(user: MatrixUser) {
|
fun onUserSelected(user: MatrixUser) {
|
||||||
onMemberSelected(user.id)
|
onMemberSelected(user.userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,13 @@ package io.element.android.features.roomdetails.impl.members
|
||||||
import io.element.android.features.userlist.api.UserListDataSource
|
import io.element.android.features.userlist.api.UserListDataSource
|
||||||
import io.element.android.libraries.core.bool.orFalse
|
import io.element.android.libraries.core.bool.orFalse
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
|
||||||
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.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.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
import io.element.android.libraries.matrix.api.room.toMatrixUser
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import kotlinx.coroutines.flow.dropWhile
|
import kotlinx.coroutines.flow.dropWhile
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
@ -50,22 +50,11 @@ class RoomUserListDataSource @Inject constructor(
|
||||||
|| member.displayName?.contains(query, ignoreCase = true).orFalse()
|
|| member.displayName?.contains(query, ignoreCase = true).orFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
filteredMembers.map(::mapMemberToMatrixUser)
|
filteredMembers.map(RoomMember::toMatrixUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getProfile(userId: UserId): MatrixUser? {
|
override suspend fun getProfile(userId: UserId): MatrixUser? {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mapMemberToMatrixUser(member: RoomMember): MatrixUser {
|
|
||||||
return MatrixUser(
|
|
||||||
id = member.userId,
|
|
||||||
username = member.displayName,
|
|
||||||
avatarData = AvatarData(
|
|
||||||
id = member.userId.value,
|
|
||||||
name = member.displayName,
|
|
||||||
url = member.avatarUrl
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,10 @@
|
||||||
|
|
||||||
package io.element.android.features.roomdetails.impl.members.details
|
package io.element.android.features.roomdetails.impl.members.details
|
||||||
|
|
||||||
|
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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
|
@ -27,7 +29,6 @@ import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
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.ChatBubbleOutline
|
import androidx.compose.material.icons.outlined.ChatBubbleOutline
|
||||||
import androidx.compose.material.icons.outlined.Share
|
import androidx.compose.material.icons.outlined.Share
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
|
@ -36,23 +37,21 @@ 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.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
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.roomdetails.blockuser.BlockUserDialogs
|
import io.element.android.features.roomdetails.blockuser.BlockUserDialogs
|
||||||
import io.element.android.features.roomdetails.blockuser.BlockUserSection
|
import io.element.android.features.roomdetails.blockuser.BlockUserSection
|
||||||
import io.element.android.features.roomdetails.impl.R
|
|
||||||
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
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
import io.element.android.libraries.designsystem.components.button.MainActionButton
|
||||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
|
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
|
||||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
|
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
|
||||||
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.LocalColors
|
import io.element.android.libraries.designsystem.preview.LargeHeightPreview
|
||||||
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.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
|
||||||
|
|
@ -83,7 +82,9 @@ fun RoomMemberDetailsView(
|
||||||
userName = state.userName,
|
userName = state.userName,
|
||||||
)
|
)
|
||||||
|
|
||||||
RoomMemberShareSection(onShareUser = onShareUser)
|
RoomMemberMainActionsSection(onShareUser = onShareUser)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(26.dp))
|
||||||
|
|
||||||
SendMessageSection(onSendMessage = {
|
SendMessageSection(onSendMessage = {
|
||||||
// TODO implement send DM
|
// TODO implement send DM
|
||||||
|
|
@ -117,18 +118,14 @@ internal fun RoomMemberHeaderSection(
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
}
|
}
|
||||||
Text(userId, style = ElementTextStyles.Regular.body, color = MaterialTheme.colorScheme.secondary)
|
Text(userId, style = ElementTextStyles.Regular.body, color = MaterialTheme.colorScheme.secondary)
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(40.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RoomMemberShareSection(onShareUser: () -> Unit, modifier: Modifier = Modifier) {
|
internal fun RoomMemberMainActionsSection(onShareUser: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
PreferenceCategory(modifier = modifier) {
|
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||||
PreferenceText(
|
MainActionButton(title = stringResource(StringR.string.action_share), icon = Icons.Outlined.Share, onClick = onShareUser)
|
||||||
title = stringResource(StringR.string.action_share),
|
|
||||||
icon = Icons.Outlined.Share,
|
|
||||||
onClick = onShareUser,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,12 +140,12 @@ internal fun SendMessageSection(onSendMessage: () -> Unit, modifier: Modifier =
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@LargeHeightPreview
|
||||||
@Composable
|
@Composable
|
||||||
fun RoomMemberDetailsViewLightPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) =
|
fun RoomMemberDetailsViewLightPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) =
|
||||||
ElementPreviewLight { ContentToPreview(state) }
|
ElementPreviewLight { ContentToPreview(state) }
|
||||||
|
|
||||||
@Preview
|
@LargeHeightPreview
|
||||||
@Composable
|
@Composable
|
||||||
fun RoomMemberDetailsViewDarkPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) =
|
fun RoomMemberDetailsViewDarkPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) =
|
||||||
ElementPreviewDark { ContentToPreview(state) }
|
ElementPreviewDark { ContentToPreview(state) }
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
<plurals name="screen_room_member_list_header_title">
|
<plurals name="screen_room_member_list_header_title">
|
||||||
<item quantity="one">"o persoană"</item>
|
<item quantity="one">"o persoană"</item>
|
||||||
<item quantity="few"></item>
|
|
||||||
<item quantity="other">"%1$d persoane"</item>
|
<item quantity="other">"%1$d persoane"</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="screen_room_details_encryption_enabled_subtitle">"Mesajele sunt securizate cu încuietori. Doar dumneavoastră și destinatarii aveți cheile unice pentru a le debloca."</string>
|
<string name="screen_room_details_encryption_enabled_subtitle">"Mesajele sunt securizate cu încuietori. Doar dumneavoastră și destinatarii aveți cheile unice pentru a le debloca."</string>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
</plurals>
|
</plurals>
|
||||||
<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_invite_people_title">"Invite people"</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_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>
|
||||||
|
|
@ -14,6 +13,7 @@
|
||||||
<string name="screen_dm_details_unblock_alert_action">"Unblock"</string>
|
<string name="screen_dm_details_unblock_alert_action">"Unblock"</string>
|
||||||
<string name="screen_dm_details_unblock_alert_description">"On unblocking the user, you will be able to see all messages by them again."</string>
|
<string name="screen_dm_details_unblock_alert_description">"On unblocking the user, you will be able to see all messages by them again."</string>
|
||||||
<string name="screen_dm_details_unblock_user">"Unblock user"</string>
|
<string name="screen_dm_details_unblock_user">"Unblock user"</string>
|
||||||
|
<string name="screen_room_details_invite_people_title">"Invite people"</string>
|
||||||
<string name="screen_room_details_leave_room_title">"Leave room"</string>
|
<string name="screen_room_details_leave_room_title">"Leave room"</string>
|
||||||
<string name="screen_room_details_people_title">"People"</string>
|
<string name="screen_room_details_people_title">"People"</string>
|
||||||
<string name="screen_room_details_security_title">"Security"</string>
|
<string name="screen_room_details_security_title">"Security"</string>
|
||||||
|
|
|
||||||
|
|
@ -26,15 +26,14 @@ import io.element.android.features.userlist.api.UserListDataSource
|
||||||
import io.element.android.features.userlist.api.UserListDataStore
|
import io.element.android.features.userlist.api.UserListDataStore
|
||||||
import io.element.android.features.userlist.api.UserListPresenter
|
import io.element.android.features.userlist.api.UserListPresenter
|
||||||
import io.element.android.features.userlist.api.UserListPresenterArgs
|
import io.element.android.features.userlist.api.UserListPresenterArgs
|
||||||
|
import io.element.android.features.userlist.api.UserSearchResultState
|
||||||
import io.element.android.features.userlist.impl.DefaultUserListPresenter
|
import io.element.android.features.userlist.impl.DefaultUserListPresenter
|
||||||
import io.element.android.features.userlist.test.FakeUserListDataSource
|
import io.element.android.features.userlist.test.FakeUserListDataSource
|
||||||
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.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import okhttp3.internal.toImmutableList
|
import okhttp3.internal.toImmutableList
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -72,7 +71,7 @@ class RoomMemberListPresenterTests {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
Truth.assertThat(initialState.allUsers).isInstanceOf(Async.Loading::class.java)
|
Truth.assertThat(initialState.allUsers).isInstanceOf(Async.Loading::class.java)
|
||||||
Truth.assertThat(initialState.userListState.isSearchActive).isFalse()
|
Truth.assertThat(initialState.userListState.isSearchActive).isFalse()
|
||||||
Truth.assertThat(initialState.userListState.searchResults).isEmpty()
|
Truth.assertThat(initialState.userListState.searchResults).isEqualTo(UserSearchResultState.NotSearching)
|
||||||
Truth.assertThat(initialState.userListState.selectionMode).isEqualTo(SelectionMode.Single)
|
Truth.assertThat(initialState.userListState.selectionMode).isEqualTo(SelectionMode.Single)
|
||||||
|
|
||||||
val loadedState = awaitItem()
|
val loadedState = awaitItem()
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ dependencies {
|
||||||
implementation(projects.libraries.testtags)
|
implementation(projects.libraries.testtags)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.libraries.dateformatter.api)
|
implementation(projects.libraries.dateformatter.api)
|
||||||
|
implementation(projects.features.invitelist.api)
|
||||||
implementation(projects.features.networkmonitor.api)
|
implementation(projects.features.networkmonitor.api)
|
||||||
implementation(libs.accompanist.placeholder)
|
implementation(libs.accompanist.placeholder)
|
||||||
api(projects.features.roomlist.api)
|
api(projects.features.roomlist.api)
|
||||||
|
|
@ -61,8 +62,10 @@ dependencies {
|
||||||
testImplementation(libs.test.robolectric)
|
testImplementation(libs.test.robolectric)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.libraries.dateformatter.test)
|
testImplementation(projects.libraries.dateformatter.test)
|
||||||
testImplementation(projects.features.networkmonitor.test)
|
|
||||||
testImplementation(projects.libraries.permissions.noop)
|
testImplementation(projects.libraries.permissions.noop)
|
||||||
|
testImplementation(projects.features.invitelist.test)
|
||||||
|
testImplementation(projects.features.networkmonitor.test)
|
||||||
|
testImplementation(projects.tests.testutils)
|
||||||
|
|
||||||
androidTestImplementation(libs.test.junitext)
|
androidTestImplementation(libs.test.junitext)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
/*
|
||||||
|
* 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.roomlist.impl
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.features.invitelist.api.SeenInvitesStore
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomSummary
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ContributesBinding(SessionScope::class)
|
||||||
|
class DefaultInviteStateDataSource @Inject constructor(
|
||||||
|
private val client: MatrixClient,
|
||||||
|
private val seenInvitesStore: SeenInvitesStore,
|
||||||
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
|
) : InviteStateDataSource {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun inviteState(): InvitesState {
|
||||||
|
val invites by client
|
||||||
|
.invitesDataSource
|
||||||
|
.roomSummaries()
|
||||||
|
.collectAsState()
|
||||||
|
|
||||||
|
val seenInvites by seenInvitesStore
|
||||||
|
.seenRoomIds()
|
||||||
|
.collectAsState(initial = emptySet())
|
||||||
|
|
||||||
|
var state by remember { mutableStateOf(InvitesState.NoInvites) }
|
||||||
|
|
||||||
|
LaunchedEffect(invites, seenInvites) {
|
||||||
|
withContext(coroutineDispatchers.computation) {
|
||||||
|
state = when {
|
||||||
|
invites.isEmpty() -> InvitesState.NoInvites
|
||||||
|
seenInvites.containsAll(invites.roomIds) -> InvitesState.SeenInvites
|
||||||
|
else -> InvitesState.NewInvites
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val List<RoomSummary>.roomIds: Collection<RoomId>
|
||||||
|
get() = filterIsInstance<RoomSummary.Filled>().map { it.details.roomId }
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.roomlist.impl
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
interface InviteStateDataSource {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun inviteState(): InvitesState
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,7 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||||
|
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders
|
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
|
@ -34,17 +35,15 @@ import io.element.android.libraries.core.coroutine.parallelMap
|
||||||
import io.element.android.libraries.core.extensions.orEmpty
|
import io.element.android.libraries.core.extensions.orEmpty
|
||||||
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
|
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
|
||||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
import io.element.android.libraries.designsystem.utils.handleSnackbarMessage
|
import io.element.android.libraries.designsystem.utils.handleSnackbarMessage
|
||||||
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.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.RoomSummary
|
import io.element.android.libraries.matrix.api.room.RoomSummary
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
|
||||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
@ -62,6 +61,7 @@ class RoomListPresenter @Inject constructor(
|
||||||
private val sessionVerificationService: SessionVerificationService,
|
private val sessionVerificationService: SessionVerificationService,
|
||||||
private val networkMonitor: NetworkMonitor,
|
private val networkMonitor: NetworkMonitor,
|
||||||
private val snackbarDispatcher: SnackbarDispatcher,
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
|
private val inviteStateDataSource: InviteStateDataSource,
|
||||||
) : Presenter<RoomListState> {
|
) : Presenter<RoomListState> {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -87,13 +87,6 @@ class RoomListPresenter @Inject constructor(
|
||||||
initialLoad(matrixUser)
|
initialLoad(matrixUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
val invites by client
|
|
||||||
.invitesDataSource
|
|
||||||
.roomSummaries()
|
|
||||||
.collectAsState()
|
|
||||||
|
|
||||||
Timber.v("Invites size = ${invites.size}")
|
|
||||||
|
|
||||||
// Session verification status (unknown, not verified, verified)
|
// Session verification status (unknown, not verified, verified)
|
||||||
val sessionVerifiedStatus by sessionVerificationService.sessionVerifiedStatus.collectAsState()
|
val sessionVerifiedStatus by sessionVerificationService.sessionVerifiedStatus.collectAsState()
|
||||||
var verificationPromptDismissed by rememberSaveable { mutableStateOf(false) }
|
var verificationPromptDismissed by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
@ -113,7 +106,7 @@ class RoomListPresenter @Inject constructor(
|
||||||
if (displaySearchResults) {
|
if (displaySearchResults) {
|
||||||
filter = ""
|
filter = ""
|
||||||
}
|
}
|
||||||
displaySearchResults =! displaySearchResults
|
displaySearchResults = !displaySearchResults
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -137,7 +130,7 @@ class RoomListPresenter @Inject constructor(
|
||||||
displayVerificationPrompt = displayVerificationPrompt,
|
displayVerificationPrompt = displayVerificationPrompt,
|
||||||
snackbarMessage = snackbarMessage,
|
snackbarMessage = snackbarMessage,
|
||||||
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
|
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
|
||||||
displayInvites = invites.isNotEmpty(),
|
invitesState = inviteStateDataSource.inviteState(),
|
||||||
displaySearchResults = displaySearchResults,
|
displaySearchResults = displaySearchResults,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
|
|
@ -153,17 +146,10 @@ class RoomListPresenter @Inject constructor(
|
||||||
private fun CoroutineScope.initialLoad(matrixUser: MutableState<MatrixUser?>) = launch {
|
private fun CoroutineScope.initialLoad(matrixUser: MutableState<MatrixUser?>) = launch {
|
||||||
val userAvatarUrl = client.loadUserAvatarURLString().getOrNull()
|
val userAvatarUrl = client.loadUserAvatarURLString().getOrNull()
|
||||||
val userDisplayName = client.loadUserDisplayName().getOrNull()
|
val userDisplayName = client.loadUserDisplayName().getOrNull()
|
||||||
val avatarData =
|
|
||||||
AvatarData(
|
|
||||||
id = client.sessionId.value,
|
|
||||||
name = userDisplayName,
|
|
||||||
url = userAvatarUrl,
|
|
||||||
size = AvatarSize.SMALL
|
|
||||||
)
|
|
||||||
matrixUser.value = MatrixUser(
|
matrixUser.value = MatrixUser(
|
||||||
id = UserId(client.sessionId.value),
|
userId = UserId(client.sessionId.value),
|
||||||
username = userDisplayName ?: client.sessionId.value,
|
displayName = userDisplayName,
|
||||||
avatarData = avatarData,
|
avatarUrl = userAvatarUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ package io.element.android.features.roomlist.impl
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||||
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
||||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
|
@ -31,7 +31,13 @@ data class RoomListState(
|
||||||
val displayVerificationPrompt: Boolean,
|
val displayVerificationPrompt: Boolean,
|
||||||
val hasNetworkConnection: Boolean,
|
val hasNetworkConnection: Boolean,
|
||||||
val snackbarMessage: SnackbarMessage?,
|
val snackbarMessage: SnackbarMessage?,
|
||||||
val displayInvites: Boolean,
|
val invitesState: InvitesState,
|
||||||
val displaySearchResults: Boolean,
|
val displaySearchResults: Boolean,
|
||||||
val eventSink: (RoomListEvents) -> Unit
|
val eventSink: (RoomListEvents) -> Unit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class InvitesState {
|
||||||
|
NoInvites,
|
||||||
|
SeenInvites,
|
||||||
|
NewInvites,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
||||||
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
|
||||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import io.element.android.libraries.ui.strings.R as StringR
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
@ -35,21 +35,22 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
|
||||||
aRoomListState().copy(displayVerificationPrompt = true),
|
aRoomListState().copy(displayVerificationPrompt = true),
|
||||||
aRoomListState().copy(snackbarMessage = SnackbarMessage(StringR.string.common_verification_complete)),
|
aRoomListState().copy(snackbarMessage = SnackbarMessage(StringR.string.common_verification_complete)),
|
||||||
aRoomListState().copy(hasNetworkConnection = false),
|
aRoomListState().copy(hasNetworkConnection = false),
|
||||||
aRoomListState().copy(displayInvites = true),
|
aRoomListState().copy(invitesState = InvitesState.SeenInvites),
|
||||||
|
aRoomListState().copy(invitesState = InvitesState.NewInvites),
|
||||||
aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()),
|
aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()),
|
||||||
aRoomListState().copy(displaySearchResults = true),
|
aRoomListState().copy(displaySearchResults = true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun aRoomListState() = RoomListState(
|
internal fun aRoomListState() = RoomListState(
|
||||||
matrixUser = MatrixUser(id = UserId("@id:domain"), username = "User#1", avatarData = AvatarData("@id:domain", "U")),
|
matrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"),
|
||||||
roomList = aRoomListRoomSummaryList(),
|
roomList = aRoomListRoomSummaryList(),
|
||||||
filter = "filter",
|
filter = "filter",
|
||||||
filteredRoomList = aRoomListRoomSummaryList(),
|
filteredRoomList = aRoomListRoomSummaryList(),
|
||||||
hasNetworkConnection = true,
|
hasNetworkConnection = true,
|
||||||
snackbarMessage = null,
|
snackbarMessage = null,
|
||||||
displayVerificationPrompt = false,
|
displayVerificationPrompt = false,
|
||||||
displayInvites = false,
|
invitesState = InvitesState.NoInvites,
|
||||||
displaySearchResults = false,
|
displaySearchResults = false,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -206,19 +206,23 @@ fun RoomListContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.displayInvites) {
|
if (state.invitesState != InvitesState.NoInvites) {
|
||||||
item {
|
item {
|
||||||
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxSize()) {
|
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxSize()) {
|
||||||
TextButton(
|
TextButton(
|
||||||
content = {
|
content = {
|
||||||
Text(stringResource(StringR.string.action_invites_list))
|
Text(stringResource(StringR.string.action_invites_list))
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
Box(
|
if (state.invitesState == InvitesState.NewInvites) {
|
||||||
modifier = Modifier
|
Spacer(Modifier.size(8.dp))
|
||||||
.size(12.dp)
|
|
||||||
.clip(CircleShape)
|
Box(
|
||||||
.background(MaterialTheme.roomListUnreadIndicator())
|
modifier = Modifier
|
||||||
)
|
.size(12.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.roomListUnreadIndicator())
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onClick = onInvitesClicked,
|
onClick = onInvitesClicked,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
@ -35,7 +38,6 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.roomlist.impl.R
|
import io.element.android.features.roomlist.impl.R
|
||||||
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.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.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
|
@ -44,7 +46,8 @@ import io.element.android.libraries.designsystem.theme.components.MediumTopAppBa
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.utils.LogCompositions
|
import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||||
import io.element.android.libraries.testtags.TestTags
|
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
|
||||||
|
|
@ -107,7 +110,8 @@ private fun DefaultRoomListTopBar(
|
||||||
modifier = Modifier.testTag(TestTags.homeScreenSettings),
|
modifier = Modifier.testTag(TestTags.homeScreenSettings),
|
||||||
onClick = onOpenSettings
|
onClick = onOpenSettings
|
||||||
) {
|
) {
|
||||||
Avatar(matrixUser.avatarData, contentDescription = stringResource(StringR.string.common_settings))
|
val avatarData by remember { derivedStateOf { matrixUser.getAvatarData() } }
|
||||||
|
Avatar(avatarData, contentDescription = stringResource(StringR.string.common_settings))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -135,7 +139,7 @@ internal fun DefaultRoomListTopBarDarkPreview() = ElementPreviewDark { DefaultRo
|
||||||
@Composable
|
@Composable
|
||||||
private fun DefaultRoomListTopBarPreview() {
|
private fun DefaultRoomListTopBarPreview() {
|
||||||
DefaultRoomListTopBar(
|
DefaultRoomListTopBar(
|
||||||
matrixUser = MatrixUser(UserId("@id:domain"), "Alice", AvatarData("@id:domain", "Alice")),
|
matrixUser = MatrixUser(UserId("@id:domain"), "Alice"),
|
||||||
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
|
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
/*
|
||||||
|
* 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.roomlist.impl
|
||||||
|
|
||||||
|
import app.cash.molecule.RecompositionClock
|
||||||
|
import app.cash.molecule.moleculeFlow
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth
|
||||||
|
import io.element.android.features.invitelist.test.FakeSeenInvitesStore
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
||||||
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
|
||||||
|
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
|
||||||
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
internal class DefaultInviteStateDataSourceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `emits NoInvites state if invites list is empty`() = runTest {
|
||||||
|
val matrixDataSource = FakeRoomSummaryDataSource()
|
||||||
|
val client = FakeMatrixClient(invitesDataSource = matrixDataSource)
|
||||||
|
val seenStore = FakeSeenInvitesStore()
|
||||||
|
val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers())
|
||||||
|
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
dataSource.inviteState()
|
||||||
|
}.test {
|
||||||
|
Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NoInvites)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `emits NewInvites state if unseen invite exists`() = runTest {
|
||||||
|
val matrixDataSource = FakeRoomSummaryDataSource()
|
||||||
|
matrixDataSource.postRoomSummary(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID)))
|
||||||
|
val client = FakeMatrixClient(invitesDataSource = matrixDataSource)
|
||||||
|
val seenStore = FakeSeenInvitesStore()
|
||||||
|
val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers())
|
||||||
|
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
dataSource.inviteState()
|
||||||
|
}.test {
|
||||||
|
skipItems(1)
|
||||||
|
Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NewInvites)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `emits NewInvites state if multiple invites exist and at least one is unseen`() = runTest {
|
||||||
|
val matrixDataSource = FakeRoomSummaryDataSource()
|
||||||
|
matrixDataSource.postRoomSummary(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID), aRoomSummaryFilled(roomId = A_ROOM_ID_2)))
|
||||||
|
val client = FakeMatrixClient(invitesDataSource = matrixDataSource)
|
||||||
|
val seenStore = FakeSeenInvitesStore()
|
||||||
|
seenStore.publishRoomIds(setOf(A_ROOM_ID))
|
||||||
|
val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers())
|
||||||
|
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
dataSource.inviteState()
|
||||||
|
}.test {
|
||||||
|
skipItems(1)
|
||||||
|
Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NewInvites)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `emits SeenInvites state if invite exists in seen store`() = runTest {
|
||||||
|
val matrixDataSource = FakeRoomSummaryDataSource()
|
||||||
|
matrixDataSource.postRoomSummary(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID)))
|
||||||
|
val client = FakeMatrixClient(invitesDataSource = matrixDataSource)
|
||||||
|
val seenStore = FakeSeenInvitesStore()
|
||||||
|
seenStore.publishRoomIds(setOf(A_ROOM_ID))
|
||||||
|
val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers())
|
||||||
|
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
dataSource.inviteState()
|
||||||
|
}.test {
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
Truth.assertThat(awaitItem()).isEqualTo(InvitesState.SeenInvites)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `emits new state in response to upstream events`() = runTest {
|
||||||
|
val matrixDataSource = FakeRoomSummaryDataSource()
|
||||||
|
val client = FakeMatrixClient(invitesDataSource = matrixDataSource)
|
||||||
|
val seenStore = FakeSeenInvitesStore()
|
||||||
|
val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers())
|
||||||
|
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
dataSource.inviteState()
|
||||||
|
}.test {
|
||||||
|
// Initially there are no invites
|
||||||
|
Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NoInvites)
|
||||||
|
|
||||||
|
// When a single invite is received, state should be NewInvites
|
||||||
|
matrixDataSource.postRoomSummary(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID)))
|
||||||
|
skipItems(1)
|
||||||
|
Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NewInvites)
|
||||||
|
|
||||||
|
// If that invite is marked as seen, then the state becomes SeenInvites
|
||||||
|
seenStore.publishRoomIds(setOf(A_ROOM_ID))
|
||||||
|
skipItems(1)
|
||||||
|
Truth.assertThat(awaitItem()).isEqualTo(InvitesState.SeenInvites)
|
||||||
|
|
||||||
|
// Another new invite resets it to NewInvites
|
||||||
|
matrixDataSource.postRoomSummary(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID), aRoomSummaryFilled(roomId = A_ROOM_ID_2)))
|
||||||
|
skipItems(1)
|
||||||
|
Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NewInvites)
|
||||||
|
|
||||||
|
// All of the invites going away reverts to NoInvites
|
||||||
|
matrixDataSource.postRoomSummary(emptyList())
|
||||||
|
skipItems(1)
|
||||||
|
Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NoInvites)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
* 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.roomlist.impl
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
|
||||||
|
class FakeInviteDataSource(
|
||||||
|
private val flow: Flow<InvitesState> = flowOf()
|
||||||
|
) : InviteStateDataSource {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun inviteState(): InvitesState {
|
||||||
|
val state = flow.collectAsState(initial = InvitesState.NoInvites)
|
||||||
|
return state.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,10 +38,12 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
|
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
|
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
|
||||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class RoomListPresenterTests {
|
@OptIn(ExperimentalCoroutinesApi::class) class RoomListPresenterTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - should start with no user and then load user with success`() = runTest {
|
fun `present - should start with no user and then load user with success`() = runTest {
|
||||||
|
|
@ -52,6 +54,7 @@ class RoomListPresenterTests {
|
||||||
FakeSessionVerificationService(),
|
FakeSessionVerificationService(),
|
||||||
FakeNetworkMonitor(),
|
FakeNetworkMonitor(),
|
||||||
SnackbarDispatcher(),
|
SnackbarDispatcher(),
|
||||||
|
FakeInviteDataSource(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -60,10 +63,9 @@ class RoomListPresenterTests {
|
||||||
Truth.assertThat(initialState.matrixUser).isNull()
|
Truth.assertThat(initialState.matrixUser).isNull()
|
||||||
val withUserState = awaitItem()
|
val withUserState = awaitItem()
|
||||||
Truth.assertThat(withUserState.matrixUser).isNotNull()
|
Truth.assertThat(withUserState.matrixUser).isNotNull()
|
||||||
Truth.assertThat(withUserState.matrixUser!!.id).isEqualTo(A_USER_ID)
|
Truth.assertThat(withUserState.matrixUser!!.userId).isEqualTo(A_USER_ID)
|
||||||
Truth.assertThat(withUserState.matrixUser!!.username).isEqualTo(A_USER_NAME)
|
Truth.assertThat(withUserState.matrixUser!!.displayName).isEqualTo(A_USER_NAME)
|
||||||
Truth.assertThat(withUserState.matrixUser!!.avatarData.name).isEqualTo(A_USER_NAME)
|
Truth.assertThat(withUserState.matrixUser!!.avatarUrl).isEqualTo(AN_AVATAR_URL)
|
||||||
Truth.assertThat(withUserState.matrixUser!!.avatarData.url).isEqualTo(AN_AVATAR_URL)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,6 +82,7 @@ class RoomListPresenterTests {
|
||||||
FakeSessionVerificationService(),
|
FakeSessionVerificationService(),
|
||||||
FakeNetworkMonitor(),
|
FakeNetworkMonitor(),
|
||||||
SnackbarDispatcher(),
|
SnackbarDispatcher(),
|
||||||
|
FakeInviteDataSource(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -88,8 +91,6 @@ class RoomListPresenterTests {
|
||||||
Truth.assertThat(initialState.matrixUser).isNull()
|
Truth.assertThat(initialState.matrixUser).isNull()
|
||||||
val withUserState = awaitItem()
|
val withUserState = awaitItem()
|
||||||
Truth.assertThat(withUserState.matrixUser).isNotNull()
|
Truth.assertThat(withUserState.matrixUser).isNotNull()
|
||||||
// username fallback to user id value
|
|
||||||
Truth.assertThat(withUserState.matrixUser!!.username).isEqualTo(A_USER_ID.value)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,6 +103,7 @@ class RoomListPresenterTests {
|
||||||
FakeSessionVerificationService(),
|
FakeSessionVerificationService(),
|
||||||
FakeNetworkMonitor(),
|
FakeNetworkMonitor(),
|
||||||
SnackbarDispatcher(),
|
SnackbarDispatcher(),
|
||||||
|
FakeInviteDataSource(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -130,6 +132,7 @@ class RoomListPresenterTests {
|
||||||
FakeSessionVerificationService(),
|
FakeSessionVerificationService(),
|
||||||
FakeNetworkMonitor(),
|
FakeNetworkMonitor(),
|
||||||
SnackbarDispatcher(),
|
SnackbarDispatcher(),
|
||||||
|
FakeInviteDataSource(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -161,6 +164,7 @@ class RoomListPresenterTests {
|
||||||
FakeSessionVerificationService(),
|
FakeSessionVerificationService(),
|
||||||
FakeNetworkMonitor(),
|
FakeNetworkMonitor(),
|
||||||
SnackbarDispatcher(),
|
SnackbarDispatcher(),
|
||||||
|
FakeInviteDataSource(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -198,6 +202,7 @@ class RoomListPresenterTests {
|
||||||
FakeSessionVerificationService(),
|
FakeSessionVerificationService(),
|
||||||
FakeNetworkMonitor(),
|
FakeNetworkMonitor(),
|
||||||
SnackbarDispatcher(),
|
SnackbarDispatcher(),
|
||||||
|
FakeInviteDataSource(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -248,6 +253,7 @@ class RoomListPresenterTests {
|
||||||
},
|
},
|
||||||
FakeNetworkMonitor(),
|
FakeNetworkMonitor(),
|
||||||
SnackbarDispatcher(),
|
SnackbarDispatcher(),
|
||||||
|
FakeInviteDataSource(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -261,31 +267,34 @@ class RoomListPresenterTests {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - displays invites row if any invites exist`() = runTest {
|
fun `present - sets invite state`() = runTest {
|
||||||
val invitesDataSource = FakeRoomSummaryDataSource()
|
val inviteStateFlow = MutableStateFlow(InvitesState.NoInvites)
|
||||||
val presenter = RoomListPresenter(
|
val presenter = RoomListPresenter(
|
||||||
FakeMatrixClient(
|
FakeMatrixClient(
|
||||||
sessionId = A_SESSION_ID,
|
sessionId = A_SESSION_ID,
|
||||||
invitesDataSource = invitesDataSource
|
|
||||||
),
|
),
|
||||||
createDateFormatter(),
|
createDateFormatter(),
|
||||||
FakeRoomLastMessageFormatter(),
|
FakeRoomLastMessageFormatter(),
|
||||||
FakeSessionVerificationService(),
|
FakeSessionVerificationService(),
|
||||||
FakeNetworkMonitor(),
|
FakeNetworkMonitor(),
|
||||||
SnackbarDispatcher(),
|
SnackbarDispatcher(),
|
||||||
|
FakeInviteDataSource(inviteStateFlow),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
|
|
||||||
Truth.assertThat(awaitItem().displayInvites).isFalse()
|
Truth.assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NoInvites)
|
||||||
|
|
||||||
invitesDataSource.postRoomSummary(listOf(aRoomSummaryFilled()))
|
inviteStateFlow.value = InvitesState.SeenInvites
|
||||||
Truth.assertThat(awaitItem().displayInvites).isTrue()
|
Truth.assertThat(awaitItem().invitesState).isEqualTo(InvitesState.SeenInvites)
|
||||||
|
|
||||||
invitesDataSource.postRoomSummary(listOf())
|
inviteStateFlow.value = InvitesState.NewInvites
|
||||||
Truth.assertThat(awaitItem().displayInvites).isFalse()
|
Truth.assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NewInvites)
|
||||||
|
|
||||||
|
inviteStateFlow.value = InvitesState.NoInvites
|
||||||
|
Truth.assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NoInvites)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
*/
|
*/
|
||||||
plugins {
|
plugins {
|
||||||
id("io.element.android-compose-library")
|
id("io.element.android-compose-library")
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
@ -27,4 +28,5 @@ dependencies {
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
implementation(projects.libraries.matrixui)
|
implementation(projects.libraries.matrixui)
|
||||||
|
ksp(libs.showkase.processor)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
package io.element.android.features.userlist.api
|
package io.element.android.features.userlist.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.ui.model.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
|
||||||
interface UserListDataSource {
|
interface UserListDataSource {
|
||||||
//TODO should probably have a flow
|
//TODO should probably have a flow
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.userlist.api
|
package io.element.android.features.userlist.api
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.userlist.api
|
package io.element.android.features.userlist.api
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
|
||||||
sealed interface UserListEvents {
|
sealed interface UserListEvents {
|
||||||
data class UpdateSearchQuery(val query: String) : UserListEvents
|
data class UpdateSearchQuery(val query: String) : UserListEvents
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,14 @@ package io.element.android.features.userlist.api
|
||||||
|
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,12 @@
|
||||||
|
|
||||||
package io.element.android.features.userlist.api
|
package io.element.android.features.userlist.api
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.ui.model.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: ImmutableList<MatrixUser>,
|
val searchResults: UserSearchResultState,
|
||||||
val selectedUsers: ImmutableList<MatrixUser>,
|
val selectedUsers: ImmutableList<MatrixUser>,
|
||||||
val isSearchActive: Boolean,
|
val isSearchActive: Boolean,
|
||||||
val selectionMode: SelectionMode,
|
val selectionMode: SelectionMode,
|
||||||
|
|
@ -29,3 +29,14 @@ 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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,22 +37,27 @@ open class UserListStateProvider : PreviewParameterProvider<UserListState> {
|
||||||
isSearchActive = true,
|
isSearchActive = true,
|
||||||
searchQuery = "@someone:matrix.org",
|
searchQuery = "@someone:matrix.org",
|
||||||
selectedUsers = aListOfSelectedUsers(),
|
selectedUsers = aListOfSelectedUsers(),
|
||||||
searchResults = aMatrixUserList().toImmutableList(),
|
searchResults = UserSearchResultState.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 = aMatrixUserList().toImmutableList(),
|
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()),
|
||||||
)
|
),
|
||||||
|
aUserListState().copy(
|
||||||
|
isSearchActive = true,
|
||||||
|
searchQuery = "something-with-no-results",
|
||||||
|
searchResults = UserSearchResultState.NoResults
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aUserListState() = UserListState(
|
fun aUserListState() = UserListState(
|
||||||
isSearchActive = false,
|
isSearchActive = false,
|
||||||
searchQuery = "",
|
searchQuery = "",
|
||||||
searchResults = persistentListOf(),
|
searchResults = UserSearchResultState.NotSearching,
|
||||||
selectedUsers = persistentListOf(),
|
selectedUsers = persistentListOf(),
|
||||||
selectionMode = SelectionMode.Single,
|
selectionMode = SelectionMode.Single,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,9 @@ import androidx.compose.ui.unit.dp
|
||||||
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.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.ui.components.CheckableMatrixUserRow
|
import io.element.android.libraries.matrix.ui.components.CheckableMatrixUserRow
|
||||||
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.model.MatrixUser
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchMultipleUsersResultItem(
|
fun SearchMultipleUsersResultItem(
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,9 @@ import androidx.compose.ui.unit.dp
|
||||||
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.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
|
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
|
||||||
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.model.MatrixUser
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchSingleUserResultItem(
|
fun SearchSingleUserResultItem(
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,17 @@
|
||||||
package io.element.android.features.userlist.api.components
|
package io.element.android.features.userlist.api.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
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.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Search
|
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.SearchBarDefaults
|
import androidx.compose.material3.SearchBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -32,13 +35,15 @@ import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
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.components.button.BackButton
|
||||||
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.SearchBar
|
import io.element.android.libraries.designsystem.theme.components.SearchBar
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.ui.strings.R
|
import io.element.android.libraries.ui.strings.R
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
|
@ -46,7 +51,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchUserBar(
|
fun SearchUserBar(
|
||||||
query: String,
|
query: String,
|
||||||
results: ImmutableList<MatrixUser>,
|
state: UserSearchResultState,
|
||||||
selectedUsers: ImmutableList<MatrixUser>,
|
selectedUsers: ImmutableList<MatrixUser>,
|
||||||
active: Boolean,
|
active: Boolean,
|
||||||
isMultiSelectionEnabled: Boolean,
|
isMultiSelectionEnabled: Boolean,
|
||||||
|
|
@ -91,6 +96,7 @@ fun SearchUserBar(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
!active -> {
|
!active -> {
|
||||||
{
|
{
|
||||||
Icon(
|
Icon(
|
||||||
|
|
@ -100,6 +106,7 @@ fun SearchUserBar(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> null
|
else -> null
|
||||||
},
|
},
|
||||||
colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent),
|
colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
|
@ -113,31 +120,43 @@ fun SearchUserBar(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn {
|
|
||||||
if (isMultiSelectionEnabled) {
|
if (state is UserSearchResultState.Results) {
|
||||||
items(results) { matrixUser ->
|
LazyColumn {
|
||||||
SearchMultipleUsersResultItem(
|
if (isMultiSelectionEnabled) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
items(state.results) { matrixUser ->
|
||||||
matrixUser = matrixUser,
|
SearchMultipleUsersResultItem(
|
||||||
isUserSelected = selectedUsers.find { it.id == matrixUser.id } != null,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onCheckedChange = { checked ->
|
matrixUser = matrixUser,
|
||||||
if (checked) {
|
isUserSelected = selectedUsers.find { it.userId == matrixUser.userId } != null,
|
||||||
onUserSelected(matrixUser)
|
onCheckedChange = { checked ->
|
||||||
} else {
|
if (checked) {
|
||||||
onUserDeselected(matrixUser)
|
onUserSelected(matrixUser)
|
||||||
|
} else {
|
||||||
|
onUserDeselected(matrixUser)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
)
|
}
|
||||||
}
|
} else {
|
||||||
} else {
|
items(state.results) { matrixUser ->
|
||||||
items(results) { matrixUser ->
|
SearchSingleUserResultItem(
|
||||||
SearchSingleUserResultItem(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
matrixUser = matrixUser,
|
||||||
matrixUser = matrixUser,
|
onClick = { onUserSelected(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()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,9 @@ 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.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.ui.components.aMatrixUser
|
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
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
|
||||||
|
|
||||||
|
|
@ -55,7 +56,7 @@ fun SelectedUser(
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Avatar(matrixUser.avatarData.copy(size = AvatarSize.Custom(56.dp)))
|
Avatar(matrixUser.getAvatarData(size = AvatarSize.Custom(56.dp)))
|
||||||
Text(
|
Text(
|
||||||
text = matrixUser.getBestName(),
|
text = matrixUser.getBestName(),
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.userlist.api.aListOfSelectedUsers
|
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.ui.model.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import io.element.android.features.userlist.api.UserListState
|
||||||
import io.element.android.features.userlist.api.UserListStateProvider
|
import io.element.android.features.userlist.api.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.ui.model.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UserListView(
|
fun UserListView(
|
||||||
|
|
@ -44,7 +44,7 @@ fun UserListView(
|
||||||
SearchUserBar(
|
SearchUserBar(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
query = state.searchQuery,
|
query = state.searchQuery,
|
||||||
results = state.searchResults,
|
state = state.searchResults,
|
||||||
selectedUsers = state.selectedUsers,
|
selectedUsers = state.selectedUsers,
|
||||||
active = state.isSearchActive,
|
active = state.isSearchActive,
|
||||||
isMultiSelectionEnabled = state.isMultiSelectionEnabled,
|
isMultiSelectionEnabled = state.isMultiSelectionEnabled,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ package io.element.android.features.userlist.impl
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -35,13 +34,14 @@ import io.element.android.features.userlist.api.UserListEvents
|
||||||
import io.element.android.features.userlist.api.UserListPresenter
|
import io.element.android.features.userlist.api.UserListPresenter
|
||||||
import io.element.android.features.userlist.api.UserListPresenterArgs
|
import io.element.android.features.userlist.api.UserListPresenterArgs
|
||||||
import io.element.android.features.userlist.api.UserListState
|
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.MatrixPatterns
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
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,
|
||||||
|
|
@ -62,52 +62,54 @@ class DefaultUserListPresenter @AssistedInject constructor(
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): UserListState {
|
override fun present(): UserListState {
|
||||||
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
||||||
val selectedUsers = userListDataStore.selectedUsers().collectAsState(emptyList())
|
val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
|
||||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||||
val searchResults: MutableState<ImmutableList<MatrixUser>> = remember {
|
var searchResults: UserSearchResultState by remember {
|
||||||
mutableStateOf(persistentListOf())
|
mutableStateOf(UserSearchResultState.NotSearching)
|
||||||
}
|
|
||||||
|
|
||||||
fun handleEvents(event: UserListEvents) {
|
|
||||||
when (event) {
|
|
||||||
is UserListEvents.OnSearchActiveChanged -> isSearchActive = event.active
|
|
||||||
is UserListEvents.UpdateSearchQuery -> searchQuery = event.query
|
|
||||||
is UserListEvents.AddToSelection -> userListDataStore.selectUser(event.matrixUser)
|
|
||||||
is UserListEvents.RemoveFromSelection -> userListDataStore.removeUserFromSelection(event.matrixUser)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(searchQuery) {
|
LaunchedEffect(searchQuery) {
|
||||||
// Clear the search results before performing the search, manually add a fake result with the matrixId, if any
|
// Clear the search results before performing the search, manually add a fake result with the matrixId, if any
|
||||||
searchResults.value = if (MatrixPatterns.isUserId(searchQuery)) {
|
searchResults = if (MatrixPatterns.isUserId(searchQuery)) {
|
||||||
persistentListOf(MatrixUser(UserId(searchQuery)))
|
UserSearchResultState.Results(persistentListOf(MatrixUser(UserId(searchQuery))))
|
||||||
} else {
|
} else {
|
||||||
persistentListOf()
|
UserSearchResultState.NotSearching
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debounce
|
||||||
|
delay(args.searchDebouncePeriodMillis)
|
||||||
|
|
||||||
// Perform the search asynchronously
|
// Perform the search asynchronously
|
||||||
if (searchQuery.isNotEmpty()) {
|
if (searchQuery.length >= args.minimumSearchLength) {
|
||||||
searchResults.value = performSearch(searchQuery)
|
searchResults = performSearch(searchQuery)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return UserListState(
|
return UserListState(
|
||||||
searchQuery = searchQuery,
|
searchQuery = searchQuery,
|
||||||
searchResults = searchResults.value,
|
searchResults = searchResults,
|
||||||
selectedUsers = selectedUsers.value.toImmutableList(),
|
selectedUsers = selectedUsers.toImmutableList(),
|
||||||
isSearchActive = isSearchActive,
|
isSearchActive = isSearchActive,
|
||||||
selectionMode = args.selectionMode,
|
selectionMode = args.selectionMode,
|
||||||
eventSink = ::handleEvents,
|
eventSink = { event ->
|
||||||
|
when (event) {
|
||||||
|
is UserListEvents.OnSearchActiveChanged -> isSearchActive = event.active
|
||||||
|
is UserListEvents.UpdateSearchQuery -> searchQuery = event.query
|
||||||
|
is UserListEvents.AddToSelection -> userListDataStore.selectUser(event.matrixUser)
|
||||||
|
is UserListEvents.RemoveFromSelection -> userListDataStore.removeUserFromSelection(event.matrixUser)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun performSearch(query: String): ImmutableList<MatrixUser> {
|
private suspend fun performSearch(query: String): UserSearchResultState {
|
||||||
val isMatrixId = MatrixPatterns.isUserId(query)
|
val isMatrixId = MatrixPatterns.isUserId(query)
|
||||||
val results = userListDataSource.search(query).toMutableList()
|
val results = userListDataSource.search(query).toMutableList()
|
||||||
if (isMatrixId && results.none { it.id.value == query }) {
|
if (isMatrixId && results.none { it.userId.value == query }) {
|
||||||
val getProfileResult: MatrixUser? = userListDataSource.getProfile(UserId(query))
|
val getProfileResult: MatrixUser? = userListDataSource.getProfile(UserId(query))
|
||||||
val profile = getProfileResult ?: MatrixUser(UserId(query))
|
val profile = getProfileResult ?: MatrixUser(UserId(query))
|
||||||
results.add(0, profile)
|
results.add(0, profile)
|
||||||
}
|
}
|
||||||
return results.toImmutableList()
|
return if (results.isEmpty()) UserSearchResultState.NoResults else UserSearchResultState.Results(results.toImmutableList())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,14 @@ import io.element.android.features.userlist.api.SelectionMode
|
||||||
import io.element.android.features.userlist.api.UserListDataStore
|
import io.element.android.features.userlist.api.UserListDataStore
|
||||||
import io.element.android.features.userlist.api.UserListEvents
|
import io.element.android.features.userlist.api.UserListEvents
|
||||||
import io.element.android.features.userlist.api.UserListPresenterArgs
|
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.features.userlist.test.FakeUserListDataSource
|
||||||
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.ui.components.aMatrixUser
|
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
|
||||||
import io.mockk.coJustRun
|
import io.mockk.coJustRun
|
||||||
import io.mockk.mockkConstructor
|
import io.mockk.mockkConstructor
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -55,7 +57,7 @@ class DefaultUserListPresenterTests {
|
||||||
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).isEmpty()
|
assertThat(initialState.searchResults).isEqualTo(UserSearchResultState.NotSearching)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,7 +76,7 @@ class DefaultUserListPresenterTests {
|
||||||
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).isEmpty()
|
assertThat(initialState.searchResults).isEqualTo(UserSearchResultState.NotSearching)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,18 +98,42 @@ 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).containsExactly(MatrixUser(UserId(matrixIdQuery)))
|
assertThat(awaitItem().searchResults).isEqualTo(UserSearchResultState.Results(persistentListOf(MatrixUser(UserId(matrixIdQuery)))))
|
||||||
|
|
||||||
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).isEmpty()
|
assertThat(awaitItem().searchResults).isEqualTo(UserSearchResultState.NoResults)
|
||||||
|
|
||||||
initialState.eventSink(UserListEvents.OnSearchActiveChanged(false))
|
initialState.eventSink(UserListEvents.OnSearchActiveChanged(false))
|
||||||
assertThat(awaitItem().isSearchActive).isFalse()
|
assertThat(awaitItem().isSearchActive).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - searches when minimum length exceeded`() = runTest {
|
||||||
|
val presenter = DefaultUserListPresenter(
|
||||||
|
UserListPresenterArgs(selectionMode = SelectionMode.Single, minimumSearchLength = 3),
|
||||||
|
userListDataSource,
|
||||||
|
UserListDataStore(),
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
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"))
|
||||||
|
assertThat(awaitItem().searchResults).isEqualTo(UserSearchResultState.NotSearching)
|
||||||
|
assertThat(awaitItem().searchResults).isEqualTo(UserSearchResultState.Results(persistentListOf(aMatrixUser())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - select a user`() = runTest {
|
fun `present - select a user`() = runTest {
|
||||||
mockkConstructor(LazyListState::class)
|
mockkConstructor(LazyListState::class)
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ package io.element.android.features.userlist.test
|
||||||
|
|
||||||
import io.element.android.features.userlist.api.UserListDataSource
|
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.ui.model.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
|
||||||
class FakeUserListDataSource : UserListDataSource {
|
class FakeUserListDataSource : UserListDataSource {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@
|
||||||
[versions]
|
[versions]
|
||||||
# Project
|
# Project
|
||||||
android_gradle_plugin = "8.0.1"
|
android_gradle_plugin = "8.0.1"
|
||||||
kotlin = "1.8.20"
|
kotlin = "1.8.21"
|
||||||
ksp = "1.8.20-1.0.11"
|
ksp = "1.8.21-1.0.11"
|
||||||
molecule = "0.9.0"
|
molecule = "0.9.0"
|
||||||
|
|
||||||
# AndroidX
|
# AndroidX
|
||||||
material = "1.8.0"
|
material = "1.9.0"
|
||||||
core = "1.10.0"
|
core = "1.10.0"
|
||||||
datastore = "1.0.0"
|
datastore = "1.0.0"
|
||||||
constraintlayout = "2.1.4"
|
constraintlayout = "2.1.4"
|
||||||
|
|
@ -20,7 +20,7 @@ startup = "1.1.1"
|
||||||
|
|
||||||
# Compose
|
# Compose
|
||||||
compose_bom = "2023.04.01"
|
compose_bom = "2023.04.01"
|
||||||
composecompiler = "1.4.6"
|
composecompiler = "1.4.7"
|
||||||
|
|
||||||
# Coroutines
|
# Coroutines
|
||||||
coroutines = "1.6.4"
|
coroutines = "1.6.4"
|
||||||
|
|
@ -35,7 +35,7 @@ test_core = "1.5.0"
|
||||||
coil = "2.3.0"
|
coil = "2.3.0"
|
||||||
datetime = "0.4.0"
|
datetime = "0.4.0"
|
||||||
serialization_json = "1.5.0"
|
serialization_json = "1.5.0"
|
||||||
showkase = "1.0.0-beta17"
|
showkase = "1.0.0-beta18"
|
||||||
jsoup = "1.16.1"
|
jsoup = "1.16.1"
|
||||||
appyx = "1.2.0"
|
appyx = "1.2.0"
|
||||||
dependencycheck = "8.2.1"
|
dependencycheck = "8.2.1"
|
||||||
|
|
@ -114,7 +114,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"
|
test_robolectric = "org.robolectric:robolectric:4.10.1"
|
||||||
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
|
||||||
|
|
@ -145,7 +145,7 @@ anvil_compiler_api = { module = "com.squareup.anvil:compiler-api", version.ref =
|
||||||
anvil_compiler_utils = { module = "com.squareup.anvil:compiler-utils", version.ref = "anvil" }
|
anvil_compiler_utils = { module = "com.squareup.anvil:compiler-utils", version.ref = "anvil" }
|
||||||
|
|
||||||
# Composer
|
# Composer
|
||||||
wysiwyg = "io.element.android:wysiwyg:1.2.2"
|
wysiwyg = "io.element.android:wysiwyg:2.1.0"
|
||||||
|
|
||||||
# Miscellaneous
|
# Miscellaneous
|
||||||
# Add unused dependency to androidx.compose.compiler:compiler to let Renovate create PR to change the
|
# Add unused dependency to androidx.compose.compiler:compiler to let Renovate create PR to change the
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ android {
|
||||||
implementation(libs.accompanist.systemui)
|
implementation(libs.accompanist.systemui)
|
||||||
implementation(projects.libraries.elementresources)
|
implementation(projects.libraries.elementresources)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
|
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
|
kspTest(libs.showkase.processor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,8 @@ import androidx.compose.ui.text.ParagraphStyle
|
||||||
import androidx.compose.ui.text.TextLayoutResult
|
import androidx.compose.ui.text.TextLayoutResult
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import kotlinx.collections.immutable.ImmutableMap
|
import kotlinx.collections.immutable.ImmutableMap
|
||||||
import kotlinx.collections.immutable.persistentMapOf
|
import kotlinx.collections.immutable.persistentMapOf
|
||||||
|
|
@ -97,15 +97,10 @@ fun ClickableLinkText(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview(group = PreviewGroup.Text)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ClickableLinkTextLightPreview() =
|
internal fun ClickableLinkTextPreview() =
|
||||||
ElementPreviewLight { ContentToPreview() }
|
ElementThemedPreview { ContentToPreview() }
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
internal fun ClickableLinkTextDarkPreview() =
|
|
||||||
ElementPreviewDark { ContentToPreview() }
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContentToPreview() {
|
private fun ContentToPreview() {
|
||||||
|
|
@ -117,3 +112,4 @@ private fun ContentToPreview() {
|
||||||
interactionSource = MutableInteractionSource(),
|
interactionSource = MutableInteractionSource(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ 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.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||||
import io.element.android.libraries.designsystem.theme.components.Checkbox
|
import io.element.android.libraries.designsystem.theme.components.Checkbox
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
|
||||||
|
|
@ -52,13 +52,9 @@ fun LabelledCheckbox(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview(group = PreviewGroup.Toggles)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun LabelledCheckboxLightPreview() = ElementPreviewLight { ContentToPreview() }
|
internal fun LabelledCheckboxPreview() = ElementThemedPreview { ContentToPreview() }
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
internal fun LabelledCheckboxDarkPreview() = ElementPreviewDark { ContentToPreview() }
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContentToPreview() {
|
private fun ContentToPreview() {
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,9 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.components.dialogs.DialogPreview
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||||
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.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
|
||||||
|
|
@ -71,15 +72,43 @@ fun ProgressDialog(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ProgressDialogLightPreview() = ElementPreviewLight { ContentToPreview() }
|
private fun ProgressDialogContent(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
text: String? = null,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
if (!text.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview(group = PreviewGroup.Dialogs)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ProgressDialogDarkPreview() = ElementPreviewDark { ContentToPreview() }
|
internal fun ProgressDialogPreview() = ElementThemedPreview { ContentToPreview() }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContentToPreview() {
|
private fun ContentToPreview() {
|
||||||
ProgressDialog(text = "test dialog content")
|
DialogPreview {
|
||||||
|
ProgressDialogContent(text = "test dialog content")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,8 @@ import androidx.compose.ui.unit.sp
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import io.element.android.libraries.designsystem.AvatarGradientEnd
|
import io.element.android.libraries.designsystem.AvatarGradientEnd
|
||||||
import io.element.android.libraries.designsystem.AvatarGradientStart
|
import io.element.android.libraries.designsystem.AvatarGradientStart
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||||
import io.element.android.libraries.designsystem.preview.debugPlaceholderAvatar
|
import io.element.android.libraries.designsystem.preview.debugPlaceholderAvatar
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -106,17 +106,7 @@ private fun InitialsAvatar(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview(group = PreviewGroup.Avatars)
|
||||||
@Composable
|
@Composable
|
||||||
fun AvatarLightPreview(@PreviewParameter(AvatarDataProvider::class) avatarData: AvatarData) =
|
fun AvatarPreview(@PreviewParameter(AvatarDataProvider::class) avatarData: AvatarData) =
|
||||||
ElementPreviewLight { ContentToPreview(avatarData) }
|
ElementThemedPreview { Avatar(avatarData) }
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun AvatarDarkPreview(@PreviewParameter(AvatarDataProvider::class) avatarData: AvatarData) =
|
|
||||||
ElementPreviewDark { ContentToPreview(avatarData) }
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ContentToPreview(avatarData: AvatarData) {
|
|
||||||
Avatar(avatarData)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue