Browse Source

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

test/jme/fix-danger-lint-duplicate-reports
Jorge Martin Espinosa 1 year ago committed by GitHub
parent
commit
2376d32b9e
  1. 11
      appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt
  2. 1
      changelog.d/339.feature
  3. 7
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
  4. 17
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt
  5. 89
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/blockuser/BlockUserSection.kt
  6. 18
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt
  7. 49
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
  8. 5
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt
  9. 1
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt
  10. 12
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt
  11. 14
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModules.kt
  12. 8
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt
  13. 3
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomUserListDataSource.kt
  14. 6
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsEvents.kt
  15. 2
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt
  16. 53
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt
  17. 10
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsState.kt
  18. 5
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt
  19. 28
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt
  20. 42
      features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt
  21. 68
      features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt
  22. 22
      libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/ErrorFlow.kt
  23. 12
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
  24. 82
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
  25. 9
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt
  26. 1
      libraries/matrix/test/build.gradle.kts
  27. 40
      libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
  28. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png
  29. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png
  30. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_3,NEXUS_5,1.0,en].png
  31. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_4,NEXUS_5,1.0,en].png
  32. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview_0_null_5,NEXUS_5,1.0,en].png
  33. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview_0_null_6,NEXUS_5,1.0,en].png
  34. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview_0_null_5,NEXUS_5,1.0,en].png
  35. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview_0_null_6,NEXUS_5,1.0,en].png

11
appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt

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

1
changelog.d/339.feature

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

7
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt

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

17
features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt

@ -49,23 +49,6 @@ class TimelinePresenterTest { @@ -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
fun `present - load more`() = runTest {
val presenter = TimelinePresenter(

89
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/blockuser/BlockUserSection.kt

@ -0,0 +1,89 @@ @@ -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
)
}

18
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt

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

49
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt

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

5
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt

@ -16,10 +16,8 @@ @@ -16,10 +16,8 @@
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.isLoading
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
data class RoomDetailsState(
@ -33,6 +31,7 @@ data class RoomDetailsState( @@ -33,6 +31,7 @@ data class RoomDetailsState(
val displayLeaveRoomWarning: LeaveRoomWarning?,
val error: RoomDetailsError?,
val roomType: RoomDetailsType,
val roomMemberDetailsState: RoomMemberDetailsState?,
val eventSink: (RoomDetailsEvent) -> Unit
)

1
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt

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

12
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt

@ -42,7 +42,8 @@ import androidx.compose.ui.res.vectorResource @@ -42,7 +42,8 @@ import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.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.RoomMemberShareSection
import io.element.android.libraries.architecture.Async
@ -135,10 +136,11 @@ fun RoomDetailsView( @@ -135,10 +136,11 @@ fun RoomDetailsView(
})
}
is RoomDetailsType.Dm -> {
BlockSection(
isBlocked = state.roomType.roomMember.isIgnored,
onToggleBlock = { /*TODO*/ }
)
if (state.roomMemberDetailsState != null) {
val roomMemberState = state.roomMemberDetailsState
BlockUserSection(roomMemberState)
BlockUserDialogs(roomMemberState)
}
}
}

14
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModules.kt

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

8
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt

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

3
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomUserListDataSource.kt

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

6
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsEvents.kt

@ -16,4 +16,8 @@ @@ -16,4 +16,8 @@
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
}

2
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt

