Merge remote-tracking branch 'origin/develop' into

feature/fre/start_chat_with_matrix_id
This commit is contained in:
Florian Renaud
2023-04-05 10:16:33 +02:00
68 changed files with 1157 additions and 187 deletions

View File

@@ -42,12 +42,14 @@ jobs:
app/build/outputs/apk/debug/*.apk
- uses: rnkdsh/action-upload-diawi@v1.3.2
id: diawi
if: ${{ github.event_name == 'pull_request' }}
with:
env:
token: ${{ secrets.DIAWI_TOKEN }}
if: ${{ github.event_name == 'pull_request' && env.token != '' }}
with:
token: ${{ env.token }}
file: app/build/outputs/apk/debug/app-arm64-v8a-debug.apk
- name: Add or update PR comment with QR Code to download APK.
if: ${{ github.event_name == 'pull_request' }}
if: ${{ github.event_name == 'pull_request' && steps.diawi.conclusion == 'success' }}
uses: NejcZdovc/comment-pr@v2
with:
message: |

View File

@@ -13,6 +13,7 @@ jobs:
nightly:
name: Build and publish nightly APK to Firebase
runs-on: ubuntu-latest
if: ${{ github.repository == 'vector-im/element-x-android' }}
steps:
- uses: actions/checkout@v3
- name: Install towncrier

View File

@@ -210,7 +210,7 @@ dependencies {
anvil(projects.anvilcodegen)
// https://developer.android.com/studio/write/java8-support#library-desugaring-versions
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.2")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
implementation(libs.appyx.core)
implementation(libs.androidx.splash)
implementation(libs.androidx.corektx)

View File

@@ -69,6 +69,8 @@ class RoomFlowNode @AssistedInject constructor(
private val inputs: Inputs = inputs()
private val roomFlowPresenter = RoomFlowPresenter(inputs.room)
init {
lifecycle.subscribe(
onCreate = {
@@ -110,6 +112,7 @@ class RoomFlowNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
roomFlowPresenter.present()
Children(
navModel = backstack,
modifier = modifier,

View File

@@ -0,0 +1,46 @@
/*
* 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.appnav
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import timber.log.Timber
class RoomFlowPresenter(
private val room: MatrixRoom,
) : Presenter<RoomFlowState> {
@Composable
override fun present(): RoomFlowState {
// Preload room members so we can quickly detect if the room is a DM room
LaunchedEffect(Unit) {
room.fetchMembers()
.onFailure {
Timber.e(it, "Fail to fetch members for room ${room.roomId}")
}.onSuccess {
Timber.v("Success fetching members for room ${room.roomId}")
}
}
return RoomFlowState
}
}
// At first the return type was Unit, but detekt complained about it
object RoomFlowState

View File

@@ -0,0 +1,62 @@
/*
* 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.appnav
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.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.lang.IllegalStateException
class RoomFlowPresenterTest {
@Test
fun `present - fetches room members`() = runTest {
val fakeTimeline = FakeMatrixTimeline()
val room = FakeMatrixRoom(matrixTimeline = fakeTimeline)
val presenter = RoomFlowPresenter(room)
Truth.assertThat(room.areMembersFetched).isFalse()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
Truth.assertThat(room.areMembersFetched).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - recovers from error while fetching room members`() = runTest {
val fakeTimeline = FakeMatrixTimeline()
val room = FakeMatrixRoom(matrixTimeline = fakeTimeline).apply {
givenFetchMemberResult(Result.failure(IllegalStateException("Some error")))
}
val presenter = RoomFlowPresenter(room)
Truth.assertThat(room.areMembersFetched).isFalse()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
Truth.assertThat(room.areMembersFetched).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
}

View File

@@ -1 +1,2 @@
Implement Room Details screen
Implement Room Member List screen

View File

@@ -47,7 +47,7 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings)
implementation(projects.features.selectusers.api)
implementation(projects.features.userlist.api)
api(projects.features.createroom.api)
testImplementation(libs.test.junit)
@@ -56,8 +56,8 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.selectusers.impl)
testImplementation(projects.features.selectusers.test)
testImplementation(projects.features.userlist.impl)
testImplementation(projects.features.userlist.test)
androidTestImplementation(libs.test.junitext)

View File

@@ -0,0 +1,33 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.createroom.impl
import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.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() : MatrixUserDataSource {
override suspend fun search(query: String): List<MatrixUser> {
return emptyList()
}
override suspend fun getProfile(userId: UserId): MatrixUser? {
return null
}
}

View File

@@ -17,31 +17,38 @@
package io.element.android.features.createroom.impl.addpeople
import androidx.compose.runtime.Composable
import io.element.android.features.selectusers.api.SelectUsersPresenter
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs
import io.element.android.features.selectusers.api.SelectionMode
import io.element.android.features.userlist.api.SelectionMode
import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.libraries.architecture.Presenter
import javax.inject.Inject
import javax.inject.Named
class AddPeoplePresenter @Inject constructor(
private val selectUsersPresenterFactory: SelectUsersPresenter.Factory,
private val userListPresenterFactory: UserListPresenter.Factory,
@Named("AllUsers") private val matrixUserDataSource: MatrixUserDataSource,
) : Presenter<AddPeopleState> {
private val selectUsersPresenter by lazy {
selectUsersPresenterFactory.create(SelectUsersPresenterArgs(SelectionMode.Multiple))
private val userListPresenter by lazy {
userListPresenterFactory.create(
UserListPresenterArgs(selectionMode = SelectionMode.Multiple),
matrixUserDataSource,
)
}
@Composable
override fun present(): AddPeopleState {
val selectUsersState = selectUsersPresenter.present()
val userListState = userListPresenter.present()
fun handleEvents(event: AddPeopleEvents) {
// do nothing for now
}
return AddPeopleState(
selectUsersState = selectUsersState,
userListState = userListState,
eventSink = ::handleEvents,
)
}
}

View File

@@ -16,9 +16,9 @@
package io.element.android.features.createroom.impl.addpeople
import io.element.android.features.selectusers.api.SelectUsersState
import io.element.android.features.userlist.api.UserListState
data class AddPeopleState(
val selectUsersState: SelectUsersState,
val userListState: UserListState,
val eventSink: (AddPeopleEvents) -> Unit,
)

View File

@@ -17,22 +17,22 @@
package io.element.android.features.createroom.impl.addpeople
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.selectusers.api.SelectionMode
import io.element.android.features.selectusers.api.aListOfSelectedUsers
import io.element.android.features.selectusers.api.aSelectUsersState
import io.element.android.features.userlist.api.SelectionMode
import io.element.android.features.userlist.api.aListOfSelectedUsers
import io.element.android.features.userlist.api.aUserListState
open class AddPeopleStateProvider : PreviewParameterProvider<AddPeopleState> {
override val values: Sequence<AddPeopleState>
get() = sequenceOf(
aAddPeopleState(),
aAddPeopleState().copy(
selectUsersState = aSelectUsersState().copy(
userListState = aUserListState().copy(
selectedUsers = aListOfSelectedUsers(),
selectionMode = SelectionMode.Multiple,
)
),
aAddPeopleState().copy(
selectUsersState = aSelectUsersState().copy(
userListState = aUserListState().copy(
selectedUsers = aListOfSelectedUsers(),
isSearchActive = true,
selectionMode = SelectionMode.Multiple,
@@ -42,6 +42,6 @@ open class AddPeopleStateProvider : PreviewParameterProvider<AddPeopleState> {
}
fun aAddPeopleState() = AddPeopleState(
selectUsersState = aSelectUsersState(),
userListState = aUserListState(),
eventSink = {}
)

View File

@@ -29,8 +29,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.userlist.api.UserListView
import io.element.android.features.createroom.impl.R
import io.element.android.features.selectusers.api.SelectUsersView
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@@ -52,9 +52,9 @@ fun AddPeopleView(
Scaffold(
topBar = {
if (!state.selectUsersState.isSearchActive) {
if (!state.userListState.isSearchActive) {
AddPeopleViewTopBar(
hasSelectedUsers = state.selectUsersState.selectedUsers.isNotEmpty(),
hasSelectedUsers = state.userListState.selectedUsers.isNotEmpty(),
onBackPressed = onBackPressed,
onNextPressed = onNextPressed,
)
@@ -66,9 +66,9 @@ fun AddPeopleView(
.fillMaxSize()
.padding(padding),
) {
SelectUsersView(
UserListView(
modifier = Modifier.fillMaxWidth(),
state = state.selectUsersState,
state = state.userListState,
)
}
}

View File

@@ -0,0 +1,35 @@
/*
* 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.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import io.element.android.features.createroom.impl.AllMatrixUsersDataSource
import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.libraries.di.AppScope
import javax.inject.Named
@Module
@ContributesTo(AppScope::class)
interface CreateRoomModule {
@Binds
@Named("AllUsers")
fun bindAllUserListDataSource(dataSource: AllMatrixUsersDataSource): MatrixUserDataSource
}

View File

@@ -21,9 +21,10 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.selectusers.api.SelectUsersPresenter
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs
import io.element.android.features.selectusers.api.SelectionMode
import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.features.userlist.api.SelectionMode
import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute
@@ -33,19 +34,24 @@ import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Named
class CreateRoomRootPresenter @Inject constructor(
private val presenterFactory: SelectUsersPresenter.Factory,
private val presenterFactory: UserListPresenter.Factory,
@Named("AllUsers") private val matrixUserDataSource: MatrixUserDataSource,
private val matrixClient: MatrixClient,
) : Presenter<CreateRoomRootState> {
private val presenter by lazy {
presenterFactory.create(SelectUsersPresenterArgs(SelectionMode.Single))
presenterFactory.create(
UserListPresenterArgs(selectionMode = SelectionMode.Single),
matrixUserDataSource,
)
}
@Composable
override fun present(): CreateRoomRootState {
val selectUsersState = presenter.present()
val userListState = presenter.present()
val localCoroutineScope = rememberCoroutineScope()
val startDmAction: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) }
@@ -65,7 +71,7 @@ class CreateRoomRootPresenter @Inject constructor(
is CreateRoomRootEvents.StartDM -> startDm(event.matrixUser)
CreateRoomRootEvents.RetryStartDM -> {
startDmAction.value = Async.Uninitialized
selectUsersState.selectedUsers.firstOrNull()?.let { startDm(it) }
userListState.selectedUsers.firstOrNull()?.let { startDm(it) }
}
CreateRoomRootEvents.CancelStartDM -> startDmAction.value = Async.Uninitialized
CreateRoomRootEvents.InvitePeople -> Unit // Todo Handle invite people action
@@ -73,7 +79,7 @@ class CreateRoomRootPresenter @Inject constructor(
}
return CreateRoomRootState(
selectUsersState = selectUsersState,
userListState = userListState,
startDmAction = startDmAction.value,
eventSink = ::handleEvents,
)

View File

@@ -16,12 +16,12 @@
package io.element.android.features.createroom.impl.root
import io.element.android.features.selectusers.api.SelectUsersState
import io.element.android.features.userlist.api.UserListState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
data class CreateRoomRootState(
val selectUsersState: SelectUsersState,
val userListState: UserListState,
val startDmAction: Async<RoomId>,
val eventSink: (CreateRoomRootEvents) -> Unit,
)

View File

@@ -17,7 +17,7 @@
package io.element.android.features.createroom.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.selectusers.api.aSelectUsersState
import io.element.android.features.userlist.api.aUserListState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import kotlinx.collections.immutable.persistentListOf
@@ -28,8 +28,8 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
aCreateRoomRootState(),
aCreateRoomRootState().copy(
startDmAction = Async.Loading(),
selectUsersState = aMatrixUser().let {
aSelectUsersState().copy(
userListState = aMatrixUser().let {
aUserListState().copy(
searchQuery = it.id.value,
searchResults = persistentListOf(it),
selectedUsers = persistentListOf(it),
@@ -39,8 +39,8 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
),
aCreateRoomRootState().copy(
startDmAction = Async.Failure(Throwable()),
selectUsersState = aMatrixUser().let {
aSelectUsersState().copy(
userListState = aMatrixUser().let {
aUserListState().copy(
searchQuery = it.id.value,
searchResults = persistentListOf(it),
selectedUsers = persistentListOf(it),
@@ -54,5 +54,5 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
fun aCreateRoomRootState() = CreateRoomRootState(
eventSink = {},
startDmAction = Async.Uninitialized,
selectUsersState = aSelectUsersState(),
userListState = aUserListState(),
)

View File

@@ -41,7 +41,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.createroom.impl.R
import io.element.android.features.selectusers.api.SelectUsersView
import io.element.android.features.userlist.api.UserListView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
@@ -74,7 +74,7 @@ fun CreateRoomRootView(
Scaffold(
modifier = modifier.fillMaxWidth(),
topBar = {
if (!state.selectUsersState.isSearchActive) {
if (!state.userListState.isSearchActive) {
CreateRoomRootViewTopBar(onClosePressed = onClosePressed)
}
}
@@ -84,9 +84,9 @@ fun CreateRoomRootView(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val context = LocalContext.current
SelectUsersView(
UserListView(
modifier = Modifier.fillMaxWidth(),
state = state.selectUsersState,
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
@@ -95,7 +95,7 @@ fun CreateRoomRootView(
},
)
if (!state.selectUsersState.isSearchActive) {
if (!state.userListState.isSearchActive) {
CreateRoomActionButtonsList(
onNewRoomClicked = onNewRoomClicked,
onInvitePeopleClicked = { state.eventSink(CreateRoomRootEvents.InvitePeople) },

View File

@@ -22,7 +22,8 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.selectusers.test.FakeSelectUserPresenterFactory
import io.element.android.features.userlist.test.FakeMatrixUserDataSource
import io.element.android.features.userlist.test.FakeUserListPresenterFactory
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
@@ -34,7 +35,7 @@ class AddPeoplePresenterTests {
@Before
fun setup() {
presenter = AddPeoplePresenter(FakeSelectUserPresenterFactory())
presenter = AddPeoplePresenter(FakeUserListPresenterFactory(), FakeMatrixUserDataSource())
}
@Test
@@ -47,3 +48,4 @@ class AddPeoplePresenterTests {
}
}
}

View File

@@ -22,10 +22,10 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.selectusers.api.SelectUsersPresenter
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs
import io.element.android.features.selectusers.api.aSelectUsersState
import io.element.android.features.selectusers.test.FakeSelectUserPresenter
import io.element.android.features.userlist.api.aUserListState
import io.element.android.features.userlist.test.FakeMatrixUserDataSource
import io.element.android.features.userlist.test.FakeUserListPresenter
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
@@ -41,18 +41,17 @@ import org.junit.Test
class CreateRoomRootPresenterTests {
private lateinit var userListDataSource: FakeMatrixUserDataSource
private lateinit var presenter: CreateRoomRootPresenter
private lateinit var fakeSelectUsersPresenter: FakeSelectUserPresenter
private lateinit var fakeUserListPresenter: FakeUserListPresenter
private lateinit var fakeMatrixClient: FakeMatrixClient
@Before
fun setup() {
val factory = object : SelectUsersPresenter.Factory {
override fun create(args: SelectUsersPresenterArgs) = fakeSelectUsersPresenter
}
fakeSelectUsersPresenter = FakeSelectUserPresenter()
fakeUserListPresenter = FakeUserListPresenter()
fakeMatrixClient = FakeMatrixClient()
presenter = CreateRoomRootPresenter(factory, fakeMatrixClient)
userListDataSource = FakeMatrixUserDataSource()
presenter = CreateRoomRootPresenter(FakeUserListPresenterFactory(fakeUserListPresenter), userListDataSource, fakeMatrixClient)
}
@Test
@@ -121,7 +120,7 @@ class CreateRoomRootPresenterTests {
val initialState = awaitItem()
val matrixUser = MatrixUser(UserId("@name:matrix.org"))
val createDmResult = Result.success(RoomId("!createDmResult"))
fakeSelectUsersPresenter.givenState(aSelectUsersState().copy(selectedUsers = persistentListOf(matrixUser)))
fakeUserListPresenter.givenState(aUserListState().copy(selectedUsers = persistentListOf(matrixUser)))
fakeMatrixClient.givenFindDmResult(null)
fakeMatrixClient.givenCreateDmError(A_THROWABLE)

View File

@@ -42,6 +42,7 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings)
implementation(projects.features.userlist.api)
implementation(projects.libraries.androidutils)
api(projects.features.roomdetails.api)
implementation(libs.coil.compose)
@@ -52,6 +53,8 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.userlist.impl)
testImplementation(projects.features.userlist.test)
ksp(libs.showkase.processor)
}

View File

@@ -24,9 +24,11 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.roomdetails.impl.members.RoomMemberListNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
@@ -49,11 +51,25 @@ class RoomDetailsFlowNode @AssistedInject constructor(
sealed interface NavTarget : Parcelable {
@Parcelize
object RoomDetails : NavTarget
@Parcelize
object RoomMemberList : NavTarget
}
interface Callback : Plugin {
fun openRoomMemberList()
}
val callback = object : Callback {
override fun openRoomMemberList() {
backstack.push(NavTarget.RoomMemberList)
}
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.RoomDetails -> createNode<RoomDetailsNode>(buildContext)
NavTarget.RoomDetails -> createNode<RoomDetailsNode>(buildContext, listOf(callback))
NavTarget.RoomMemberList -> createNode<RoomMemberListNode>(buildContext)
}
}

View File

@@ -23,6 +23,7 @@ import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
@@ -40,6 +41,12 @@ class RoomDetailsNode @AssistedInject constructor(
private val room: MatrixRoom,
) : Node(buildContext, plugins = plugins) {
private val callback = plugins<RoomDetailsFlowNode.Callback>().firstOrNull()
private fun openRoomMemberList() {
callback?.openRoomMemberList()
}
private fun onShareRoom(context: Context) {
val alias = room.alias ?: room.alternativeAliases.firstOrNull()
val permalinkResult = alias?.let { PermalinkBuilder.permalinkForRoomAlias(it) }
@@ -64,6 +71,7 @@ class RoomDetailsNode @AssistedInject constructor(
modifier = modifier,
goBack = { navigateUp() },
onShareRoom = { onShareRoom(context) },
openRoomMemberList = ::openRoomMemberList,
)
}
}

View File

@@ -17,8 +17,16 @@
package io.element.android.features.roomdetails.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
class RoomDetailsPresenter @Inject constructor(
@@ -29,13 +37,24 @@ class RoomDetailsPresenter @Inject constructor(
override fun present(): RoomDetailsState {
// fun handleEvents(event: RoomDetailsEvent) {}
var memberCount: Async<Int> by remember { mutableStateOf(Async.Loading()) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
memberCount = runCatching { room.memberCount() }
.fold(
onSuccess = { Async.Success(it) },
onFailure = { Async.Failure(it) }
)
}
}
return RoomDetailsState(
roomId = room.roomId.value,
roomName = room.name ?: room.displayName,
roomAlias = room.alias,
roomAvatarUrl = room.avatarUrl,
roomTopic = room.topic,
memberCount = room.members.size,
memberCount = memberCount,
isEncrypted = room.isEncrypted,
// eventSink = ::handleEvents
)

View File

@@ -16,13 +16,15 @@
package io.element.android.features.roomdetails.impl
import io.element.android.libraries.architecture.Async
data class RoomDetailsState(
val roomId: String,
val roomName: String,
val roomAlias: String?,
val roomAvatarUrl: String?,
val roomTopic: String?,
val memberCount: Int,
val memberCount: Async<Int>,
val isEncrypted: Boolean,
// val eventSink: (RoomDetailsEvent) -> Unit
)

View File

@@ -17,6 +17,7 @@
package io.element.android.features.roomdetails.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState> {
override val values: Sequence<RoomDetailsState>
@@ -25,6 +26,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
aRoomDetailsState().copy(roomTopic = null),
aRoomDetailsState().copy(isEncrypted = false),
aRoomDetailsState().copy(roomAlias = null),
aRoomDetailsState().copy(memberCount = Async.Failure(Throwable())),
// Add other state here
)
}
@@ -39,7 +41,7 @@ fun aRoomDetailsState() = RoomDetailsState(
"|| MAIL iki/Marketing " +
"|| MAI iki/Marketing " +
"|| MAI iki/Marketing...",
memberCount = 32,
memberCount = Async.Success(32),
isEncrypted = true,
// eventSink = {}
)

View File

@@ -42,6 +42,8 @@ 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.libraries.architecture.Async
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
@@ -62,6 +64,7 @@ fun RoomDetailsView(
state: RoomDetailsState,
goBack: () -> Unit,
onShareRoom: () -> Unit,
openRoomMemberList: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
@@ -87,7 +90,12 @@ fun RoomDetailsView(
TopicSection(roomTopic = state.roomTopic)
}
MembersSection(memberCount = state.memberCount)
val memberCount = (state.memberCount as? Async.Success<Int>)?.state
MembersSection(
memberCount = memberCount,
isLoading = state.memberCount.isLoading(),
openRoomMemberList = openRoomMemberList
)
if (state.isEncrypted) {
SecuritySection()
@@ -148,12 +156,19 @@ internal fun TopicSection(roomTopic: String, modifier: Modifier = Modifier) {
}
@Composable
internal fun MembersSection(memberCount: Int, modifier: Modifier = Modifier) {
internal fun MembersSection(
memberCount: Int?,
isLoading: Boolean,
modifier: Modifier = Modifier,
openRoomMemberList: () -> Unit
) {
PreferenceCategory(modifier = modifier) {
PreferenceText(
title = stringResource(R.string.screen_room_details_people_title),
icon = Icons.Outlined.Person,
currentValue = memberCount.toString(),
currentValue = memberCount?.toString(),
onClick = openRoomMemberList,
loadingCurrentValue = isLoading,
)
PreferenceText(
title = stringResource(R.string.screen_room_details_invite_people_title),
@@ -200,5 +215,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
state = state,
goBack = {},
onShareRoom = {},
openRoomMemberList = {},
)
}

View File

@@ -0,0 +1,35 @@
/*
* 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.roomdetails.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import io.element.android.features.roomdetails.impl.members.RoomMatrixUserDataSource
import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.libraries.di.RoomScope
import javax.inject.Named
@Module
@ContributesTo(RoomScope::class)
interface RoomMemberModule {
@Binds
@Named("RoomMembers")
fun bindRoomMemberUserListDataSource(dataSource: RoomMatrixUserDataSource): MatrixUserDataSource
}

View File

@@ -0,0 +1,58 @@
/*
* 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.roomdetails.impl.members
import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.libraries.core.bool.orFalse
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.RoomMember
import io.element.android.libraries.matrix.ui.model.MatrixUser
import javax.inject.Inject
class RoomMatrixUserDataSource @Inject constructor(
private val room: MatrixRoom
) : MatrixUserDataSource {
override suspend fun search(query: String): List<MatrixUser> {
return room.members().filter { member ->
if (query.isBlank()) {
true
} else {
member.userId.contains(query, ignoreCase = true) || member.displayName?.contains(query, ignoreCase = true).orFalse()
}
}.map(::mapMemberToMatrixUser)
}
override suspend fun getProfile(userId: UserId): MatrixUser? {
return null
}
private fun mapMemberToMatrixUser(member: RoomMember): MatrixUser {
return MatrixUser(
id = UserId(member.userId),
username = member.displayName,
avatarData = AvatarData(
id = member.userId,
name = member.displayName,
url = member.avatarUrl
)
)
}
}

View File

@@ -0,0 +1,52 @@
/*
* 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.roomdetails.impl.members
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.ui.model.MatrixUser
import timber.log.Timber
@ContributesNode(RoomScope::class)
class RoomMemberListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: RoomMemberListPresenter,
) : Node(buildContext, plugins = plugins) {
private fun onUserSelected(matrixUser: MatrixUser) {
Timber.d("TODO: implement user selection. User: $matrixUser")
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
RoomMemberListView(
state = state,
modifier = modifier,
onBackPressed = { navigateUp() },
onUserSelected = ::onUserSelected,
)
}
}

View File

@@ -0,0 +1,64 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.members
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import io.element.android.features.userlist.api.SelectionMode
import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Named
class RoomMemberListPresenter @Inject constructor(
private val userListPresenterFactory: UserListPresenter.Factory,
@Named("RoomMembers") private val matrixUserDataSource: MatrixUserDataSource,
) : Presenter<RoomMemberListState> {
private val userListPresenter by lazy {
userListPresenterFactory.create(
UserListPresenterArgs(selectionMode = SelectionMode.Single),
matrixUserDataSource,
)
}
@Composable
override fun present(): RoomMemberListState {
val userListState = userListPresenter.present()
val allUsers = remember { mutableStateOf<Async<ImmutableList<MatrixUser>>>(Async.Loading()) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
allUsers.value = Async.Success(matrixUserDataSource.search("").toImmutableList())
}
}
return RoomMemberListState(
allUsers = allUsers.value,
userListState = userListState
)
}
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class RoomMemberListState(
val allUsers: Async<ImmutableList<MatrixUser>>,
val userListState: UserListState,
// val eventSink: (AddPeopleEvents) -> Unit,
)

View File

@@ -0,0 +1,42 @@
/*
* 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.roomdetails.impl.members
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.userlist.api.aUserListState
import io.element.android.libraries.architecture.Async
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
internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMemberListState> {
override val values: Sequence<RoomMemberListState>
get() = sequenceOf(
aRoomMemberListState(allUsers = Async.Success(persistentListOf(aMatrixUser()))),
aRoomMemberListState(allUsers = Async.Loading())
)
}
internal fun aRoomMemberListState(
searchResults: ImmutableList<MatrixUser> = persistentListOf(),
allUsers: Async<ImmutableList<MatrixUser>> = Async.Uninitialized,
) =
RoomMemberListState(
userListState = aUserListState().copy(searchResults = searchResults),
allUsers = allUsers,
)

View File

@@ -0,0 +1,145 @@
/*
* 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.roomdetails.impl.members
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.roomdetails.impl.R
import io.element.android.features.userlist.api.SearchSingleUserResultItem
import io.element.android.features.userlist.api.UserListView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.isLoading
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.ui.model.MatrixUser
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomMemberListView(
state: RoomMemberListState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
onUserSelected: (MatrixUser) -> Unit = {},
) {
Scaffold(
topBar = {
if (!state.userListState.isSearchActive) {
RoomMemberListTopBar(onBackPressed = onBackPressed)
}
}
) { padding ->
Column(
modifier = modifier
.fillMaxWidth()
.padding(padding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
UserListView(
state = state.userListState,
onUserSelected = onUserSelected,
)
if (!state.userListState.isSearchActive) {
if (state.allUsers is Async.Success) {
LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
item {
val memberCount = state.allUsers.state.count()
Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
text = pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount),
style = ElementTextStyles.Regular.callout,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Start,
)
}
items(state.allUsers.state) { matrixUser ->
SearchSingleUserResultItem(
modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser,
onClick = { onUserSelected(matrixUser) }
)
}
}
} else if (state.allUsers.isLoading()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomMemberListTopBar(
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
) {
CenterAlignedTopAppBar(
modifier = modifier,
title = {
Text(
text = stringResource(R.string.screen_room_details_people_title),
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
)
},
navigationIcon = { BackButton(onClick = onBackPressed) },
)
}
@Preview
@Composable
fun RoomMemberListLightPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun RoomMemberListDarkPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: RoomMemberListState) {
RoomMemberListView(state)
}

View File

@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="screen_room_member_list_header_title">
<item quantity="one">"1 person"</item>
<item quantity="other">"%1$d people"</item>
</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>

View File

@@ -21,6 +21,7 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.test.A_ROOM_ID
@@ -42,8 +43,25 @@ class RoomDetailsPresenterTests {
Truth.assertThat(initialState.roomName).isEqualTo(room.name)
Truth.assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl)
Truth.assertThat(initialState.roomTopic).isEqualTo(room.topic)
Truth.assertThat(initialState.memberCount).isEqualTo(room.members.count())
Truth.assertThat(initialState.memberCount).isEqualTo(Async.Loading(null))
Truth.assertThat(initialState.isEncrypted).isEqualTo(room.isEncrypted)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - room member count is calculated asynchronously`() = runTest {
val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.memberCount).isEqualTo(Async.Loading(null))
val finalState = awaitItem()
Truth.assertThat(finalState.memberCount).isEqualTo(Async.Success(0))
}
}
@@ -56,6 +74,24 @@ class RoomDetailsPresenterTests {
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.roomName).isEqualTo(room.displayName)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - can handle error while fetching member count`() = runTest {
val room = aMatrixRoom(name = null).apply {
givenFetchMemberResult(Result.failure(Throwable()))
}
val presenter = RoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
Truth.assertThat(awaitItem().memberCount).isInstanceOf(Async.Failure::class.java)
cancelAndIgnoreRemainingEvents()
}
}
}

View File

@@ -0,0 +1,62 @@
/*
* 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.roomdetails.members
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.roomdetails.impl.members.RoomMemberListPresenter
import io.element.android.features.userlist.api.SelectionMode
import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.features.userlist.impl.DefaultUserListPresenter
import io.element.android.features.userlist.test.FakeMatrixUserDataSource
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import kotlinx.coroutines.test.runTest
import okhttp3.internal.toImmutableList
import org.junit.Test
class RoomMemberListPresenterTests {
@Test
fun `present - search is done automatically on start, but is async`() = runTest {
val searchResult = listOf(aMatrixUser())
val userListDataSource = FakeMatrixUserDataSource().apply {
givenSearchResult(searchResult)
}
val userListFactory = object : UserListPresenter.Factory {
override fun create(args: UserListPresenterArgs, dataSource: MatrixUserDataSource) = DefaultUserListPresenter(args, dataSource)
}
val presenter = RoomMemberListPresenter(userListFactory, userListDataSource)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
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.selectionMode).isEqualTo(SelectionMode.Single)
val loadedState = awaitItem()
Truth.assertThat((loadedState.allUsers as? Async.Success)?.state).isEqualTo(searchResult.toImmutableList())
}
}
}

View File

@@ -19,7 +19,7 @@ plugins {
}
android {
namespace = "io.element.android.features.selectusers.api"
namespace = "io.element.android.features.userlist.api"
}
dependencies {

View File

@@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.userlist.api
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.MatrixUser
interface MatrixUserDataSource {
suspend fun search(query: String): List<MatrixUser>
suspend fun getProfile(userId: UserId): MatrixUser?
}

View File

@@ -14,13 +14,13 @@
* limitations under the License.
*/
package io.element.android.features.selectusers.api
package io.element.android.features.userlist.api
import io.element.android.libraries.matrix.ui.model.MatrixUser
sealed interface SelectUsersEvents {
data class UpdateSearchQuery(val query: String) : SelectUsersEvents
data class AddToSelection(val matrixUser: MatrixUser) : SelectUsersEvents
data class RemoveFromSelection(val matrixUser: MatrixUser) : SelectUsersEvents
data class OnSearchActiveChanged(val active: Boolean) : SelectUsersEvents
sealed interface UserListEvents {
data class UpdateSearchQuery(val query: String) : UserListEvents
data class AddToSelection(val matrixUser: MatrixUser) : UserListEvents
data class RemoveFromSelection(val matrixUser: MatrixUser) : UserListEvents
data class OnSearchActiveChanged(val active: Boolean) : UserListEvents
}

View File

@@ -14,13 +14,13 @@
* limitations under the License.
*/
package io.element.android.features.selectusers.api
package io.element.android.features.userlist.api
import io.element.android.libraries.architecture.Presenter
interface SelectUsersPresenter : Presenter<SelectUsersState> {
interface UserListPresenter : Presenter<UserListState> {
interface Factory {
fun create(args: SelectUsersPresenterArgs): SelectUsersPresenter
fun create(args: UserListPresenterArgs, matrixUserDataSource: MatrixUserDataSource): UserListPresenter
}
}

View File

@@ -14,9 +14,9 @@
* limitations under the License.
*/
package io.element.android.features.selectusers.api
package io.element.android.features.userlist.api
data class SelectUsersPresenterArgs(
data class UserListPresenterArgs(
val selectionMode: SelectionMode,
)

View File

@@ -14,20 +14,20 @@
* limitations under the License.
*/
package io.element.android.features.selectusers.api
package io.element.android.features.userlist.api
import androidx.compose.foundation.lazy.LazyListState
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class SelectUsersState(
data class UserListState(
val searchQuery: String,
val searchResults: ImmutableList<MatrixUser>,
val selectedUsers: ImmutableList<MatrixUser>,
val selectedUsersListState: LazyListState,
val isSearchActive: Boolean,
val selectionMode: SelectionMode,
val eventSink: (SelectUsersEvents) -> Unit,
val eventSink: (UserListEvents) -> Unit,
) {
val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.selectusers.api
package io.element.android.features.userlist.api
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@@ -22,25 +22,25 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.persistentListOf
open class SelectUsersStateProvider : PreviewParameterProvider<SelectUsersState> {
override val values: Sequence<SelectUsersState>
open class UserListStateProvider : PreviewParameterProvider<UserListState> {
override val values: Sequence<UserListState>
get() = sequenceOf(
aSelectUsersState(),
aSelectUsersState().copy(
aUserListState(),
aUserListState().copy(
isSearchActive = false,
selectedUsers = aListOfSelectedUsers(),
selectionMode = SelectionMode.Multiple,
),
aSelectUsersState().copy(isSearchActive = true),
aSelectUsersState().copy(isSearchActive = true, searchQuery = "someone"),
aSelectUsersState().copy(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Multiple),
aSelectUsersState().copy(
aUserListState().copy(isSearchActive = true),
aUserListState().copy(isSearchActive = true, searchQuery = "someone"),
aUserListState().copy(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Multiple),
aUserListState().copy(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
selectedUsers = aListOfSelectedUsers(),
searchResults = aListOfResults(),
),
aSelectUsersState().copy(
aUserListState().copy(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
selectionMode = SelectionMode.Multiple,
@@ -50,7 +50,7 @@ open class SelectUsersStateProvider : PreviewParameterProvider<SelectUsersState>
)
}
fun aSelectUsersState() = SelectUsersState(
fun aUserListState() = UserListState(
isSearchActive = false,
searchQuery = "",
searchResults = persistentListOf(),

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.selectusers.api
package io.element.android.features.userlist.api
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -65,8 +65,8 @@ import kotlinx.collections.immutable.ImmutableList
import io.element.android.libraries.ui.strings.R as StringR
@Composable
fun SelectUsersView(
state: SelectUsersState,
fun UserListView(
state: UserListState,
modifier: Modifier = Modifier,
onUserSelected: (MatrixUser) -> Unit = {},
onUserDeselected: (MatrixUser) -> Unit = {},
@@ -82,14 +82,14 @@ fun SelectUsersView(
selectedUsersListState = state.selectedUsersListState,
active = state.isSearchActive,
isMultiSelectionEnabled = state.isMultiSelectionEnabled,
onActiveChanged = { state.eventSink(SelectUsersEvents.OnSearchActiveChanged(it)) },
onTextChanged = { state.eventSink(SelectUsersEvents.UpdateSearchQuery(it)) },
onActiveChanged = { state.eventSink(UserListEvents.OnSearchActiveChanged(it)) },
onTextChanged = { state.eventSink(UserListEvents.UpdateSearchQuery(it)) },
onUserSelected = {
state.eventSink(SelectUsersEvents.AddToSelection(it))
state.eventSink(UserListEvents.AddToSelection(it))
onUserSelected(it)
},
onUserDeselected = {
state.eventSink(SelectUsersEvents.RemoveFromSelection(it))
state.eventSink(UserListEvents.RemoveFromSelection(it))
onUserDeselected(it)
},
)
@@ -100,7 +100,7 @@ fun SelectUsersView(
modifier = Modifier.padding(16.dp),
selectedUsers = state.selectedUsers,
onUserRemoved = {
state.eventSink(SelectUsersEvents.RemoveFromSelection(it))
state.eventSink(UserListEvents.RemoveFromSelection(it))
onUserDeselected(it)
},
)
@@ -297,15 +297,15 @@ fun SelectedUser(
@Preview
@Composable
internal fun SelectUsersViewLightPreview(@PreviewParameter(SelectUsersStateProvider::class) state: SelectUsersState) =
internal fun UserListViewLightPreview(@PreviewParameter(UserListStateProvider::class) state: UserListState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
internal fun SelectUsersViewDarkPreview(@PreviewParameter(SelectUsersStateProvider::class) state: SelectUsersState) =
internal fun UserListViewDarkPreview(@PreviewParameter(UserListStateProvider::class) state: UserListState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: SelectUsersState) {
SelectUsersView(state = state)
private fun ContentToPreview(state: UserListState) {
UserListView(state = state)
}

View File

@@ -23,7 +23,7 @@ plugins {
}
android {
namespace = "io.element.android.features.selectusers.impl"
namespace = "io.element.android.features.userlist.impl"
}
anvil {
@@ -41,7 +41,7 @@ dependencies {
implementation(projects.libraries.elementresources)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
api(projects.features.selectusers.api)
api(projects.features.userlist.api)
ksp(libs.showkase.processor)
testImplementation(libs.test.junit)
@@ -52,6 +52,7 @@ dependencies {
testImplementation(libs.test.turbine)
testImplementation(libs.test.mockk)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.userlist.test)
androidTestImplementation(libs.test.junitext)
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.selectusers.impl
package io.element.android.features.userlist.impl
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -31,10 +31,11 @@ import com.squareup.anvil.annotations.ContributesBinding
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.selectusers.api.SelectUsersEvents
import io.element.android.features.selectusers.api.SelectUsersPresenter
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs
import io.element.android.features.selectusers.api.SelectUsersState
import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.features.userlist.api.UserListEvents
import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.features.userlist.api.UserListState
import io.element.android.features.userlist.api.UserListPresenter
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
@@ -45,18 +46,19 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class DefaultSelectUsersPresenter @AssistedInject constructor(
@Assisted val args: SelectUsersPresenterArgs,
) : SelectUsersPresenter {
class DefaultUserListPresenter @AssistedInject constructor(
@Assisted val args: UserListPresenterArgs,
@Assisted val matrixUserDataSource: MatrixUserDataSource,
) : UserListPresenter {
@AssistedFactory
@ContributesBinding(SessionScope::class)
interface DefaultSelectUsersFactory : SelectUsersPresenter.Factory {
override fun create(args: SelectUsersPresenterArgs): DefaultSelectUsersPresenter
interface DefaultUserListFactory : UserListPresenter.Factory {
override fun create(args: UserListPresenterArgs, matrixUserDataSource: MatrixUserDataSource): DefaultUserListPresenter
}
@Composable
override fun present(): SelectUsersState {
override fun present(): UserListState {
val localCoroutineScope = rememberCoroutineScope()
var isSearchActive by rememberSaveable { mutableStateOf(false) }
val selectedUsers: MutableState<ImmutableList<MatrixUser>> = remember {
@@ -68,17 +70,17 @@ class DefaultSelectUsersPresenter @AssistedInject constructor(
mutableStateOf(persistentListOf())
}
fun handleEvents(event: SelectUsersEvents) {
fun handleEvents(event: UserListEvents) {
when (event) {
is SelectUsersEvents.OnSearchActiveChanged -> isSearchActive = event.active
is SelectUsersEvents.UpdateSearchQuery -> searchQuery = event.query
is SelectUsersEvents.AddToSelection -> {
is UserListEvents.OnSearchActiveChanged -> isSearchActive = event.active
is UserListEvents.UpdateSearchQuery -> searchQuery = event.query
is UserListEvents.AddToSelection -> {
if (event.matrixUser !in selectedUsers.value) {
selectedUsers.value = selectedUsers.value.plus(event.matrixUser).toImmutableList()
}
localCoroutineScope.scrollToFirstSelectedUser(selectedUsersListState)
}
is SelectUsersEvents.RemoveFromSelection -> selectedUsers.value = selectedUsers.value.minus(event.matrixUser).toImmutableList()
is UserListEvents.RemoveFromSelection -> selectedUsers.value = selectedUsers.value.minus(event.matrixUser).toImmutableList()
}
}
@@ -95,7 +97,7 @@ class DefaultSelectUsersPresenter @AssistedInject constructor(
}
}
return SelectUsersState(
return UserListState(
searchQuery = searchQuery,
searchResults = searchResults.value,
selectedUsers = selectedUsers.value.reversed().toImmutableList(),
@@ -106,11 +108,11 @@ class DefaultSelectUsersPresenter @AssistedInject constructor(
)
}
private fun performSearch(query: String): ImmutableList<MatrixUser> {
private suspend fun performSearch(query: String): ImmutableList<MatrixUser> {
val isMatrixId = MatrixPatterns.isUserId(query)
val results = mutableListOf<MatrixUser>()// TODO trigger /search request
val results = matrixUserDataSource.search(query).toMutableList()
if (isMatrixId && results.none { it.id.value == query }) {
val getProfileResult: MatrixUser? = null // TODO trigger /profile request
val getProfileResult: MatrixUser? = matrixUserDataSource.getProfile(UserId(query))
val profile = getProfileResult ?: MatrixUser(UserId(query))
results.add(0, profile)
}

View File

@@ -14,16 +14,17 @@
* limitations under the License.
*/
package io.element.android.features.selectusers.impl
package io.element.android.features.userlist.impl
import androidx.compose.foundation.lazy.LazyListState
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.selectusers.api.SelectUsersEvents
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs
import io.element.android.features.selectusers.api.SelectionMode
import io.element.android.features.userlist.api.UserListEvents
import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.features.userlist.api.SelectionMode
import io.element.android.features.userlist.test.FakeMatrixUserDataSource
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.model.MatrixUser
@@ -34,11 +35,16 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultSelectUsersPresenterTests {
class DefaultUserListPresenterTests {
private val userListDataSource = FakeMatrixUserDataSource()
@Test
fun `present - initial state for single selection`() = runTest {
val presenter = DefaultSelectUsersPresenter(SelectUsersPresenterArgs(SelectionMode.Single))
val presenter = DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Single),
userListDataSource
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -53,7 +59,10 @@ class DefaultSelectUsersPresenterTests {
@Test
fun `present - initial state for multiple selection`() = runTest {
val presenter = DefaultSelectUsersPresenter(SelectUsersPresenterArgs(SelectionMode.Multiple))
val presenter = DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Multiple),
userListDataSource
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -68,26 +77,29 @@ class DefaultSelectUsersPresenterTests {
@Test
fun `present - update search query`() = runTest {
val presenter = DefaultSelectUsersPresenter(SelectUsersPresenterArgs(SelectionMode.Single))
val presenter = DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Single),
userListDataSource
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(SelectUsersEvents.OnSearchActiveChanged(true))
initialState.eventSink(UserListEvents.OnSearchActiveChanged(true))
assertThat(awaitItem().isSearchActive).isTrue()
val matrixIdQuery = "@name:matrix.org"
initialState.eventSink(SelectUsersEvents.UpdateSearchQuery(matrixIdQuery))
initialState.eventSink(UserListEvents.UpdateSearchQuery(matrixIdQuery))
assertThat(awaitItem().searchQuery).isEqualTo(matrixIdQuery)
assertThat(awaitItem().searchResults).containsExactly(MatrixUser(UserId(matrixIdQuery)))
val notMatrixIdQuery = "name"
initialState.eventSink(SelectUsersEvents.UpdateSearchQuery(notMatrixIdQuery))
initialState.eventSink(UserListEvents.UpdateSearchQuery(notMatrixIdQuery))
assertThat(awaitItem().searchQuery).isEqualTo(notMatrixIdQuery)
assertThat(awaitItem().searchResults).isEmpty()
initialState.eventSink(SelectUsersEvents.OnSearchActiveChanged(false))
initialState.eventSink(UserListEvents.OnSearchActiveChanged(false))
assertThat(awaitItem().isSearchActive).isFalse()
}
}
@@ -97,7 +109,10 @@ class DefaultSelectUsersPresenterTests {
mockkConstructor(LazyListState::class)
coJustRun { anyConstructed<LazyListState>().scrollToItem(index = any()) }
val presenter = DefaultSelectUsersPresenter(SelectUsersPresenterArgs(SelectionMode.Single))
val presenter = DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Single),
userListDataSource
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -108,23 +123,23 @@ class DefaultSelectUsersPresenterTests {
val userABis = aMatrixUser("userA", "A")
val userC = aMatrixUser("userC", "C")
initialState.eventSink(SelectUsersEvents.AddToSelection(userA))
initialState.eventSink(UserListEvents.AddToSelection(userA))
assertThat(awaitItem().selectedUsers).containsExactly(userA)
initialState.eventSink(SelectUsersEvents.AddToSelection(userB))
initialState.eventSink(UserListEvents.AddToSelection(userB))
// the last added user should be presented first
assertThat(awaitItem().selectedUsers).containsExactly(userB, userA)
initialState.eventSink(SelectUsersEvents.AddToSelection(userABis))
initialState.eventSink(SelectUsersEvents.AddToSelection(userC))
initialState.eventSink(UserListEvents.AddToSelection(userABis))
initialState.eventSink(UserListEvents.AddToSelection(userC))
// duplicated users should be ignored
assertThat(awaitItem().selectedUsers).containsExactly(userC, userB, userA)
initialState.eventSink(SelectUsersEvents.RemoveFromSelection(userB))
initialState.eventSink(UserListEvents.RemoveFromSelection(userB))
assertThat(awaitItem().selectedUsers).containsExactly(userC, userA)
initialState.eventSink(SelectUsersEvents.RemoveFromSelection(userA))
initialState.eventSink(UserListEvents.RemoveFromSelection(userA))
assertThat(awaitItem().selectedUsers).containsExactly(userC)
initialState.eventSink(SelectUsersEvents.RemoveFromSelection(userC))
initialState.eventSink(UserListEvents.RemoveFromSelection(userC))
assertThat(awaitItem().selectedUsers).isEmpty()
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2023 New Vector Ltd
* 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.
@@ -19,10 +19,13 @@ plugins {
}
android {
namespace = "io.element.android.features.selectusers.test"
namespace = "io.element.android.features.userlist.test"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.features.selectusers.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.matrix.api)
api(projects.features.userlist.api)
api(libs.coroutines.core)
}

View File

@@ -0,0 +1,39 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.userlist.test
import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.MatrixUser
class FakeMatrixUserDataSource : MatrixUserDataSource {
private var searchResult: List<MatrixUser> = emptyList()
private var profile: MatrixUser? = null
override suspend fun search(query: String): List<MatrixUser> = searchResult
override suspend fun getProfile(userId: UserId): MatrixUser? = profile
fun givenSearchResult(users: List<MatrixUser>) {
this.searchResult = users
}
fun givenUserProfile(matrixUser: MatrixUser?) {
this.profile = matrixUser
}
}

View File

@@ -14,23 +14,23 @@
* limitations under the License.
*/
package io.element.android.features.selectusers.test
package io.element.android.features.userlist.test
import androidx.compose.runtime.Composable
import io.element.android.features.selectusers.api.SelectUsersPresenter
import io.element.android.features.selectusers.api.SelectUsersState
import io.element.android.features.selectusers.api.aSelectUsersState
import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListState
import io.element.android.features.userlist.api.aUserListState
class FakeSelectUserPresenter : SelectUsersPresenter {
class FakeUserListPresenter : UserListPresenter {
private var state = aSelectUsersState()
private var state = aUserListState()
fun givenState(state: SelectUsersState) {
fun givenState(state: UserListState) {
this.state = state
}
@Composable
override fun present(): SelectUsersState {
override fun present(): UserListState {
return state
}
}

View File

@@ -14,12 +14,15 @@
* limitations under the License.
*/
package io.element.android.features.selectusers.test
package io.element.android.features.userlist.test
import io.element.android.features.selectusers.api.SelectUsersPresenter
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs
import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenterArgs
class FakeSelectUserPresenterFactory : SelectUsersPresenter.Factory {
class FakeUserListPresenterFactory(
private val fakeUserListPresenter: FakeUserListPresenter = FakeUserListPresenter()
) : UserListPresenter.Factory {
override fun create(args: SelectUsersPresenterArgs): SelectUsersPresenter = FakeSelectUserPresenter()
override fun create(args: UserListPresenterArgs, matrixUserDataSource: MatrixUserDataSource): UserListPresenter = fakeUserListPresenter
}

View File

@@ -57,3 +57,7 @@ suspend fun <T> (suspend () -> Result<T>).executeResult(state: MutableState<Asyn
}
)
}
fun <T> Async<T>.isLoading(): Boolean {
return this is Async.Loading<T>
}

View File

@@ -25,7 +25,9 @@ import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.progressSemantics
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material3.MaterialTheme
@@ -39,6 +41,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
@@ -47,6 +50,7 @@ fun PreferenceText(
modifier: Modifier = Modifier,
subtitle: String? = null,
currentValue: String? = null,
loadingCurrentValue: Boolean = false,
icon: ImageVector? = null,
tintColor: Color? = null,
onClick: () -> Unit = {},
@@ -56,11 +60,13 @@ fun PreferenceText(
modifier = modifier
.fillMaxWidth()
.defaultMinSize(minHeight = minHeight)
.padding(end = preferencePaddingHorizontal)
.clickable { onClick() },
.clickable { onClick() }
.padding(end = preferencePaddingHorizontal),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = preferencePaddingVertical)
modifier = Modifier
.fillMaxWidth()
.padding(vertical = preferencePaddingVertical)
) {
PreferenceIcon(icon = icon, tintColor = tintColor)
Column(modifier = Modifier
@@ -88,7 +94,11 @@ fun PreferenceText(
if (currentValue != null) {
Text(currentValue, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
Spacer(Modifier.width(16.dp))
} else if (loadingCurrentValue) {
CircularProgressIndicator(modifier = Modifier.progressSemantics().size(20.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(16.dp))
}
}
}
}

View File

@@ -31,9 +31,11 @@ interface MatrixRoom: Closeable {
val alternativeAliases: List<String>
val topic: String?
val avatarUrl: String?
val members: List<RoomMember>
val isEncrypted: Boolean
suspend fun members() : List<RoomMember>
suspend fun memberCount(): Int
fun syncUpdateFlow(): Flow<Long>
fun timeline(): MatrixTimeline

View File

@@ -17,6 +17,7 @@
package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.MatrixRoom
@@ -24,10 +25,12 @@ import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.SlidingSyncRoom
@@ -43,10 +46,32 @@ class RustMatrixRoom(
private val coroutineDispatchers: CoroutineDispatchers,
) : MatrixRoom {
private var loadMembersJob: Job? = null
private var cachedMembers: List<RoomMember> = emptyList()
override suspend fun members(): List<RoomMember> {
return cachedMembers.ifEmpty {
if (loadMembersJob == null) {
loadMembersJob = coroutineScope.launch(coroutineDispatchers.io) {
cachedMembers = tryOrNull {
innerRoom.members().map(RoomMemberMapper::map)
} ?: emptyList()
}
}
loadMembersJob?.join()
loadMembersJob = null
cachedMembers
}
}
override suspend fun memberCount(): Int {
return members().size
}
override fun syncUpdateFlow(): Flow<Long> {
return slidingSyncUpdateFlow
.filter {
it.rooms.contains(innerRoom.id())
it.rooms.contains(roomId.value)
}
.map {
System.currentTimeMillis()
@@ -95,9 +120,6 @@ class RustMatrixRoom(
return innerRoom.avatarUrl()
}
override val members: List<RoomMember>
get() = innerRoom.members().map(RoomMemberMapper::map)
override val isEncrypted: Boolean
get() = innerRoom.isEncrypted()

View File

@@ -87,14 +87,6 @@ class RustMatrixTimeline(
override fun initialize() {
Timber.v("Init timeline for room ${matrixRoom.roomId}")
coroutineScope.launch {
matrixRoom.fetchMembers()
.onFailure {
Timber.e(it, "Fail to fetch members for room ${matrixRoom.roomId}")
}.onSuccess {
Timber.v("Success fetching members for room ${matrixRoom.roomId}")
}
}
coroutineScope.launch {
val result = addListener(innerTimelineListener)
result

View File

@@ -34,13 +34,18 @@ class FakeMatrixRoom(
override val displayName: String = "",
override val topic: String? = null,
override val avatarUrl: String? = null,
override val members: List<RoomMember> = emptyList(),
override val isEncrypted: Boolean = false,
override val alias: String? = null,
override val alternativeAliases: List<String> = emptyList(),
private val members: List<RoomMember> = emptyList(),
private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(),
) : MatrixRoom {
private var fetchMemberResult: Result<Unit> = Result.success(Unit)
var areMembersFetched: Boolean = false
private set
override fun syncUpdateFlow(): Flow<Long> {
return emptyFlow()
}
@@ -50,7 +55,11 @@ class FakeMatrixRoom(
}
override suspend fun fetchMembers(): Result<Unit> {
return Result.success(Unit)
return fetchMemberResult.also { result ->
if (result.isSuccess) {
areMembersFetched = true
}
}
}
override suspend fun userDisplayName(userId: String): Result<String?> {
@@ -61,6 +70,18 @@ class FakeMatrixRoom(
TODO("Not yet implemented")
}
override suspend fun members(): List<RoomMember> {
return members
}
override suspend fun memberCount(): Int {
if (fetchMemberResult.isSuccess) {
return members.count()
} else {
throw fetchMemberResult.exceptionOrNull()!!
}
}
override suspend fun sendMessage(message: String): Result<Unit> {
delay(100)
return Result.success(Unit)
@@ -94,4 +115,8 @@ class FakeMatrixRoom(
}
override fun close() = Unit
fun givenFetchMemberResult(result: Result<Unit>) {
fetchMemberResult = result
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="action_confirm">"Confirmare"</string>
<string name="action_create_a_room">"Creați o cameră"</string>
<string name="action_done">"Gata"</string>
<string name="action_ok">"OK"</string>
<string name="action_report_content">"Raportează conținutul"</string>
<string name="action_start_chat">"Începe discuția"</string>
<string name="action_view_source">"Vezi sursa"</string>
</resources>

View File

@@ -123,11 +123,19 @@
</plurals>
<string name="preference_rageshake">"Rageshake to report bug"</string>
<string name="rageshake_dialog_content">"You seem to be shaking the phone in frustration. Would you like to open the bug report screen?"</string>
<string name="report_content_explanation">"Reporting this message will send its unique event ID to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images."</string>
<string name="report_content_explanation">"This message will be reported to your homeservers administrator. They will not be able to read any encrypted messages."</string>
<string name="report_content_hint">"Reason for reporting this content"</string>
<string name="room_timeline_beginning_of_room">"This is the beginning of %1$s."</string>
<string name="room_timeline_beginning_of_room_no_name">"This is the beginning of this conversation."</string>
<string name="room_timeline_read_marker_title">"New"</string>
<string name="screen_dm_details_block_alert_action">"Block"</string>
<string name="screen_dm_details_block_alert_description">"Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime."</string>
<string name="screen_dm_details_block_user">"Block user"</string>
<string name="screen_dm_details_unblock_alert_action">"Unblock"</string>
<string name="screen_dm_details_unblock_alert_description">"On unblocking the user, you will be able to see all messages by them again."</string>
<string name="screen_dm_details_unblock_user">"Unblock user"</string>
<string name="screen_report_content_block_user">"Block user"</string>
<string name="screen_report_content_block_user_hint">"Check if you want to hide all current and future messages from this user"</string>
<string name="screen_room_member_details_block_alert_action">"Block"</string>
<string name="screen_room_member_details_block_alert_description">"Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime."</string>
<string name="screen_room_member_details_block_user">"Block user"</string>

View File

@@ -57,5 +57,5 @@ dependencies {
implementation(projects.features.roomlist.impl)
implementation(projects.features.login.impl)
implementation(libs.coroutines.core)
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.2")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
}

View File

@@ -61,7 +61,9 @@
{
"name": ":features:roomdetails:impl",
"includeRegex": [
"screen_room_details_.*"
"screen_room_details_.*",
"screen_room_member_list_.*",
"screen_dm_details_.*"
]
}
]