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