[Room Details] Block & unblock user (#340)

This commit is contained in:
Jorge Martin Espinosa 2023-04-26 16:14:44 +02:00 committed by GitHub
parent 0d327cd543
commit aef2b38983
35 changed files with 477 additions and 174 deletions

View file

@ -18,6 +18,7 @@ package io.element.android.appnav
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.composable.Children import com.bumble.appyx.core.composable.Children
@ -134,8 +135,18 @@ class RoomFlowNode @AssistedInject constructor(
object RoomDetails : NavTarget object RoomDetails : NavTarget
} }
private val timeline = inputs.room.timeline()
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
DisposableEffect(Unit) {
timeline.initialize()
onDispose {
timeline.dispose()
}
}
Children( Children(
navModel = backstack, navModel = backstack,
modifier = modifier, modifier = modifier,

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

@ -0,0 +1 @@
Block & unblock users from room details screen.

View file

@ -80,13 +80,6 @@ class TimelinePresenter @Inject constructor(
.launchIn(this) .launchIn(this)
} }
DisposableEffect(Unit) {
timeline.initialize()
onDispose {
timeline.dispose()
}
}
return TimelineState( return TimelineState(
highlightedEventId = highlightedEventId.value, highlightedEventId = highlightedEventId.value,
paginationState = paginationState.value, paginationState = paginationState.value,

View file

@ -49,23 +49,6 @@ class TimelinePresenterTest {
} }
} }
@Test
fun `present - makes sure timeline is initialized and disposed`() = runTest {
val fakeTimeline = FakeMatrixTimeline()
val presenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
room = FakeMatrixRoom(matrixTimeline = fakeTimeline),
)
assertThat(fakeTimeline.isInitialized).isFalse()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(2)
assertThat(fakeTimeline.isInitialized).isTrue()
}
assertThat(fakeTimeline.isInitialized).isFalse()
}
@Test @Test
fun `present - load more`() = runTest { fun `present - load more`() = runTest {
val presenter = TimelinePresenter( val presenter = TimelinePresenter(

View file

@ -0,0 +1,89 @@
/*
* 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.blockuser
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Block
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsEvents
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.theme.LocalColors
@Composable
internal fun BlockUserSection(state: RoomMemberDetailsState, modifier: Modifier = Modifier) {
PreferenceCategory(showDivider = false, modifier = modifier) {
if (state.isBlocked) {
PreferenceText(
title = stringResource(R.string.screen_dm_details_unblock_user),
icon = Icons.Outlined.Block,
onClick = { state.eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = true)) },
)
} else {
PreferenceText(
title = stringResource(R.string.screen_dm_details_block_user),
icon = Icons.Outlined.Block,
tintColor = LocalColors.current.textActionCritical,
onClick = { state.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = true)) },
)
}
}
}
@Composable
internal fun BlockUserDialogs(state: RoomMemberDetailsState) {
when (state.displayConfirmationDialog) {
null -> Unit
RoomMemberDetailsState.ConfirmationDialog.Block -> {
BlockConfirmationDialog(
onBlockAction = { state.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = false)) },
onDismiss = { state.eventSink(RoomMemberDetailsEvents.ClearConfirmationDialog) }
)
}
RoomMemberDetailsState.ConfirmationDialog.Unblock -> {
UnblockConfirmationDialog(
onUnblockAction = { state.eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = false)) },
onDismiss = { state.eventSink(RoomMemberDetailsEvents.ClearConfirmationDialog) }
)
}
}
}
@Composable
internal fun BlockConfirmationDialog(onBlockAction: () -> Unit, onDismiss: () -> Unit) {
ConfirmationDialog(
content = stringResource(R.string.screen_dm_details_block_alert_description),
submitText = stringResource(R.string.screen_dm_details_block_alert_action),
onSubmitClicked = onBlockAction,
onDismiss = onDismiss
)
}
@Composable
internal fun UnblockConfirmationDialog(onUnblockAction: () -> Unit, onDismiss: () -> Unit) {
ConfirmationDialog(
content = stringResource(R.string.screen_dm_details_unblock_alert_description),
submitText = stringResource(R.string.screen_dm_details_unblock_alert_action),
onSubmitClicked = onUnblockAction,
onDismiss = onDismiss
)
}

View file

@ -63,7 +63,10 @@ class RoomDetailsNode @AssistedInject constructor(
activityResultLauncher = null, activityResultLauncher = null,
chooserTitle = context.getString(R.string.screen_room_details_share_room_title), chooserTitle = context.getString(R.string.screen_room_details_share_room_title),
text = permalink, text = permalink,
noActivityFoundMessage = context.getString(AndroidUtilsR.string.error_no_compatible_app_found)
) )
}.onFailure {
Timber.e(it)
} }
} }
@ -86,12 +89,21 @@ class RoomDetailsNode @AssistedInject constructor(
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val context = LocalContext.current val context = LocalContext.current
val state = presenter.present() val state = presenter.present()
fun onShareRoom() {
this.onShareRoom(context)
}
fun onShareMember(roomMember: RoomMember) {
this.onShareMember(context, roomMember)
}
RoomDetailsView( RoomDetailsView(
state = state, state = state,
modifier = modifier, modifier = modifier,
goBack = { navigateUp() }, goBack = this::navigateUp,
onShareRoom = { onShareRoom(context) }, onShareRoom = ::onShareRoom,
onShareMember = { onShareMember(context, it) }, onShareMember = ::onShareMember,
openRoomMemberList = ::openRoomMemberList, openRoomMemberList = ::openRoomMemberList,
) )
} }

View file

@ -17,12 +17,14 @@
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.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Async 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.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
@ -30,16 +32,32 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.runBlocking
import javax.inject.Inject import javax.inject.Inject
class RoomDetailsPresenter @Inject constructor( class RoomDetailsPresenter @Inject constructor(
private val sessionId: SessionId, private val matrixClient: MatrixClient,
private val room: MatrixRoom, private val room: MatrixRoom,
private val roomMembershipObserver: RoomMembershipObserver, private val roomMembershipObserver: RoomMembershipObserver,
) : Presenter<RoomDetailsState> { ) : Presenter<RoomDetailsState> {
private val roomMemberDetailsPresenter by lazy {
val dmMember = runBlocking {
room.getDmMember().firstOrNull()
}
if (dmMember != null) {
RoomMemberDetailsPresenter(matrixClient.sessionId, room, dmMember)
} else {
null
}
}
@Composable @Composable
override fun present(): RoomDetailsState { override fun present(): RoomDetailsState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@ -50,20 +68,16 @@ class RoomDetailsPresenter @Inject constructor(
mutableStateOf<RoomDetailsError?>(null) mutableStateOf<RoomDetailsError?>(null)
} }
var memberCount: Async<Int> by remember { mutableStateOf(Async.Loading()) } val memberCount by produceState<Async<Int>>(initialValue = Async.Loading(null)) {
LaunchedEffect(Unit) { room.members().map { it.count() }
withContext(Dispatchers.IO) { .onEach { value = Async.Success(it) }
memberCount = runCatching { room.memberCount() } .catch { value = Async.Failure(it) }
.fold( .launchIn(coroutineScope)
onSuccess = { Async.Success(it) },
onFailure = { Async.Failure(it) }
)
}
} }
val dmMember = room.getDmMember() val dmMember by room.getDmMember().collectAsState(initial = null)
val roomType = if (dmMember != null) { val roomType = if (dmMember != null) {
RoomDetailsType.Dm(dmMember) RoomDetailsType.Dm(dmMember!!)
} else { } else {
RoomDetailsType.Room RoomDetailsType.Room
} }
@ -90,6 +104,12 @@ class RoomDetailsPresenter @Inject constructor(
} }
} }
val roomMemberDetailsState = if (dmMember != null) {
roomMemberDetailsPresenter?.present()
} else {
null
}
return RoomDetailsState( return RoomDetailsState(
roomId = room.roomId.value, roomId = room.roomId.value,
roomName = room.name ?: room.displayName, roomName = room.name ?: room.displayName,
@ -101,6 +121,7 @@ class RoomDetailsPresenter @Inject constructor(
displayLeaveRoomWarning = leaveRoomWarning, displayLeaveRoomWarning = leaveRoomWarning,
error = error, error = error,
roomType = roomType, roomType = roomType,
roomMemberDetailsState = roomMemberDetailsState,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }

View file

@ -16,10 +16,8 @@
package io.element.android.features.roomdetails.impl package io.element.android.features.roomdetails.impl
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.isLoading
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
data class RoomDetailsState( data class RoomDetailsState(
@ -33,6 +31,7 @@ data class RoomDetailsState(
val displayLeaveRoomWarning: LeaveRoomWarning?, val displayLeaveRoomWarning: LeaveRoomWarning?,
val error: RoomDetailsError?, val error: RoomDetailsError?,
val roomType: RoomDetailsType, val roomType: RoomDetailsType,
val roomMemberDetailsState: RoomMemberDetailsState?,
val eventSink: (RoomDetailsEvent) -> Unit val eventSink: (RoomDetailsEvent) -> Unit
) )

View file

@ -71,5 +71,6 @@ fun aRoomDetailsState() = RoomDetailsState(
displayLeaveRoomWarning = null, displayLeaveRoomWarning = null,
error = null, error = null,
roomType = RoomDetailsType.Room, roomType = RoomDetailsType.Room,
roomMemberDetailsState = null,
eventSink = {} eventSink = {}
) )

View file

@ -42,7 +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.features.roomdetails.impl.members.details.BlockSection import io.element.android.features.roomdetails.blockuser.BlockUserDialogs
import io.element.android.features.roomdetails.blockuser.BlockUserSection
import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection
import io.element.android.features.roomdetails.impl.members.details.RoomMemberShareSection import io.element.android.features.roomdetails.impl.members.details.RoomMemberShareSection
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
@ -135,10 +136,11 @@ fun RoomDetailsView(
}) })
} }
is RoomDetailsType.Dm -> { is RoomDetailsType.Dm -> {
BlockSection( if (state.roomMemberDetailsState != null) {
isBlocked = state.roomType.roomMember.isIgnored, val roomMemberState = state.roomMemberDetailsState
onToggleBlock = { /*TODO*/ } BlockUserSection(roomMemberState)
) BlockUserDialogs(roomMemberState)
}
} }
} }

