[Room member list] Display room member list (#276)

* Implement room member list

* Move timeline initialization back to `TimelinePresenter`.

* Fix crash when the `innerRoom` inside a `RustMatrixRoom` is destroyed but `syncUpdateFlow` is still running.

* Address review comments
This commit is contained in:
Jorge Martin Espinosa 2023-04-04 18:07:57 +02:00 committed by GitHub
parent f0b95d30be
commit d7a6779343
62 changed files with 1159 additions and 157 deletions

View file

@ -69,6 +69,8 @@ class RoomFlowNode @AssistedInject constructor(
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
private val roomFlowPresenter = RoomFlowPresenter(inputs.room)
init { init {
lifecycle.subscribe( lifecycle.subscribe(
onCreate = { onCreate = {
@ -110,6 +112,7 @@ class RoomFlowNode @AssistedInject constructor(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
roomFlowPresenter.present()
Children( Children(
navModel = backstack, navModel = backstack,
modifier = modifier, 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 Details screen
Implement Room Member List screen

View file

@ -47,7 +47,7 @@ dependencies {
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources) implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.features.selectusers.api) implementation(projects.features.userlist.api)
api(projects.features.createroom.api) api(projects.features.createroom.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
@ -56,7 +56,8 @@ dependencies {
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.selectusers.impl) testImplementation(projects.features.userlist.impl)
testImplementation(projects.features.userlist.test)
androidTestImplementation(libs.test.junitext) 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 package io.element.android.features.createroom.impl.addpeople
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import io.element.android.features.selectusers.api.SelectUsersPresenter import io.element.android.features.userlist.api.SelectionMode
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.features.selectusers.api.SelectionMode import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Named
class AddPeoplePresenter @Inject constructor( class AddPeoplePresenter @Inject constructor(
private val selectUsersPresenterFactory: SelectUsersPresenter.Factory, private val userListPresenterFactory: UserListPresenter.Factory,
@Named("AllUsers") private val matrixUserDataSource: MatrixUserDataSource,
) : Presenter<AddPeopleState> { ) : Presenter<AddPeopleState> {
private val selectUsersPresenter by lazy { private val userListPresenter by lazy {
selectUsersPresenterFactory.create(SelectUsersPresenterArgs(SelectionMode.Multiple)) userListPresenterFactory.create(
UserListPresenterArgs(selectionMode = SelectionMode.Multiple),
matrixUserDataSource,
)
} }
@Composable @Composable
override fun present(): AddPeopleState { override fun present(): AddPeopleState {
val selectUsersState = selectUsersPresenter.present() val userListState = userListPresenter.present()
fun handleEvents(event: AddPeopleEvents) { fun handleEvents(event: AddPeopleEvents) {
// do nothing for now // do nothing for now
} }
return AddPeopleState( return AddPeopleState(
selectUsersState = selectUsersState, userListState = userListState,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
} }

View file

@ -16,9 +16,9 @@
package io.element.android.features.createroom.impl.addpeople 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( data class AddPeopleState(
val selectUsersState: SelectUsersState, val userListState: UserListState,
val eventSink: (AddPeopleEvents) -> Unit, val eventSink: (AddPeopleEvents) -> Unit,
) )

View file

@ -17,22 +17,22 @@
package io.element.android.features.createroom.impl.addpeople package io.element.android.features.createroom.impl.addpeople
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.selectusers.api.SelectionMode import io.element.android.features.userlist.api.SelectionMode
import io.element.android.features.selectusers.api.aListOfSelectedUsers import io.element.android.features.userlist.api.aListOfSelectedUsers
import io.element.android.features.selectusers.api.aSelectUsersState import io.element.android.features.userlist.api.aUserListState
open class AddPeopleStateProvider : PreviewParameterProvider<AddPeopleState> { open class AddPeopleStateProvider : PreviewParameterProvider<AddPeopleState> {
override val values: Sequence<AddPeopleState> override val values: Sequence<AddPeopleState>
get() = sequenceOf( get() = sequenceOf(
aAddPeopleState(), aAddPeopleState(),
aAddPeopleState().copy( aAddPeopleState().copy(
selectUsersState = aSelectUsersState().copy( userListState = aUserListState().copy(
selectedUsers = aListOfSelectedUsers(), selectedUsers = aListOfSelectedUsers(),
selectionMode = SelectionMode.Multiple, selectionMode = SelectionMode.Multiple,
) )
), ),
aAddPeopleState().copy( aAddPeopleState().copy(
selectUsersState = aSelectUsersState().copy( userListState = aUserListState().copy(
selectedUsers = aListOfSelectedUsers(), selectedUsers = aListOfSelectedUsers(),
isSearchActive = true, isSearchActive = true,
selectionMode = SelectionMode.Multiple, selectionMode = SelectionMode.Multiple,
@ -42,6 +42,6 @@ open class AddPeopleStateProvider : PreviewParameterProvider<AddPeopleState> {
} }
fun aAddPeopleState() = AddPeopleState( fun aAddPeopleState() = AddPeopleState(
selectUsersState = aSelectUsersState(), userListState = aUserListState(),
eventSink = {} 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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import io.element.android.features.userlist.api.UserListView
import io.element.android.features.createroom.impl.R 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.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@ -52,9 +52,9 @@ fun AddPeopleView(
Scaffold( Scaffold(
topBar = { topBar = {
if (!state.selectUsersState.isSearchActive) { if (!state.userListState.isSearchActive) {
AddPeopleViewTopBar( AddPeopleViewTopBar(
hasSelectedUsers = state.selectUsersState.selectedUsers.isNotEmpty(), hasSelectedUsers = state.userListState.selectedUsers.isNotEmpty(),
onBackPressed = onBackPressed, onBackPressed = onBackPressed,
onNextPressed = onNextPressed, onNextPressed = onNextPressed,
) )
@ -66,9 +66,9 @@ fun AddPeopleView(
.fillMaxSize() .fillMaxSize()
.padding(padding), .padding(padding),
) { ) {
SelectUsersView( UserListView(
modifier = Modifier.fillMaxWidth(), 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

@ -17,25 +17,31 @@
package io.element.android.features.createroom.impl.root package io.element.android.features.createroom.impl.root
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import io.element.android.features.selectusers.api.SelectUsersPresenter import io.element.android.features.userlist.api.SelectionMode
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.features.selectusers.api.SelectionMode import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.ui.model.MatrixUser import io.element.android.libraries.matrix.ui.model.MatrixUser
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Named
class CreateRoomRootPresenter @Inject constructor( class CreateRoomRootPresenter @Inject constructor(
private val presenterFactory: SelectUsersPresenter.Factory, private val presenterFactory: UserListPresenter.Factory,
@Named("AllUsers") private val matrixUserDataSource: MatrixUserDataSource,
) : Presenter<CreateRoomRootState> { ) : Presenter<CreateRoomRootState> {
private val presenter by lazy { private val presenter by lazy {
presenterFactory.create(SelectUsersPresenterArgs(SelectionMode.Single)) presenterFactory.create(
UserListPresenterArgs(selectionMode = SelectionMode.Single),
matrixUserDataSource,
)
} }
@Composable @Composable
override fun present(): CreateRoomRootState { override fun present(): CreateRoomRootState {
val selectUsersState = presenter.present() val userListState = presenter.present()
fun handleEvents(event: CreateRoomRootEvents) { fun handleEvents(event: CreateRoomRootEvents) {
when (event) { when (event) {
@ -45,7 +51,7 @@ class CreateRoomRootPresenter @Inject constructor(
} }
return CreateRoomRootState( return CreateRoomRootState(
selectUsersState = selectUsersState, userListState = userListState,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }

View file

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

View file

@ -17,7 +17,7 @@
package io.element.android.features.createroom.impl.root package io.element.android.features.createroom.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.selectusers.api.aSelectUsersState import io.element.android.features.userlist.api.aUserListState
open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRootState> { open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRootState> {
override val values: Sequence<CreateRoomRootState> override val values: Sequence<CreateRoomRootState>
@ -28,5 +28,5 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
fun aCreateRoomRootState() = CreateRoomRootState( fun aCreateRoomRootState() = CreateRoomRootState(
eventSink = {}, eventSink = {},
selectUsersState = aSelectUsersState(), userListState = aUserListState(),
) )

View file

@ -37,8 +37,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import io.element.android.features.userlist.api.UserListView
import io.element.android.features.createroom.impl.R import io.element.android.features.createroom.impl.R
import io.element.android.features.selectusers.api.SelectUsersView
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
@ -60,7 +60,7 @@ fun CreateRoomRootView(
Scaffold( Scaffold(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
topBar = { topBar = {
if (!state.selectUsersState.isSearchActive) { if (!state.userListState.isSearchActive) {
CreateRoomRootViewTopBar(onClosePressed = onClosePressed) CreateRoomRootViewTopBar(onClosePressed = onClosePressed)
} }
} }
@ -69,13 +69,13 @@ fun CreateRoomRootView(
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
SelectUsersView( UserListView(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
state = state.selectUsersState, state = state.userListState,
onUserSelected = { state.eventSink.invoke(CreateRoomRootEvents.StartDM(it)) }, onUserSelected = { state.eventSink.invoke(CreateRoomRootEvents.StartDM(it)) },
) )
if (!state.selectUsersState.isSearchActive) { if (!state.userListState.isSearchActive) {
CreateRoomActionButtonsList( CreateRoomActionButtonsList(
onNewRoomClicked = onNewRoomClicked, onNewRoomClicked = onNewRoomClicked,
onInvitePeopleClicked = { state.eventSink(CreateRoomRootEvents.InvitePeople) }, onInvitePeopleClicked = { state.eventSink(CreateRoomRootEvents.InvitePeople) },

View file

@ -22,8 +22,10 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.features.selectusers.impl.DefaultSelectUsersPresenter 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 kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
@ -35,10 +37,11 @@ class AddPeoplePresenterTests {
@Before @Before
fun setup() { fun setup() {
val selectUsersFactory = object : DefaultSelectUsersPresenter.DefaultSelectUsersFactory { val userListFactory = object : DefaultUserListPresenter.DefaultUserListFactory {
override fun create(args: SelectUsersPresenterArgs) = DefaultSelectUsersPresenter(args) override fun create(args: UserListPresenterArgs, dataSource: MatrixUserDataSource) = DefaultUserListPresenter(args, dataSource)
} }
presenter = AddPeoplePresenter(selectUsersFactory) val dataSource = FakeMatrixUserDataSource()
presenter = AddPeoplePresenter(userListFactory, dataSource)
} }
@Test @Test
@ -51,3 +54,4 @@ class AddPeoplePresenterTests {
} }
} }
} }

View file

@ -22,8 +22,10 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.features.selectusers.impl.DefaultSelectUsersPresenter 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.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.MatrixUser import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -33,14 +35,16 @@ import org.junit.Test
class CreateRoomRootPresenterTests { class CreateRoomRootPresenterTests {
private lateinit var userListDataSource: FakeMatrixUserDataSource
private lateinit var presenter: CreateRoomRootPresenter private lateinit var presenter: CreateRoomRootPresenter
@Before @Before
fun setup() { fun setup() {
val selectUsersPresenter = object : DefaultSelectUsersPresenter.DefaultSelectUsersFactory { val userListPresenter = object : DefaultUserListPresenter.DefaultUserListFactory {
override fun create(args: SelectUsersPresenterArgs) = DefaultSelectUsersPresenter(args) override fun create(args: UserListPresenterArgs, dataSource: MatrixUserDataSource) = DefaultUserListPresenter(args, dataSource)
} }
presenter = CreateRoomRootPresenter(selectUsersPresenter) userListDataSource = FakeMatrixUserDataSource()
presenter = CreateRoomRootPresenter(userListPresenter, userListDataSource)
} }
@Test @Test

View file

@ -42,6 +42,7 @@ dependencies {
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources) implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.features.userlist.api)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
api(projects.features.roomdetails.api) api(projects.features.roomdetails.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)
@ -52,6 +53,8 @@ dependencies {
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.userlist.impl)
testImplementation(projects.features.userlist.test)
ksp(libs.showkase.processor) 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.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode 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.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
@ -49,11 +51,25 @@ class RoomDetailsFlowNode @AssistedInject constructor(
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
object RoomDetails : NavTarget 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 { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { 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.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
@ -40,6 +41,12 @@ class RoomDetailsNode @AssistedInject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private val callback = plugins<RoomDetailsFlowNode.Callback>().firstOrNull()
private fun openRoomMemberList() {
callback?.openRoomMemberList()
}
private fun onShareRoom(context: Context) { private fun onShareRoom(context: Context) {
val alias = room.alias ?: room.alternativeAliases.firstOrNull() val alias = room.alias ?: room.alternativeAliases.firstOrNull()
val permalinkResult = alias?.let { PermalinkBuilder.permalinkForRoomAlias(it) } val permalinkResult = alias?.let { PermalinkBuilder.permalinkForRoomAlias(it) }
@ -64,6 +71,7 @@ class RoomDetailsNode @AssistedInject constructor(
modifier = modifier, modifier = modifier,
goBack = { navigateUp() }, goBack = { navigateUp() },
onShareRoom = { onShareRoom(context) }, onShareRoom = { onShareRoom(context) },
openRoomMemberList = ::openRoomMemberList,
) )
} }
} }

View file

@ -17,8 +17,16 @@
package io.element.android.features.roomdetails.impl package io.element.android.features.roomdetails.impl
import androidx.compose.runtime.Composable 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.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
class RoomDetailsPresenter @Inject constructor( class RoomDetailsPresenter @Inject constructor(
@ -29,13 +37,24 @@ class RoomDetailsPresenter @Inject constructor(
override fun present(): RoomDetailsState { override fun present(): RoomDetailsState {
// fun handleEvents(event: RoomDetailsEvent) {} // 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( return RoomDetailsState(
roomId = room.roomId.value, roomId = room.roomId.value,
roomName = room.name ?: room.displayName, roomName = room.name ?: room.displayName,
roomAlias = room.alias, roomAlias = room.alias,
roomAvatarUrl = room.avatarUrl, roomAvatarUrl = room.avatarUrl,
roomTopic = room.topic, roomTopic = room.topic,
memberCount = room.members.size, memberCount = memberCount,
isEncrypted = room.isEncrypted, isEncrypted = room.isEncrypted,
// eventSink = ::handleEvents // eventSink = ::handleEvents
) )

View file

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

View file

@ -17,6 +17,7 @@
package io.element.android.features.roomdetails.impl package io.element.android.features.roomdetails.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState> { open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState> {
override val values: Sequence<RoomDetailsState> override val values: Sequence<RoomDetailsState>
@ -25,6 +26,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
aRoomDetailsState().copy(roomTopic = null), aRoomDetailsState().copy(roomTopic = null),
aRoomDetailsState().copy(isEncrypted = false), aRoomDetailsState().copy(isEncrypted = false),
aRoomDetailsState().copy(roomAlias = null), aRoomDetailsState().copy(roomAlias = null),
aRoomDetailsState().copy(memberCount = Async.Failure(Throwable())),
// Add other state here // Add other state here
) )
} }
@ -39,7 +41,7 @@ fun aRoomDetailsState() = RoomDetailsState(
"|| MAIL iki/Marketing " + "|| MAIL iki/Marketing " +
"|| MAI iki/Marketing " + "|| MAI iki/Marketing " +
"|| MAI iki/Marketing...", "|| MAI iki/Marketing...",
memberCount = 32, memberCount = Async.Success(32),
isEncrypted = true, isEncrypted = true,
// eventSink = {} // 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.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.isLoading
import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
@ -62,6 +64,7 @@ fun RoomDetailsView(
state: RoomDetailsState, state: RoomDetailsState,
goBack: () -> Unit, goBack: () -> Unit,
onShareRoom: () -> Unit, onShareRoom: () -> Unit,
openRoomMemberList: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Scaffold( Scaffold(
@ -87,7 +90,12 @@ fun RoomDetailsView(
TopicSection(roomTopic = state.roomTopic) 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) { if (state.isEncrypted) {
SecuritySection() SecuritySection()
@ -148,12 +156,19 @@ internal fun TopicSection(roomTopic: String, modifier: Modifier = Modifier) {
} }
@Composable @Composable
internal fun MembersSection(memberCount: Int, modifier: Modifier = Modifier) { internal fun MembersSection(
memberCount: Int?,
isLoading: Boolean,
modifier: Modifier = Modifier,
openRoomMemberList: () -> Unit
) {
PreferenceCategory(modifier = modifier) { PreferenceCategory(modifier = modifier) {
PreferenceText( PreferenceText(
title = stringResource(R.string.screen_room_details_people_title), title = stringResource(R.string.screen_room_details_people_title),
icon = Icons.Outlined.Person, icon = Icons.Outlined.Person,
currentValue = memberCount.toString(), currentValue = memberCount?.toString(),
onClick = openRoomMemberList,
loadingCurrentValue = isLoading,
) )
PreferenceText( PreferenceText(
title = stringResource(R.string.screen_room_details_invite_people_title), title = stringResource(R.string.screen_room_details_invite_people_title),
@ -200,5 +215,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
state = state, state = state,
goBack = {}, goBack = {},
onShareRoom = {}, 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"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<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_subtitle">"Messages are secured with locks. Only you and the recipients have the unique keys to unlock them."</string>
<string name="screen_room_details_encryption_enabled_title">"Message encryption enabled"</string> <string name="screen_room_details_encryption_enabled_title">"Message encryption enabled"</string>
<string name="screen_room_details_invite_people_title">"Invite people"</string> <string name="screen_room_details_invite_people_title">"Invite people"</string>

View file

@ -21,6 +21,7 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter 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.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.test.A_ROOM_ID 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.roomName).isEqualTo(room.name)
Truth.assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl) Truth.assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl)
Truth.assertThat(initialState.roomTopic).isEqualTo(room.topic) 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) 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 { }.test {
val initialState = awaitItem() val initialState = awaitItem()
Truth.assertThat(initialState.roomName).isEqualTo(room.displayName) 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 { android {
namespace = "io.element.android.features.selectusers.api" namespace = "io.element.android.features.userlist.api"
} }
dependencies { 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. * 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 import io.element.android.libraries.matrix.ui.model.MatrixUser
sealed interface SelectUsersEvents { sealed interface UserListEvents {
data class UpdateSearchQuery(val query: String) : SelectUsersEvents data class UpdateSearchQuery(val query: String) : UserListEvents
data class AddToSelection(val matrixUser: MatrixUser) : SelectUsersEvents data class AddToSelection(val matrixUser: MatrixUser) : UserListEvents
data class RemoveFromSelection(val matrixUser: MatrixUser) : SelectUsersEvents data class RemoveFromSelection(val matrixUser: MatrixUser) : UserListEvents
data class OnSearchActiveChanged(val active: Boolean) : SelectUsersEvents data class OnSearchActiveChanged(val active: Boolean) : UserListEvents
} }

View file

@ -14,13 +14,13 @@
* limitations under the License. * 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 import io.element.android.libraries.architecture.Presenter
interface SelectUsersPresenter : Presenter<SelectUsersState> { interface UserListPresenter : Presenter<UserListState> {
interface Factory { interface Factory {
fun create(args: SelectUsersPresenterArgs): SelectUsersPresenter fun create(args: UserListPresenterArgs, matrixUserDataSource: MatrixUserDataSource): UserListPresenter
} }
} }

View file

@ -14,9 +14,9 @@
* limitations under the License. * 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, val selectionMode: SelectionMode,
) )

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,32 @@
/*
* 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.
*/
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.userlist.test"
}
dependencies {
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

@ -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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.progressSemantics
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material3.MaterialTheme 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.components.preferences.components.PreferenceIcon
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
@Composable @Composable
@ -47,6 +50,7 @@ fun PreferenceText(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
subtitle: String? = null, subtitle: String? = null,
currentValue: String? = null, currentValue: String? = null,
loadingCurrentValue: Boolean = false,
icon: ImageVector? = null, icon: ImageVector? = null,
tintColor: Color? = null, tintColor: Color? = null,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
@ -56,11 +60,13 @@ fun PreferenceText(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.defaultMinSize(minHeight = minHeight) .defaultMinSize(minHeight = minHeight)
.padding(end = preferencePaddingHorizontal) .clickable { onClick() }
.clickable { onClick() }, .padding(end = preferencePaddingHorizontal),
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(vertical = preferencePaddingVertical) modifier = Modifier
.fillMaxWidth()
.padding(vertical = preferencePaddingVertical)
) { ) {
PreferenceIcon(icon = icon, tintColor = tintColor) PreferenceIcon(icon = icon, tintColor = tintColor)
Column(modifier = Modifier Column(modifier = Modifier
@ -88,7 +94,11 @@ fun PreferenceText(
if (currentValue != null) { if (currentValue != null) {
Text(currentValue, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary) Text(currentValue, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
Spacer(Modifier.width(16.dp)) 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 alternativeAliases: List<String>
val topic: String? val topic: String?
val avatarUrl: String? val avatarUrl: String?
val members: List<RoomMember>
val isEncrypted: Boolean val isEncrypted: Boolean
suspend fun members() : List<RoomMember>
suspend fun memberCount(): Int
fun syncUpdateFlow(): Flow<Long> fun syncUpdateFlow(): Flow<Long>
fun timeline(): MatrixTimeline fun timeline(): MatrixTimeline

View file

@ -17,6 +17,7 @@
package io.element.android.libraries.matrix.impl.room package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.core.coroutine.CoroutineDispatchers 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.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.MatrixRoom 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.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.SlidingSyncRoom import org.matrix.rustcomponents.sdk.SlidingSyncRoom
@ -43,10 +46,32 @@ class RustMatrixRoom(
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
) : MatrixRoom { ) : 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> { override fun syncUpdateFlow(): Flow<Long> {
return slidingSyncUpdateFlow return slidingSyncUpdateFlow
.filter { .filter {
it.rooms.contains(innerRoom.id()) it.rooms.contains(roomId.value)
} }
.map { .map {
System.currentTimeMillis() System.currentTimeMillis()
@ -95,9 +120,6 @@ class RustMatrixRoom(
return innerRoom.avatarUrl() return innerRoom.avatarUrl()
} }
override val members: List<RoomMember>
get() = innerRoom.members().map(RoomMemberMapper::map)
override val isEncrypted: Boolean override val isEncrypted: Boolean
get() = innerRoom.isEncrypted() get() = innerRoom.isEncrypted()

View file

@ -87,14 +87,6 @@ class RustMatrixTimeline(
override fun initialize() { override fun initialize() {
Timber.v("Init timeline for room ${matrixRoom.roomId}") 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 { coroutineScope.launch {
val result = addListener(innerTimelineListener) val result = addListener(innerTimelineListener)
result result

View file

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

@ -48,6 +48,7 @@
<string name="common_about">"About"</string> <string name="common_about">"About"</string>
<string name="common_audio">"Audio"</string> <string name="common_audio">"Audio"</string>
<string name="common_bubbles">"Bubbles"</string> <string name="common_bubbles">"Bubbles"</string>
<string name="common_creating_room">"Creating room…"</string>
<string name="common_decryption_error">"Decryption error"</string> <string name="common_decryption_error">"Decryption error"</string>
<string name="common_developer_options">"Developer options"</string> <string name="common_developer_options">"Developer options"</string>
<string name="common_edited_suffix">"(edited)"</string> <string name="common_edited_suffix">"(edited)"</string>
@ -122,11 +123,19 @@
</plurals> </plurals>
<string name="preference_rageshake">"Rageshake to report bug"</string> <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="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="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">"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_beginning_of_room_no_name">"This is the beginning of this conversation."</string>
<string name="room_timeline_read_marker_title">"New"</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_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_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> <string name="screen_room_member_details_block_user">"Block user"</string>

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6893108aabbf06f43af90f17b6b1a8a521312559f407647ce08db06ecb9a8f84
size 22033

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7e1f7feb544a84e6e66f995683038abb9e7465583fade4e77c254a4264d03b9f
size 11985

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3e7b5bd916d4d3067b5013400b4ac864fab560e96fcb75ec895684021a00b8ba
size 21808

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:309c0c0a650435cfbc0825025f6f852bdb51899ff9d37cf4cf9db00d56fb1176
size 11933

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9fe9b98fbdd3fd9c7789463f14345f6f65f9a18eedf2229d14c62a3bee6711e3
size 70058

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:48f138bc8a63ac25152491c974c68d6e024ded04b973f174fb5b9586523217ca
size 64593

View file

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