change (member moderation) : branch moderation on timeline

This commit is contained in:
ganfra
2025-05-13 11:39:19 +02:00
parent 5272587897
commit f22b921768
22 changed files with 169 additions and 95 deletions

View File

@@ -67,6 +67,7 @@ dependencies {
implementation(libs.telephoto.zoomableimage)
implementation(libs.matrix.emojibase.bindings)
implementation(projects.features.knockrequests.api)
implementation(projects.features.roommembermoderation.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)

View File

@@ -9,12 +9,15 @@ package io.element.android.features.messages.impl
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface MessagesEvents {
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents
data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : MessagesEvents
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvents
data class OnUserClicked(val user: MatrixUser) : MessagesEvents
data object Dismiss : MessagesEvents
}

View File

@@ -39,6 +39,9 @@ import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.roommembermoderation.api.ModerationAction
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationRenderer
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.androidutils.system.toast
@@ -76,7 +79,8 @@ class MessagesNode @AssistedInject constructor(
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer,
private val permalinkParser: PermalinkParser,
private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer
private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val presenter = presenterFactory.create(
navigator = this,
@@ -257,6 +261,16 @@ class MessagesNode @AssistedInject constructor(
},
modifier = modifier,
)
roomMemberModerationRenderer.Render(
state = state.roomMemberModerationState,
onSelectAction = { action ->
when (action) {
is ModerationAction.DisplayProfile -> onUserDataClick(action.user.userId)
else -> state.roomMemberModerationState.eventSink(RoomMemberModerationEvents.ProcessAction(action))
}
},
modifier = Modifier,
)
var focusedEventId by rememberSaveable {
mutableStateOf(inputs.focusedEventId)

View File

@@ -50,6 +50,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@@ -103,6 +105,7 @@ class MessagesPresenter @AssistedInject constructor(
private val readReceiptBottomSheetPresenter: Presenter<ReadReceiptBottomSheetState>,
private val pinnedMessagesBannerPresenter: Presenter<PinnedMessagesBannerState>,
private val roomCallStatePresenter: Presenter<RoomCallState>,
private val roomMemberModerationPresenter: Presenter<RoomMemberModerationState>,
private val syncService: SyncService,
private val snackbarDispatcher: SnackbarDispatcher,
private val dispatchers: CoroutineDispatchers,
@@ -143,7 +146,7 @@ class MessagesPresenter @AssistedInject constructor(
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
val roomCallState = roomCallStatePresenter.present()
val roomMemberModerationState = roomMemberModerationPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
@@ -233,6 +236,9 @@ class MessagesPresenter @AssistedInject constructor(
}
}
is MessagesEvents.Dismiss -> actionListState.eventSink(ActionListEvents.Clear)
is MessagesEvents.OnUserClicked -> {
roomMemberModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.user))
}
}
}
@@ -262,6 +268,7 @@ class MessagesPresenter @AssistedInject constructor(
roomCallState = roomCallState,
pinnedMessagesBannerState = pinnedMessagesBannerState,
dmUserVerificationState = dmUserVerificationState,
roomMemberModerationState = roomMemberModerationState,
eventSink = { handleEvents(it) }
)
}

View File

@@ -20,6 +20,7 @@ import io.element.android.features.messages.impl.timeline.components.receipt.bot
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@@ -54,5 +55,6 @@ data class MessagesState(
val appName: String,
val pinnedMessagesBannerState: PinnedMessagesBannerState,
val dmUserVerificationState: IdentityState?,
val roomMemberModerationState: RoomMemberModerationState,
val eventSink: (MessagesEvents) -> Unit
)

View File