View file

@ -20,7 +20,6 @@ import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
import io.element.android.features.roomdetails.impl.members.RoomUserListDataSource import io.element.android.features.roomdetails.impl.members.RoomUserListDataSource
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.userlist.api.UserListDataSource import io.element.android.features.userlist.api.UserListDataSource
@ -28,7 +27,6 @@ import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import javax.inject.Named import javax.inject.Named
@Module @Module
@ -44,22 +42,14 @@ interface RoomMemberBindsModule {
@ContributesTo(RoomScope::class) @ContributesTo(RoomScope::class)
object RoomMemberProvidesModule { object RoomMemberProvidesModule {
@Provides
fun provideRoomDetailsPresenter(
matrixClient: MatrixClient,
room: MatrixRoom,
roomMembershipObserver: RoomMembershipObserver,
): RoomDetailsPresenter {
return RoomDetailsPresenter(matrixClient.sessionId, room, roomMembershipObserver)
}
@Provides @Provides
fun provideRoomMemberDetailsPresenterFactory( fun provideRoomMemberDetailsPresenterFactory(
matrixClient: MatrixClient,
room: MatrixRoom, room: MatrixRoom,
): RoomMemberDetailsPresenter.Factory { ): RoomMemberDetailsPresenter.Factory {
return object : RoomMemberDetailsPresenter.Factory { return object : RoomMemberDetailsPresenter.Factory {
override fun create(roomMember: RoomMember): RoomMemberDetailsPresenter { override fun create(roomMember: RoomMember): RoomMemberDetailsPresenter {
return RoomMemberDetailsPresenter(room, roomMember) return RoomMemberDetailsPresenter(matrixClient.sessionId, room, roomMember)
} }
} }
} }

View file

@ -29,6 +29,9 @@ import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.ui.model.MatrixUser import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@ -37,6 +40,7 @@ class RoomMemberListNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val room: MatrixRoom, private val room: MatrixRoom,
private val presenter: RoomMemberListPresenter, private val presenter: RoomMemberListPresenter,
private val coroutineScope: CoroutineScope,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
@ -45,8 +49,8 @@ class RoomMemberListNode @AssistedInject constructor(
private val callbacks = plugins<Callback>() private val callbacks = plugins<Callback>()
private fun onUserSelected(matrixUser: MatrixUser) { private fun onUserSelected(matrixUser: MatrixUser) = coroutineScope.launch {
val member = room.getMember(matrixUser.id) val member = room.getMember(matrixUser.id).firstOrNull()
if (member != null) { if (member != null) {
callbacks.forEach { it.openRoomMemberDetails(member) } callbacks.forEach { it.openRoomMemberDetails(member) }
} else { } else {

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.ui.model.MatrixUser import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.coroutines.flow.firstOrNull
import javax.inject.Inject import javax.inject.Inject
class RoomUserListDataSource @Inject constructor( class RoomUserListDataSource @Inject constructor(
@ -30,7 +31,7 @@ class RoomUserListDataSource @Inject constructor(
) : UserListDataSource { ) : UserListDataSource {
override suspend fun search(query: String): List<MatrixUser> { override suspend fun search(query: String): List<MatrixUser> {
return room.members().filter { member -> return room.members().firstOrNull().orEmpty().filter { member ->
if (query.isBlank()) { if (query.isBlank()) {
true true
} else { } else {

View file

@ -16,4 +16,8 @@
package io.element.android.features.roomdetails.impl.members.details package io.element.android.features.roomdetails.impl.members.details
sealed interface RoomMemberDetailsEvents sealed interface RoomMemberDetailsEvents {
data class BlockUser(val needsConfirmation: Boolean = false) : RoomMemberDetailsEvents
data class UnblockUser(val needsConfirmation: Boolean = false) : RoomMemberDetailsEvents
object ClearConfirmationDialog : RoomMemberDetailsEvents
}

View file

@ -30,7 +30,6 @@ import io.element.android.libraries.androidutils.system.startSharePlainTextInten
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import timber.log.Timber import timber.log.Timber
@ -52,7 +51,6 @@ class RoomMemberDetailsNode @AssistedInject constructor(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val context = LocalContext.current val context = LocalContext.current
fun onShareUser() { fun onShareUser() {

View file

@ -17,15 +17,26 @@
package io.element.android.features.roomdetails.impl.members.details package io.element.android.features.roomdetails.impl.members.details
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState.ConfirmationDialog
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class RoomMemberDetailsPresenter @AssistedInject constructor( class RoomMemberDetailsPresenter @AssistedInject constructor(
private val currentUserSessionId: SessionId,
private val room: MatrixRoom, private val room: MatrixRoom,
@Assisted private val roomMember: RoomMember, @Assisted private val roomMember: RoomMember,
) : Presenter<RoomMemberDetailsState> { ) : Presenter<RoomMemberDetailsState> {
@ -36,11 +47,31 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
@Composable @Composable
override fun present(): RoomMemberDetailsState { override fun present(): RoomMemberDetailsState {
val coroutineScope = rememberCoroutineScope()
var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) }
var isBlocked = remember { mutableStateOf(roomMember.isIgnored) }
// fun handleEvents(event: RoomMemberDetailsEvents) { fun handleEvents(event: RoomMemberDetailsEvents) {
// when (event) { when (event) {
// } is RoomMemberDetailsEvents.BlockUser -> {
// } if (event.needsConfirmation) {
confirmationDialog = ConfirmationDialog.Block
} else {
confirmationDialog = null
coroutineScope.blockUser(roomMember.userId, isBlocked)
}
}
is RoomMemberDetailsEvents.UnblockUser -> {
if (event.needsConfirmation) {
confirmationDialog = ConfirmationDialog.Unblock
} else {
confirmationDialog = null
coroutineScope.unblockUser(roomMember.userId, isBlocked)
}
}
RoomMemberDetailsEvents.ClearConfirmationDialog -> confirmationDialog = null
}
}
val userName by produceState(initialValue = roomMember.displayName) { val userName by produceState(initialValue = roomMember.displayName) {
room.userDisplayName(roomMember.userId).onSuccess { displayName -> room.userDisplayName(roomMember.userId).onSuccess { displayName ->
@ -58,8 +89,18 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
userId = roomMember.userId.value, userId = roomMember.userId.value,
userName = userName, userName = userName,
avatarUrl = userAvatar, avatarUrl = userAvatar,
isBlocked = roomMember.isIgnored, isBlocked = isBlocked.value,
// eventSink = ::handleEvents displayConfirmationDialog = confirmationDialog,
isCurrentUser = roomMember.userId == currentUserSessionId,
eventSink = ::handleEvents
) )
} }
private fun CoroutineScope.blockUser(userId: UserId, isBlockedState: MutableState<Boolean>) = launch {
room.ignoreUser(userId).onSuccess { isBlockedState.value = true }
}
private fun CoroutineScope.unblockUser(userId: UserId, isBlockedState: MutableState<Boolean>) = launch {
room.unignoreUser(userId).onSuccess { isBlockedState.value = false }
}
} }

View file

@ -21,5 +21,11 @@ data class RoomMemberDetailsState(
val userName: String?, val userName: String?,
val avatarUrl: String?, val avatarUrl: String?,
val isBlocked: Boolean, val isBlocked: Boolean,
// val eventSink: (RoomMemberDetailsEvents) -> Unit val displayConfirmationDialog: ConfirmationDialog? = null,
) val isCurrentUser: Boolean,
val eventSink: (RoomMemberDetailsEvents) -> Unit
) {
enum class ConfirmationDialog {
Block, Unblock
}
}

View file

@ -24,6 +24,8 @@ open class RoomMemberDetailsStateProvider : PreviewParameterProvider<RoomMemberD
aRoomMemberDetailsState(), aRoomMemberDetailsState(),
aRoomMemberDetailsState().copy(userName = null), aRoomMemberDetailsState().copy(userName = null),
aRoomMemberDetailsState().copy(isBlocked = true), aRoomMemberDetailsState().copy(isBlocked = true),
aRoomMemberDetailsState().copy(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Block),
aRoomMemberDetailsState().copy(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Unblock),
// Add other states here // Add other states here
) )
} }
@ -33,5 +35,6 @@ fun aRoomMemberDetailsState() = RoomMemberDetailsState(
userName = "Daniel", userName = "Daniel",
avatarUrl = null, avatarUrl = null,
isBlocked = false, isBlocked = false,
// eventSink = {}, isCurrentUser = false,
eventSink = {},
) )

View file

@ -39,12 +39,15 @@ import androidx.compose.ui.res.stringResource
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.features.roomdetails.blockuser.BlockUserDialogs
import io.element.android.features.roomdetails.blockuser.BlockUserSection
import io.element.android.features.roomdetails.impl.R import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceText import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@ -86,9 +89,10 @@ fun RoomMemberDetailsView(
// TODO implement send DM // TODO implement send DM
}) })
BlockSection(isBlocked = state.isBlocked, onToggleBlock = { if (!state.isCurrentUser) {
// TODO implement block & unblock BlockUserSection(state)
}) BlockUserDialogs(state)
}
} }
} }
} }
@ -139,24 +143,6 @@ internal fun SendMessageSection(onSendMessage: () -> Unit, modifier: Modifier =
} }
} }
@Composable
internal fun BlockSection(isBlocked: Boolean, onToggleBlock: () -> Unit, modifier: Modifier = Modifier) {
PreferenceCategory(showDivider = false, modifier = modifier) {
if (isBlocked) {
PreferenceText(
title = stringResource(R.string.screen_dm_details_unblock_user),
icon = Icons.Outlined.Block,
)
} else {
PreferenceText(
title = stringResource(R.string.screen_dm_details_block_user),
icon = Icons.Outlined.Block,
tintColor = LocalColors.current.textActionCritical,
)
}
}
}
@Preview @Preview
@Composable @Composable
fun RoomMemberDetailsViewLightPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) = fun RoomMemberDetailsViewLightPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) =