@ -30,7 +30,6 @@ import io.element.android.libraries.androidutils.system.startSharePlainTextInten @@ -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.inputs
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.room.RoomMember
import timber.log.Timber
@ -52,7 +51,6 @@ class RoomMemberDetailsNode @AssistedInject constructor( @@ -52,7 +51,6 @@ class RoomMemberDetailsNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
val context = LocalContext.current
fun onShareUser() {

53
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt

@ -17,15 +17,26 @@ @@ -17,15 +17,26 @@
package io.element.android.features.roomdetails.impl.members.details
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.AssistedInject
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState.ConfirmationDialog
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.RoomMember
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class RoomMemberDetailsPresenter @AssistedInject constructor(
private val currentUserSessionId: SessionId,
private val room: MatrixRoom,
@Assisted private val roomMember: RoomMember,
) : Presenter<RoomMemberDetailsState> {
@ -36,11 +47,31 @@ class RoomMemberDetailsPresenter @AssistedInject constructor( @@ -36,11 +47,31 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
@Composable
override fun present(): RoomMemberDetailsState {
val coroutineScope = rememberCoroutineScope()
var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) }
var isBlocked = remember { mutableStateOf(roomMember.isIgnored) }
// fun handleEvents(event: RoomMemberDetailsEvents) {
// when (event) {
// }
// }
fun handleEvents(event: RoomMemberDetailsEvents) {
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) {
room.userDisplayName(roomMember.userId).onSuccess { displayName ->
@ -58,8 +89,18 @@ class RoomMemberDetailsPresenter @AssistedInject constructor( @@ -58,8 +89,18 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
userId = roomMember.userId.value,
userName = userName,
avatarUrl = userAvatar,
isBlocked = roomMember.isIgnored,
// eventSink = ::handleEvents
isBlocked = isBlocked.value,
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 }
}
}

10
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsState.kt

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

5
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt

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

28
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt

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

42
features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt

@ -23,6 +23,7 @@ import com.google.common.truth.Truth @@ -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.RoomDetailsEvent
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.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@ -32,8 +33,8 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipState @@ -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.test.A_ROOM_ID
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.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
@ -50,7 +51,7 @@ class RoomDetailsPresenterTests { @@ -50,7 +51,7 @@ class RoomDetailsPresenterTests {
@Test
fun `present - initial state is created from room info`() = runTest {
val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver)
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -69,7 +70,7 @@ class RoomDetailsPresenterTests { @@ -69,7 +70,7 @@ class RoomDetailsPresenterTests {
@Test
fun `present - room member count is calculated asynchronously`() = runTest {
val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver)
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -84,7 +85,7 @@ class RoomDetailsPresenterTests { @@ -84,7 +85,7 @@ class RoomDetailsPresenterTests {
@Test
fun `present - initial state with no room name`() = runTest {
val room = aMatrixRoom(name = null)
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver)
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -95,12 +96,33 @@ class RoomDetailsPresenterTests { @@ -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
fun `present - can handle error while fetching member count`() = runTest {
val room = aMatrixRoom(name = null).apply {
givenFetchMemberResult(Result.failure(Throwable()))
}
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver)
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -114,7 +136,7 @@ class RoomDetailsPresenterTests { @@ -114,7 +136,7 @@ class RoomDetailsPresenterTests {
@Test
fun `present - Leave with confirmation on private room shows a specific warning`() = runTest {
val room = aMatrixRoom(isPublic = false)
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver)
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -131,7 +153,7 @@ class RoomDetailsPresenterTests { @@ -131,7 +153,7 @@ class RoomDetailsPresenterTests {
@Test
fun `present - Leave with confirmation on empty room shows a specific warning`() = runTest {
val room = aMatrixRoom(members = listOf(aRoomMember()))
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver)
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -148,7 +170,7 @@ class RoomDetailsPresenterTests { @@ -148,7 +170,7 @@ class RoomDetailsPresenterTests {
@Test
fun `present - Leave with confirmation shows a generic warning`() = runTest {
val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver)
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -165,7 +187,7 @@ class RoomDetailsPresenterTests { @@ -165,7 +187,7 @@ class RoomDetailsPresenterTests {
@Test
fun `present - Leave without confirmation leaves the room`() = runTest {
val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver)
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -189,7 +211,7 @@ class RoomDetailsPresenterTests { @@ -189,7 +211,7 @@ class RoomDetailsPresenterTests {
val room = aMatrixRoom().apply {
givenLeaveRoomError(Throwable())
}
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver)
val presenter = RoomDetailsPresenter(FakeMatrixClient(), room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {

68
features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt

@ -22,7 +22,10 @@ import app.cash.turbine.test @@ -22,7 +22,10 @@ import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.features.roomdetails.aMatrixRoom
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.RoomMemberDetailsState
import io.element.android.libraries.matrix.test.A_SESSION_ID
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -37,7 +40,7 @@ class RoomMemberDetailsPresenterTests { @@ -37,7 +40,7 @@ class RoomMemberDetailsPresenterTests {
givenUserAvatarUrlResult(Result.success("A custom avatar"))
}
val roomMember = aRoomMember(displayName = "Alice")
val presenter = RoomMemberDetailsPresenter(room, roomMember)
val presenter = RoomMemberDetailsPresenter(A_SESSION_ID, room, roomMember)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -60,7 +63,7 @@ class RoomMemberDetailsPresenterTests { @@ -60,7 +63,7 @@ class RoomMemberDetailsPresenterTests {
givenUserAvatarUrlResult(Result.failure(Throwable()))
}
val roomMember = aRoomMember(displayName = "Alice")
val presenter = RoomMemberDetailsPresenter(room, roomMember)
val presenter = RoomMemberDetailsPresenter(A_SESSION_ID, room, roomMember)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -79,7 +82,7 @@ class RoomMemberDetailsPresenterTests { @@ -79,7 +82,7 @@ class RoomMemberDetailsPresenterTests {
givenUserAvatarUrlResult(Result.success(null))
}
val roomMember = aRoomMember(displayName = "Alice")
val presenter = RoomMemberDetailsPresenter(room, roomMember)
val presenter = RoomMemberDetailsPresenter(A_SESSION_ID, room, roomMember)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -90,4 +93,63 @@ class RoomMemberDetailsPresenterTests { @@ -90,4 +93,63 @@ class RoomMemberDetailsPresenterTests {
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()
}
}
}

22
libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/ErrorFlow.kt

@ -0,0 +1,22 @@ @@ -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 }

12
libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt

@ -36,13 +36,13 @@ interface MatrixRoom : Closeable { @@ -36,13 +36,13 @@ interface MatrixRoom : Closeable {
val isDirect: 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>
@ -62,6 +62,10 @@ interface MatrixRoom : Closeable { @@ -62,6 +62,10 @@ interface MatrixRoom : Closeable {
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 acceptInvitation(): Result<Unit>

82
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt

@ -26,18 +26,19 @@ import io.element.android.libraries.matrix.api.room.RoomMember @@ -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.impl.timeline.RustMatrixTimeline
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.SlidingSyncRoom
import org.matrix.rustcomponents.sdk.UpdateSummary
import org.matrix.rustcomponents.sdk.genTransactionId
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember
class RustMatrixRoom(
private val currentUserId: UserId,
@ -48,37 +49,40 @@ class RustMatrixRoom( @@ -48,37 +49,40 @@ class RustMatrixRoom(
private val coroutineDispatchers: CoroutineDispatchers,
) : MatrixRoom {
private var loadMembersJob: Job? = null
private var cachedMembers: List<RoomMember> = emptyList()
override suspend fun members(): List<RoomMember> {
return cachedMembers.ifEmpty {
if (loadMembersJob == null) {
loadMembersJob = coroutineScope.launch(coroutineDispatchers.io) {
cachedMembers = tryOrNull {
innerRoom.members().map(RoomMemberMapper::map)
} ?: emptyList()
}
}
loadMembersJob?.join()
loadMembersJob = null
cachedMembers
}
private val timeline by lazy {
RustMatrixTimeline(
matrixRoom = this,
innerRoom = innerRoom,
slidingSyncRoom = slidingSyncRoom,
coroutineScope = coroutineScope,
coroutineDispatchers = coroutineDispatchers
)
}
private var membersFlow = MutableStateFlow<List<RoomMember>>(emptyList())
override fun members(): Flow<List<RoomMember>> {
return membersFlow.onSubscription { updateMembers() }
}
override suspend fun memberCount(): Int {
return members().size
override fun updateMembers() {
val updatedMembers = tryOrNull {
innerRoom.members().map(RoomMemberMapper::map)
} ?: emptyList()
membersFlow.tryEmit(updatedMembers)
}
override fun getMember(userId: UserId): RoomMember? {
return cachedMembers.find { it.userId == userId }
override fun getMember(userId: UserId): Flow<RoomMember?> {
return membersFlow.map { members -> members.find { it.userId == userId } }
}
override fun getDmMember(): RoomMember? {
return if (cachedMembers.size == 2 && isDirect && isEncrypted) {
cachedMembers.find { it.userId != currentUserId }
} else {
null
override fun getDmMember(): Flow<RoomMember?> {
return membersFlow.map { members ->
if (members.size == 2 && isDirect && isEncrypted) {
members.find { it.userId != currentUserId }
} else {
null
}
}
}
@ -94,13 +98,7 @@ class RustMatrixRoom( @@ -94,13 +98,7 @@ class RustMatrixRoom(
}
override fun timeline(): MatrixTimeline {
return RustMatrixTimeline(
matrixRoom = this,
innerRoom = innerRoom,
slidingSyncRoom = slidingSyncRoom,
coroutineScope = coroutineScope,
coroutineDispatchers = coroutineDispatchers
)
return timeline
}
override fun close() {
@ -219,4 +217,20 @@ class RustMatrixRoom( @@ -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 }
}
}

9
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt

@ -139,7 +139,14 @@ class RustMatrixTimeline( @@ -139,7 +139,14 @@ class RustMatrixTimeline(
private suspend fun addListener(timelineListener: TimelineListener): Result<List<TimelineItem>> = withContext(coroutineDispatchers.io) {
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)
listenerTokens += result.taskHandle
result.items

1
libraries/matrix/test/build.gradle.kts

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

40
libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt

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

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_3,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_4,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview_0_null_5,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview_0_null_6,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview_0_null_5,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview_0_null_6,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.
Loading…
Cancel
Save