@@ -37,11 +37,14 @@ import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMe
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roomcall.api.anOngoingCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
import kotlinx.collections.immutable.persistentListOf
@@ -116,6 +119,7 @@ fun aMessagesState(
roomCallState: RoomCallState = aStandByCallState(),
pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(),
dmUserVerificationState: IdentityState? = null,
roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(),
eventSink: (MessagesEvents) -> Unit = {},
) = MessagesState(
roomId = RoomId("!id:domain"),
@@ -143,9 +147,20 @@ fun aMessagesState(
appName = "Element",
pinnedMessagesBannerState = pinnedMessagesBannerState,
dmUserVerificationState = dmUserVerificationState,
roomMemberModerationState = roomMemberModerationState,
eventSink = eventSink,
)
fun aRoomMemberModerationState(
canKick: Boolean = false,
canBan: Boolean = false,
) = object : RoomMemberModerationState {
override val canKick: Boolean = canKick
override val canBan: Boolean = canBan
override val eventSink: (RoomMemberModerationEvents) -> Unit = {}
}
fun aUserEventPermissions(
canRedactOwn: Boolean = false,
canRedactOther: Boolean = false,

View File

@@ -103,6 +103,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbar
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link
@@ -208,7 +209,9 @@ fun MessagesView(
.consumeWindowInsets(padding),
onContentClick = ::onContentClick,
onMessageLongClick = ::onMessageLongClick,
onUserDataClick = { hidingKeyboard { onUserDataClick(it) } },
onUserDataClick = { hidingKeyboard {
state.eventSink(MessagesEvents.OnUserClicked(it))
} },
onLinkClick = { link, customTab ->
if (customTab) {
onLinkClick(link.url, true)
@@ -293,7 +296,7 @@ private fun ReinviteDialog(state: MessagesState) {
private fun MessagesViewContent(
state: MessagesState,
onContentClick: (TimelineItem.Event) -> Unit,
onUserDataClick: (UserId) -> Unit,
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link, Boolean) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,

View File

@@ -33,6 +33,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
@ContributesNode(RoomScope::class)
@@ -63,8 +64,8 @@ class PinnedMessagesListNode @AssistedInject constructor(
return callbacks.forEach { it.onEventClick(event) }
}
private fun onUserDataClick(userId: UserId) {
callbacks.forEach { it.onUserDataClick(userId) }
private fun onUserDataClick(user: MatrixUser) {
callbacks.forEach { it.onUserDataClick(user.userId) }
}
private fun onLinkClick(context: Context, url: String) {

View File

@@ -49,6 +49,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.compose.LocalAnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@@ -59,7 +60,7 @@ fun PinnedMessagesListView(
state: PinnedMessagesListState,
onBackClick: () -> Unit,
onEventClick: (event: TimelineItem.Event) -> Unit,
onUserDataClick: (UserId) -> Unit,
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
modifier: Modifier = Modifier,
@@ -115,7 +116,7 @@ private fun PinnedMessagesListTopBar(
private fun PinnedMessagesListContent(
state: PinnedMessagesListState,
onEventClick: (event: TimelineItem.Event) -> Unit,
onUserDataClick: (UserId) -> Unit,
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onErrorDismiss: () -> Unit,
@@ -171,7 +172,7 @@ private fun PinnedMessagesListEmpty(
private fun PinnedMessagesListLoaded(
state: PinnedMessagesListState.Filled,
onEventClick: (event: TimelineItem.Event) -> Unit,
onUserDataClick: (UserId) -> Unit,
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
modifier: Modifier = Modifier,

View File

@@ -71,6 +71,7 @@ import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@@ -92,7 +93,7 @@ import kotlin.time.Duration.Companion.milliseconds
fun TimelineView(
state: TimelineState,
timelineProtectionState: TimelineProtectionState,
onUserDataClick: (UserId) -> Unit,
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onContentClick: (TimelineItem.Event) -> Unit,
onMessageLongClick: (TimelineItem.Event) -> Unit,

View File

@@ -87,6 +87,10 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView
import io.element.android.libraries.matrix.ui.messages.reply.eventId
@@ -122,7 +126,7 @@ fun TimelineItemEventRow(
onLongClick: () -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onUserDataClick: (UserId) -> Unit,
onUserDataClick: (MatrixUser) -> Unit,
inReplyToClick: (EventId) -> Unit,
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
onReactionLongClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
@@ -160,7 +164,12 @@ fun TimelineItemEventRow(
}
fun onUserDataClick() {
onUserDataClick(event.senderId)
val sender = MatrixUser(
userId = event.senderId,
displayName = event.senderProfile.getDisplayName(),
avatarUrl = event.senderProfile.getAvatarUrl(),
)
onUserDataClick(sender)
}
fun inReplyToClick() {

View File

@@ -35,6 +35,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.wysiwyg.link.Link
@Composable
@@ -48,7 +49,7 @@ fun TimelineItemGroupedEventsRow(
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit,
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
@@ -117,7 +118,7 @@ private fun TimelineItemGroupedEventsRowContent(
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit,
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,

View File

@@ -43,6 +43,7 @@ import io.element.android.libraries.designsystem.theme.LocalBuildMeta
import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.link.Link
@@ -57,7 +58,7 @@ internal fun TimelineItemRow(
isLastOutgoingMessage: Boolean,
timelineProtectionState: TimelineProtectionState,
focusedEventId: EventId?,
onUserDataClick: (UserId) -> Unit,
onUserDataClick: (MatrixUser) -> Unit,
onLinkClick: (Link) -> Unit,
onLinkLongClick: (Link) -> Unit,
onContentClick: (TimelineItem.Event) -> Unit,

View File

@@ -77,7 +77,7 @@ class RoomMemberListNode @AssistedInject constructor(
state = state.moderationState,
onSelectAction = { action ->
when (action) {
is ModerationAction.DisplayProfile -> openRoomMemberDetails(action.member.userId)
is ModerationAction.DisplayProfile -> openRoomMemberDetails(action.user.userId)
else -> state.moderationState.eventSink(RoomMemberModerationEvents.ProcessAction(action))
}
},

View File

@@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.ui.room.canInviteAsState
import io.element.android.libraries.matrix.ui.room.isDmAsState
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
@@ -166,9 +167,9 @@ class RoomMemberListPresenter @AssistedInject constructor(
is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query
is RoomMemberListEvents.RoomMemberSelected ->
if (event.roomMember.membership == RoomMembershipState.BAN) {
roomModerationState.eventSink(RoomMemberModerationEvents.ProcessAction(ModerationAction.UnbanUser(event.roomMember)))
roomModerationState.eventSink(RoomMemberModerationEvents.ProcessAction(ModerationAction.UnbanUser(event.roomMember.toMatrixUser())))
} else if (!isDm.value && (roomModerationState.canBan || roomModerationState.canKick)) {
roomModerationState.eventSink(RoomMemberModerationEvents.RenderActions(event.roomMember))
roomModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.roomMember.toMatrixUser()))
} else {
navigator.openRoomMemberDetails(event.roomMember.userId)
}

View File

@@ -7,9 +7,9 @@
package io.element.android.features.roommembermoderation.api
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.user.MatrixUser
interface RoomMemberModerationEvents {
data class RenderActions(val roomMember: RoomMember) : RoomMemberModerationEvents
data class ProcessAction(val action: ModerationAction): RoomMemberModerationEvents
data class ShowActionsForUser(val user: MatrixUser) : RoomMemberModerationEvents
data class ProcessAction(val action: ModerationAction) : RoomMemberModerationEvents
}

View File

@@ -7,7 +7,7 @@
package io.element.android.features.roommembermoderation.api
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.user.MatrixUser
interface RoomMemberModerationState {
val canKick: Boolean
@@ -16,8 +16,8 @@ interface RoomMemberModerationState {
}
sealed interface ModerationAction {
data class DisplayProfile(val member: RoomMember) : ModerationAction
data class KickUser(val member: RoomMember) : ModerationAction
data class BanUser(val member: RoomMember) : ModerationAction
data class UnbanUser(val member: RoomMember) : ModerationAction
data class DisplayProfile(val user: MatrixUser) : ModerationAction
data class KickUser(val user: MatrixUser) : ModerationAction
data class BanUser(val user: MatrixUser) : ModerationAction
data class UnbanUser(val user: MatrixUser) : ModerationAction
}

View File

@@ -13,20 +13,19 @@ import io.element.android.features.roommembermoderation.api.RoomMemberModeration
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class InternalRoomMemberModerationState(
override val canKick: Boolean,
override val canBan: Boolean,
val selectedRoomMember: AsyncData<RoomMember>,
val selectedUser: MatrixUser?,
val actions: ImmutableList<ModerationAction>,
val kickUserAsyncAction: AsyncAction<Unit>,
val banUserAsyncAction: AsyncAction<Unit>,
val unbanUserAsyncAction: AsyncAction<Unit>,
override val eventSink: (RoomMemberModerationEvents) -> Unit,
) : RoomMemberModerationState {
val canOnlyDisplayProfile = actions.size == 1 && actions.first() is ModerationAction.DisplayProfile
val canDisplayActions = actions.isNotEmpty() && !canOnlyDisplayProfile
val canDisplayActions = actions.isNotEmpty()
}

View File

@@ -20,13 +20,14 @@ import io.element.android.features.roommembermoderation.api.ModerationAction
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.room.canBanAsState
import io.element.android.libraries.matrix.ui.room.canKickAsState
import io.element.android.libraries.matrix.ui.room.userPowerLevelAsState
@@ -45,7 +46,6 @@ class RoomMemberModerationPresenter @Inject constructor(
private val dispatchers: CoroutineDispatchers,
private val analyticsService: AnalyticsService,
) : Presenter<RoomMemberModerationState> {
private var selectedMember by mutableStateOf<AsyncData<RoomMember>>(AsyncData.Uninitialized)
@Composable
override fun present(): RoomMemberModerationState {
@@ -61,60 +61,68 @@ class RoomMemberModerationPresenter @Inject constructor(
remember { mutableStateOf(AsyncAction.Uninitialized as AsyncAction<Unit>) }
val unbanUserAsyncAction =
remember { mutableStateOf(AsyncAction.Uninitialized as AsyncAction<Unit>) }
var selectedUser by remember {
mutableStateOf<MatrixUser?>(null)
}
val moderationActions = remember { mutableStateOf(persistentListOf<ModerationAction>()) }
fun handleEvent(event: RoomMemberModerationEvents) {
when (event) {
is RoomMemberModerationEvents.RenderActions -> {
selectedMember = AsyncData.Success(event.roomMember)
moderationActions.value = computeModerationActions(
member = event.roomMember,
canKick = canKick.value,
canBan = canBan.value,
currentUserMemberPowerLevel = currentUserMemberPowerLevel.value,
)
is RoomMemberModerationEvents.ShowActionsForUser -> {
coroutineScope.launch {
selectedUser = event.user
moderationActions.value = persistentListOf(ModerationAction.DisplayProfile(event.user))
room.getUpdatedMember(event.user.userId)
.onSuccess {
moderationActions.value = computeModerationActions(
member = it,
canKick = canKick.value,
canBan = canBan.value,
currentUserMemberPowerLevel = currentUserMemberPowerLevel.value,
)
}
}
}
is RoomMemberModerationEvents.ProcessAction -> {
when(val action = event.action) {
when (val action = event.action) {
is ModerationAction.DisplayProfile -> Unit
is ModerationAction.KickUser -> {
selectedMember = AsyncData.Success(action.member)
selectedUser = action.user
kickUserAsyncAction.value = AsyncAction.ConfirmingNoParams
}
is ModerationAction.BanUser -> {
selectedMember = AsyncData.Success(action.member)
selectedUser = action.user
banUserAsyncAction.value = AsyncAction.ConfirmingNoParams
}
is ModerationAction.UnbanUser -> {
selectedMember = AsyncData.Success(action.member)
selectedUser = action.user
unbanUserAsyncAction.value = AsyncAction.ConfirmingNoParams
}
}
}
is InternalRoomMemberModerationEvents.DoKickUser -> {
selectedMember.dataOrNull()?.let {
selectedUser?.let {
coroutineScope.kickUser(it.userId, event.reason, kickUserAsyncAction)
}
selectedMember = AsyncData.Uninitialized
selectedUser = null
}
is InternalRoomMemberModerationEvents.DoBanUser -> {
selectedMember.dataOrNull()?.let {
selectedUser?.let {
coroutineScope.banUser(it.userId, event.reason, banUserAsyncAction)
}
selectedMember = AsyncData.Uninitialized
selectedUser = null
}
is InternalRoomMemberModerationEvents.Reset -> {
selectedMember = AsyncData.Uninitialized
selectedUser = null
kickUserAsyncAction.value = AsyncAction.Uninitialized
banUserAsyncAction.value = AsyncAction.Uninitialized
unbanUserAsyncAction.value = AsyncAction.Uninitialized
}
is InternalRoomMemberModerationEvents.DoUnbanUser -> {
selectedMember.dataOrNull()?.let {
selectedUser?.let {
coroutineScope.unbanUser(it.userId, unbanUserAsyncAction)
}
selectedMember = AsyncData.Uninitialized
selectedUser = null
}
}
}
@@ -122,7 +130,7 @@ class RoomMemberModerationPresenter @Inject constructor(
return InternalRoomMemberModerationState(
canKick = canKick.value,
canBan = canBan.value,
selectedRoomMember = selectedMember,
selectedUser = selectedUser,
actions = moderationActions.value,
kickUserAsyncAction = kickUserAsyncAction.value,
banUserAsyncAction = banUserAsyncAction.value,
@@ -137,13 +145,15 @@ class RoomMemberModerationPresenter @Inject constructor(
canBan: Boolean,
currentUserMemberPowerLevel: Long,
): PersistentList<ModerationAction> {
val memberAsUser = member.toMatrixUser()
return buildList {
add(ModerationAction.DisplayProfile(member))
if (canKick && member.powerLevel < currentUserMemberPowerLevel) {
add(ModerationAction.KickUser(member))
add(ModerationAction.DisplayProfile(memberAsUser))
val canModerateThisUser = member.powerLevel < currentUserMemberPowerLevel && member.membership.isActive()
if (canKick && canModerateThisUser) {
add(ModerationAction.KickUser(memberAsUser))
}
if (canBan && member.powerLevel < currentUserMemberPowerLevel) {
add(ModerationAction.BanUser(member))
if (canBan && canModerateThisUser) {
add(ModerationAction.BanUser(memberAsUser))
}
}.toPersistentList()
}
@@ -194,4 +204,5 @@ class RoomMemberModerationPresenter @Inject constructor(
}
}
}
}

View File

@@ -15,26 +15,28 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.toPersistentList
class RoomMemberModerationStateProvider : PreviewParameterProvider<InternalRoomMemberModerationState> {
override val values: Sequence<InternalRoomMemberModerationState>
get() = sequenceOf(
aRoomMembersModerationState(
selectedRoomMember = AsyncData.Success(anAlice()),
selectedUser = anAlice(),
actions = listOf(
ModerationAction.DisplayProfile(anAlice()),
),
),
aRoomMembersModerationState(
selectedRoomMember = AsyncData.Success(anAlice()),
selectedUser = anAlice(),
actions = listOf(
ModerationAction.DisplayProfile(anAlice()),
ModerationAction.KickUser(anAlice()),
),
),
aRoomMembersModerationState(
selectedRoomMember = AsyncData.Success(anAlice()),
selectedUser = anAlice(),
actions = listOf(
ModerationAction.DisplayProfile(anAlice()),
ModerationAction.KickUser(anAlice()),
@@ -42,41 +44,34 @@ class RoomMemberModerationStateProvider : PreviewParameterProvider<InternalRoomM
),
),
aRoomMembersModerationState(
selectedRoomMember = AsyncData.Success(anAlice()),
selectedUser = anAlice(),
kickUserAsyncAction = AsyncAction.ConfirmingNoParams,
),
aRoomMembersModerationState(
selectedRoomMember = AsyncData.Success(anAlice()),
selectedUser = anAlice(),
kickUserAsyncAction = AsyncAction.Loading,
),
aRoomMembersModerationState(
selectedRoomMember = AsyncData.Success(anAlice()),
selectedUser = anAlice(),
banUserAsyncAction = AsyncAction.ConfirmingNoParams,
),
aRoomMembersModerationState(
selectedRoomMember = AsyncData.Success(anAlice()),
selectedUser = anAlice(),
banUserAsyncAction = AsyncAction.Loading,
),
)
}
fun anAlice() = RoomMember(
fun anAlice() = MatrixUser(
UserId(value = "@alice:server.org"),
displayName = "Alice",
avatarUrl = null,
role = RoomMember.Role.forPowerLevel(100L),
membership = RoomMembershipState.JOIN,
isNameAmbiguous = false,
powerLevel = 100L,
normalizedPowerLevel = 100L,
isIgnored = false,
membershipChangeReason = null,
)
fun aRoomMembersModerationState(
canKick: Boolean = false,
canBan: Boolean = false,
selectedRoomMember: AsyncData<RoomMember> = AsyncData.Uninitialized,
selectedUser: MatrixUser? = null,
actions: List<ModerationAction> = emptyList(),
kickUserAsyncAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
banUserAsyncAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
@@ -85,7 +80,7 @@ fun aRoomMembersModerationState(
) = InternalRoomMemberModerationState(
canKick = canKick,
canBan = canBan,
selectedRoomMember = selectedRoomMember,
selectedUser = selectedUser,
actions = actions.toPersistentList(),
kickUserAsyncAction = kickUserAsyncAction,
banUserAsyncAction = banUserAsyncAction,

View File

@@ -20,9 +20,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -49,9 +47,9 @@ import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.getBestName
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch
@@ -63,23 +61,27 @@ fun RoomMemberModerationView(
onSelectAction: (ModerationAction) -> Unit,
modifier: Modifier = Modifier,
) {
val selectedRoomMember = state.selectedRoomMember.dataOrNull()
Box(modifier = modifier) {
if (selectedRoomMember != null && state.canDisplayActions) {
val selectedUser = state.selectedUser
if (selectedUser != null && state.canDisplayActions) {
RoomMemberActionsBottomSheet(
roomMember = selectedRoomMember,
user = selectedUser,
actions = state.actions,
onSelectAction = onSelectAction,
onDismiss = { state.eventSink(InternalRoomMemberModerationEvents.Reset) },
)
}
val onSelectAction by rememberUpdatedState(onSelectAction)
LaunchedEffect(state.canOnlyDisplayProfile) {
if (state.canOnlyDisplayProfile) {
onSelectAction(state.actions.first())
}
}
RoomMemberAsyncActions(state = state)
}
}
@Composable
private fun RoomMemberAsyncActions(
state: InternalRoomMemberModerationState,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
val selectedUser = state.selectedUser
val asyncIndicatorState = rememberAsyncIndicatorState()
AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), state = asyncIndicatorState)
@@ -100,7 +102,7 @@ fun RoomMemberModerationView(
}
is AsyncAction.Loading -> {
LaunchedEffect(action) {
val userDisplayName = selectedRoomMember?.getBestName().orEmpty()
val userDisplayName = selectedUser?.getBestName().orEmpty()
asyncIndicatorState.enqueue {
AsyncIndicator.Loading(text = stringResource(R.string.screen_bottom_sheet_manage_room_member_removing_user, userDisplayName))
}
@@ -139,7 +141,7 @@ fun RoomMemberModerationView(
}
is AsyncAction.Loading -> {
LaunchedEffect(action) {
val userDisplayName = selectedRoomMember?.getBestName().orEmpty()
val userDisplayName = selectedUser?.getBestName().orEmpty()
asyncIndicatorState.enqueue {
AsyncIndicator.Loading(text = stringResource(R.string.screen_bottom_sheet_manage_room_member_banning_user, userDisplayName))
}
@@ -167,7 +169,7 @@ fun RoomMemberModerationView(
content = stringResource(R.string.screen_room_member_list_manage_member_unban_message),
submitText = stringResource(R.string.screen_room_member_list_manage_member_unban_action),
onSubmitClick = {
val userDisplayName = selectedRoomMember?.getBestName().orEmpty()
val userDisplayName = selectedUser?.getBestName().orEmpty()
asyncIndicatorState.enqueue {
AsyncIndicator.Loading(text = stringResource(R.string.screen_room_member_list_unbanning_user, userDisplayName))
}
@@ -198,7 +200,7 @@ fun RoomMemberModerationView(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoomMemberActionsBottomSheet(
roomMember: RoomMember,
user: MatrixUser,
actions: ImmutableList<ModerationAction>,
onSelectAction: (ModerationAction) -> Unit,
onDismiss: () -> Unit,
@@ -219,12 +221,12 @@ private fun RoomMemberActionsBottomSheet(
modifier = Modifier.padding(vertical = 16.dp)
) {
Avatar(
avatarData = roomMember.getAvatarData(size = AvatarSize.RoomListManageUser),
avatarData = user.getAvatarData(size = AvatarSize.RoomListManageUser),
modifier = Modifier
.padding(bottom = 28.dp)
.align(Alignment.CenterHorizontally)
)
roomMember.displayName?.let {
user.displayName?.let {
Text(
text = it,
style = ElementTheme.typography.fontHeadingLgBold,
@@ -237,7 +239,7 @@ private fun RoomMemberActionsBottomSheet(
)
}
Text(
text = roomMember.userId.toString(),
text = user.userId.toString(),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,

View File

@@ -44,6 +44,13 @@ fun ProfileTimelineDetails.getDisambiguatedDisplayName(userId: UserId): String {
}
}
fun ProfileTimelineDetails.getDisplayName(): String? {
return when (this) {
is ProfileTimelineDetails.Ready -> displayName
else -> null
}
}
fun ProfileTimelineDetails.getAvatarUrl(): String? {
return when (this) {
is ProfileTimelineDetails.Ready -> avatarUrl