Merge develop into feature/fga/image_loading

This commit is contained in:
ganfra 2023-05-05 12:51:00 +02:00
commit affb928801
602 changed files with 3434 additions and 1627 deletions

2
.gitignore vendored
View file

@ -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
View file

@ -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>

View file

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

View file

@ -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
View file

@ -0,0 +1 @@
[Create and join rooms] New invites are now marked with a badge

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

@ -0,0 +1 @@
[Create and join rooms] Improve user search results calling the "profile" API

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

@ -0,0 +1 @@
Add media pickers to the room screen.

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

@ -0,0 +1 @@
[Create and join rooms] Search for users to start a DM

View file

@ -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!

View file

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

View file

@ -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

View file

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

View file

@ -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,

View file

@ -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

View file

@ -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()

View file

@ -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>,

View file

@ -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

View file

@ -16,7 +16,7 @@
package io.element.android.features.createroom.impl.root package io.element.android.features.createroom.impl.root
import io.element.android.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

View file

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

View file

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

View file

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

View file

@ -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>

View file

@ -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 cant validate this users Matrix ID. The invite might not be received."</string> <string name="screen_start_chat_unknown_profile">"We cant validate this users Matrix ID. The invite might not be received."</string>
<string name="screen_create_room_title">"Create a room"</string>
</resources> </resources>

View file

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

View file

@ -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

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.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>)
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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(

View file

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

View file

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

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

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.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()
}
}

View file

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

View file

@ -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>

View file

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

View file

@ -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 =

View file

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

View file

@ -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) =

View file

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

View file

@ -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("")
} }

View file

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

View file

@ -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 = {}
) )

View file

@ -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(),

View 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 &amp; Video Library"</string>
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
</resources>

View file

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

View file

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

View file

@ -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,

View file

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

View file

@ -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(

View file

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

View file

@ -80,3 +80,4 @@ private fun ContentToPreview() {
state = aCrashDetectionState().copy(crashDetected = true) state = aCrashDetectionState().copy(crashDetected = true)
) )
} }

View file

@ -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>

View file

@ -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()
)

View file

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

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

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

View file

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

View file

@ -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>

View file

@ -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>

View file

@ -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()

View file

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

View file

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

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl
import androidx.compose.runtime.Composable
interface InviteStateDataSource {
@Composable
fun inviteState(): InvitesState
}

View file

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

View file

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

View file

@ -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 = {}
) )

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

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

View file

@ -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 = {}

View file

@ -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(

View file

@ -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(

View file

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

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

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

View file

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

View file

@ -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 {

View file

@ -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

View file

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

View file

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

View file

@ -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() {

View file

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

View file

@ -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