View file

@ -23,6 +23,7 @@ import com.google.common.truth.Truth
import io.element.android.features.roomdetails.impl.LeaveRoomWarning import io.element.android.features.roomdetails.impl.LeaveRoomWarning
import io.element.android.features.roomdetails.impl.RoomDetailsEvent import io.element.android.features.roomdetails.impl.RoomDetailsEvent
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
import io.element.android.features.roomdetails.impl.RoomDetailsType
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -32,8 +33,8 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
@ -50,7 +51,7 @@ class RoomDetailsPresenterTests {
@Test @Test
fun `present - initial state is created from room info`() = runTest { fun `present - initial state is created from room info`() = runTest {
val room = aMatrixRoom() val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -69,7 +70,7 @@ class RoomDetailsPresenterTests {
@Test @Test
fun `present - room member count is calculated asynchronously`() = runTest { fun `present - room member count is calculated asynchronously`() = runTest {
val room = aMatrixRoom() val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -84,7 +85,7 @@ class RoomDetailsPresenterTests {
@Test @Test
fun `present - initial state with no room name`() = runTest { fun `present - initial state with no room name`() = runTest {
val room = aMatrixRoom(name = null) val room = aMatrixRoom(name = null)
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -95,12 +96,33 @@ class RoomDetailsPresenterTests {
} }
} }
@Test
fun `present - initial state with DM member sets custom DM roomType`() = runTest {
val room = aMatrixRoom(name = null).apply {
givenDmMember(aRoomMember())
}
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// It's not configured yet in the first iteration
Truth.assertThat(initialState.roomType).isEqualTo(RoomDetailsType.Room)
// Once updated, the RoomDetailsType becomes 'Dm'
val updatedState = awaitItem()
Truth.assertThat(updatedState.roomType).isEqualTo(RoomDetailsType.Dm(aRoomMember()))
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `present - can handle error while fetching member count`() = runTest { fun `present - can handle error while fetching member count`() = runTest {
val room = aMatrixRoom(name = null).apply { val room = aMatrixRoom(name = null).apply {
givenFetchMemberResult(Result.failure(Throwable())) givenFetchMemberResult(Result.failure(Throwable()))
} }
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -114,7 +136,7 @@ class RoomDetailsPresenterTests {
@Test @Test
fun `present - Leave with confirmation on private room shows a specific warning`() = runTest { fun `present - Leave with confirmation on private room shows a specific warning`() = runTest {
val room = aMatrixRoom(isPublic = false) val room = aMatrixRoom(isPublic = false)
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -131,7 +153,7 @@ class RoomDetailsPresenterTests {
@Test @Test
fun `present - Leave with confirmation on empty room shows a specific warning`() = runTest { fun `present - Leave with confirmation on empty room shows a specific warning`() = runTest {
val room = aMatrixRoom(members = listOf(aRoomMember())) val room = aMatrixRoom(members = listOf(aRoomMember()))
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -148,7 +170,7 @@ class RoomDetailsPresenterTests {
@Test @Test
fun `present - Leave with confirmation shows a generic warning`() = runTest { fun `present - Leave with confirmation shows a generic warning`() = runTest {
val room = aMatrixRoom() val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -165,7 +187,7 @@ class RoomDetailsPresenterTests {
@Test @Test
fun `present - Leave without confirmation leaves the room`() = runTest { fun `present - Leave without confirmation leaves the room`() = runTest {
val room = aMatrixRoom() val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -189,7 +211,7 @@ class RoomDetailsPresenterTests {
val room = aMatrixRoom().apply { val room = aMatrixRoom().apply {
givenLeaveRoomError(Throwable()) givenLeaveRoomError(Throwable())
} }
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver) val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {

View file

@ -22,7 +22,10 @@ import app.cash.turbine.test
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.features.roomdetails.aMatrixRoom import io.element.android.features.roomdetails.aMatrixRoom
import io.element.android.features.roomdetails.aRoomMember import io.element.android.features.roomdetails.aRoomMember
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsEvents
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
import io.element.android.libraries.matrix.test.A_SESSION_ID
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -37,7 +40,7 @@ class RoomMemberDetailsPresenterTests {
givenUserAvatarUrlResult(Result.success("A custom avatar")) givenUserAvatarUrlResult(Result.success("A custom avatar"))
} }
val roomMember = aRoomMember(displayName = "Alice") val roomMember = aRoomMember(displayName = "Alice")
val presenter = RoomMemberDetailsPresenter(room, roomMember) val presenter = RoomMemberDetailsPresenter(A_SESSION_ID, room, roomMember)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -60,7 +63,7 @@ class RoomMemberDetailsPresenterTests {
givenUserAvatarUrlResult(Result.failure(Throwable())) givenUserAvatarUrlResult(Result.failure(Throwable()))
} }
val roomMember = aRoomMember(displayName = "Alice") val roomMember = aRoomMember(displayName = "Alice")
val presenter = RoomMemberDetailsPresenter(room, roomMember) val presenter = RoomMemberDetailsPresenter(A_SESSION_ID, room, roomMember)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -79,7 +82,7 @@ class RoomMemberDetailsPresenterTests {
givenUserAvatarUrlResult(Result.success(null)) givenUserAvatarUrlResult(Result.success(null))
} }
val roomMember = aRoomMember(displayName = "Alice") val roomMember = aRoomMember(displayName = "Alice")
val presenter = RoomMemberDetailsPresenter(room, roomMember) val presenter = RoomMemberDetailsPresenter(A_SESSION_ID, room, roomMember)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -90,4 +93,63 @@ class RoomMemberDetailsPresenterTests {
ensureAllEventsConsumed() ensureAllEventsConsumed()
} }
} }
@Test
fun `present - BlockUser needing confirmation displays confirmation dialog`() = runTest {
val room = aMatrixRoom()
val roomMember = aRoomMember()
val presenter = RoomMemberDetailsPresenter(A_SESSION_ID, room, roomMember)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = true))
val dialogState = awaitItem()
Truth.assertThat(dialogState.displayConfirmationDialog).isEqualTo(RoomMemberDetailsState.ConfirmationDialog.Block)
dialogState.eventSink(RoomMemberDetailsEvents.ClearConfirmationDialog)
Truth.assertThat(awaitItem().displayConfirmationDialog).isNull()
ensureAllEventsConsumed()
}
}
@Test
fun `present - BlockUser and UnblockUser without confirmation change the 'blocked' state`() = runTest {
val room = aMatrixRoom()
val roomMember = aRoomMember()
val presenter = RoomMemberDetailsPresenter(A_SESSION_ID, room, roomMember)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = false))
Truth.assertThat(awaitItem().isBlocked).isTrue()
initialState.eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = false))
Truth.assertThat(awaitItem().isBlocked).isFalse()
}
}
@Test
fun `present - UnblockUser needing confirmation displays confirmation dialog`() = runTest {
val room = aMatrixRoom()
val roomMember = aRoomMember()
val presenter = RoomMemberDetailsPresenter(A_SESSION_ID, room, roomMember)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = true))
val dialogState = awaitItem()
Truth.assertThat(dialogState.displayConfirmationDialog).isEqualTo(RoomMemberDetailsState.ConfirmationDialog.Unblock)
dialogState.eventSink(RoomMemberDetailsEvents.ClearConfirmationDialog)
Truth.assertThat(awaitItem().displayConfirmationDialog).isNull()
ensureAllEventsConsumed()
}
}
} }

View file

@ -0,0 +1,22 @@
/*
* 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.libraries.core.coroutine
import kotlinx.coroutines.flow.flow
/** Create a Flow emitting a single error event. It should be useful for tests. */
fun <T> errorFlow(throwable: Throwable) = flow<T> { throw throwable }

View file

@ -36,13 +36,13 @@ interface MatrixRoom : Closeable {
val isDirect: Boolean val isDirect: Boolean
val isPublic: Boolean val isPublic: Boolean
suspend fun members(): List<RoomMember> fun members() : Flow<List<RoomMember>>
suspend fun memberCount(): Int fun updateMembers()
fun getMember(userId: UserId): RoomMember? fun getMember(userId: UserId): Flow<RoomMember?>
fun getDmMember(): RoomMember? fun getDmMember(): Flow<RoomMember?>
fun syncUpdateFlow(): Flow<Long> fun syncUpdateFlow(): Flow<Long>
@ -62,6 +62,10 @@ interface MatrixRoom : Closeable {
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit> suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit>
suspend fun ignoreUser(userId: UserId): Result<Unit>
suspend fun unignoreUser(userId: UserId): Result<Unit>
suspend fun leave(): Result<Unit> suspend fun leave(): Result<Unit>
suspend fun acceptInvitation(): Result<Unit> suspend fun acceptInvitation(): Result<Unit>

View file

@ -26,18 +26,19 @@ 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.MutableStateFlow
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.flow.onSubscription
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
import org.matrix.rustcomponents.sdk.UpdateSummary import org.matrix.rustcomponents.sdk.UpdateSummary
import org.matrix.rustcomponents.sdk.genTransactionId import org.matrix.rustcomponents.sdk.genTransactionId
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember
class RustMatrixRoom( class RustMatrixRoom(
private val currentUserId: UserId, private val currentUserId: UserId,
@ -48,37 +49,40 @@ class RustMatrixRoom(
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
) : MatrixRoom { ) : MatrixRoom {
private var loadMembersJob: Job? = null private val timeline by lazy {
private var cachedMembers: List<RoomMember> = emptyList() RustMatrixTimeline(
matrixRoom = this,
innerRoom = innerRoom,
slidingSyncRoom = slidingSyncRoom,
coroutineScope = coroutineScope,
coroutineDispatchers = coroutineDispatchers
)
}
override suspend fun members(): List<RoomMember> { private var membersFlow = MutableStateFlow<List<RoomMember>>(emptyList())
return cachedMembers.ifEmpty {
if (loadMembersJob == null) { override fun members(): Flow<List<RoomMember>> {
loadMembersJob = coroutineScope.launch(coroutineDispatchers.io) { return membersFlow.onSubscription { updateMembers() }
cachedMembers = tryOrNull { }
innerRoom.members().map(RoomMemberMapper::map)
} ?: emptyList() override fun updateMembers() {
} val updatedMembers = tryOrNull {
innerRoom.members().map(RoomMemberMapper::map)
} ?: emptyList()
membersFlow.tryEmit(updatedMembers)
}
override fun getMember(userId: UserId): Flow<RoomMember?> {
return membersFlow.map { members -> members.find { it.userId == userId } }
}
override fun getDmMember(): Flow<RoomMember?> {
return membersFlow.map { members ->
if (members.size == 2 && isDirect && isEncrypted) {
members.find { it.userId != currentUserId }
} else {
null
} }
loadMembersJob?.join()
loadMembersJob = null
cachedMembers
}
}
override suspend fun memberCount(): Int {
return members().size
}
override fun getMember(userId: UserId): RoomMember? {
return cachedMembers.find { it.userId == userId }
}
override fun getDmMember(): RoomMember? {
return if (cachedMembers.size == 2 && isDirect && isEncrypted) {
cachedMembers.find { it.userId != currentUserId }
} else {
null
} }
} }
@ -94,13 +98,7 @@ class RustMatrixRoom(
} }
override fun timeline(): MatrixTimeline { override fun timeline(): MatrixTimeline {
return RustMatrixTimeline( return timeline
matrixRoom = this,
innerRoom = innerRoom,
slidingSyncRoom = slidingSyncRoom,
coroutineScope = coroutineScope,
coroutineDispatchers = coroutineDispatchers
)
} }
override fun close() { override fun close() {
@ -219,4 +217,20 @@ class RustMatrixRoom(
} }
} }
override suspend fun ignoreUser(userId: UserId): Result<Unit> {
return runCatching {
getRustMember(userId)?.ignore() ?: error("No member with userId $userId exists in room $roomId")
}
}
override suspend fun unignoreUser(userId: UserId): Result<Unit> {
return runCatching {
getRustMember(userId)?.unignore() ?: error("No member with userId $userId exists in room $roomId")
}
}
private fun getRustMember(userId: UserId): RustRoomMember? {
return innerRoom.members().find { it.userId() == userId.value }
}
} }

View file

@ -139,7 +139,14 @@ class RustMatrixTimeline(
private suspend fun addListener(timelineListener: TimelineListener): Result<List<TimelineItem>> = withContext(coroutineDispatchers.io) { private suspend fun addListener(timelineListener: TimelineListener): Result<List<TimelineItem>> = withContext(coroutineDispatchers.io) {
runCatching { runCatching {
val settings = RoomSubscription(requiredState = listOf(RequiredState(key = "m.room.canonical_alias", value = "")), timelineLimit = null) val settings = RoomSubscription(
requiredState = listOf(
RequiredState(key = "m.room.topic", value = ""),
RequiredState(key = "m.room.canonical_alias", value = ""),
RequiredState(key = "m.room.join_rules", value = ""),
),
timelineLimit = null
)
val result = slidingSyncRoom.subscribeAndAddTimelineListener(timelineListener, settings) val result = slidingSyncRoom.subscribeAndAddTimelineListener(timelineListener, settings)
listenerTokens += result.taskHandle listenerTokens += result.taskHandle
result.items result.items

View file

@ -23,6 +23,7 @@ android {
} }
dependencies { dependencies {
api(projects.libraries.core)
api(projects.libraries.matrix.api) api(projects.libraries.matrix.api)
api(libs.coroutines.core) api(libs.coroutines.core)
} }

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.test.room package io.element.android.libraries.matrix.test.room
import io.element.android.libraries.core.coroutine.errorFlow
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.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -27,6 +28,7 @@ import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
class FakeMatrixRoom( class FakeMatrixRoom(
override val roomId: RoomId = A_ROOM_ID, override val roomId: RoomId = A_ROOM_ID,
@ -50,6 +52,8 @@ class FakeMatrixRoom(
private var rejectInviteResult = Result.success(Unit) private var rejectInviteResult = Result.success(Unit)
private var dmMember: RoomMember? = null private var dmMember: RoomMember? = null
private var fetchMemberResult: Result<Unit> = Result.success(Unit) private var fetchMemberResult: Result<Unit> = Result.success(Unit)
private var ignoreResult = Result.success(Unit)
private var unignoreResult = Result.success(Unit)
var areMembersFetched: Boolean = false var areMembersFetched: Boolean = false
private set private set
@ -78,8 +82,8 @@ class FakeMatrixRoom(
} }
} }
override fun getDmMember(): RoomMember? { override fun getDmMember(): Flow<RoomMember?> {
return dmMember return flowOf(dmMember)
} }
override suspend fun userDisplayName(userId: UserId): Result<String?> { override suspend fun userDisplayName(userId: UserId): Result<String?> {
@ -90,20 +94,18 @@ class FakeMatrixRoom(
return userAvatarUrlResult return userAvatarUrlResult
} }
override suspend fun members(): List<RoomMember> { override fun members(): Flow<List<RoomMember>> {
return members return fetchMemberResult.fold(onSuccess = {
flowOf(members)
}, onFailure = {
errorFlow(it)
})
} }
override suspend fun memberCount(): Int { override fun updateMembers() = Unit
if (fetchMemberResult.isSuccess) {
return members.count()
} else {
throw fetchMemberResult.exceptionOrNull()!!
}
}
override fun getMember(userId: UserId): RoomMember? { override fun getMember(userId: UserId): Flow<RoomMember?> {
return members.firstOrNull { it.userId == userId } return flowOf(members.find { it.userId == userId })
} }
override suspend fun sendMessage(message: String): Result<Unit> { override suspend fun sendMessage(message: String): Result<Unit> {
@ -138,6 +140,10 @@ class FakeMatrixRoom(
return Result.success(Unit) return Result.success(Unit)
} }
override suspend fun ignoreUser(userId: UserId): Result<Unit> = ignoreResult
override suspend fun unignoreUser(userId: UserId): Result<Unit> = unignoreResult
override suspend fun leave(): Result<Unit> = leaveRoomError?.let { Result.failure(it) } ?: Result.success(Unit) override suspend fun leave(): Result<Unit> = leaveRoomError?.let { Result.failure(it) } ?: Result.success(Unit)
override suspend fun acceptInvitation(): Result<Unit> { override suspend fun acceptInvitation(): Result<Unit> {
isInviteAccepted = true isInviteAccepted = true
@ -179,4 +185,12 @@ class FakeMatrixRoom(
rejectInviteResult = result rejectInviteResult = result
} }
fun givenIgnoreResult(result: Result<Unit>) {
ignoreResult = result
}
fun givenUnIgnoreResult(result: Result<Unit>) {
unignoreResult = result
}
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:354452861d1e006a8bfa744251ffdaf15088e0bb181a53043f121e606233d648 oid sha256:a8a9186d741d251dc8bdf6bcf71d395e8c057c310585aeb8911609eaaacce842
size 67340 size 64550

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f1cb6bac9e72b956d0cffde807340e36a7a4d6873d4d7337995b53e82769c4f9 oid sha256:a8a9186d741d251dc8bdf6bcf71d395e8c057c310585aeb8911609eaaacce842
size 68135 size 64550

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:dc56e29c26250c6fef312ec4c5fdfaa2f63159fd0565bd37522b46c7ff67906a oid sha256:94a589b556a750485fd61af6446457c98c5f112d0d013cd78016b88a8829e6a8
size 61924 size 58643

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c6faff25a1c187dee59d0dcf0affd5029251512efb6eff7fc2d41d34bace2061 oid sha256:94a589b556a750485fd61af6446457c98c5f112d0d013cd78016b88a8829e6a8
size 62356 size 58643