Merge pull request #5879 from element-hq/feature/fga/room_permissions_rework

misc : rework power levels apis
This commit is contained in:
ganfra 2025-12-15 12:19:54 +01:00 committed by GitHub
commit 65c3188b7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 1075 additions and 1054 deletions

View file

@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.knockrequests.api
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
data class KnockRequestPermissions(
val canAccept: Boolean,
val canDecline: Boolean,
val canBan: Boolean,
) {
val hasAny = canAccept || canDecline || canBan
companion object {
val DEFAULT = KnockRequestPermissions(
canAccept = false,
canDecline = false,
canBan = false,
)
}
}
fun RoomPermissions.knockRequestPermissions(): KnockRequestPermissions {
return KnockRequestPermissions(
canAccept = canOwnUserInvite(),
canDecline = canOwnUserKick(),
canBan = canOwnUserBan(),
)
}

View file

@ -49,7 +49,7 @@ class KnockRequestsBannerPresenter(
val shouldShowBanner by remember {
derivedStateOf {
permissions.canHandle && knockRequests.isNotEmpty()
permissions.hasAny && knockRequests.isNotEmpty()
}
}

View file

@ -1,33 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.knockrequests.impl.data
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.powerlevels.canBan
import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
import io.element.android.libraries.matrix.api.room.powerlevels.canKick
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
data class KnockRequestPermissions(
val canAccept: Boolean,
val canDecline: Boolean,
val canBan: Boolean,
) {
val canHandle = canAccept || canDecline || canBan
}
fun JoinedRoom.knockRequestPermissionsFlow(): Flow<KnockRequestPermissions> {
return syncUpdateFlow.map {
val canAccept = canInvite().getOrDefault(false)
val canDecline = canKick().getOrDefault(false)
val canBan = canBan().getOrDefault(false)
KnockRequestPermissions(canAccept, canDecline, canBan)
}
}

View file

@ -12,10 +12,13 @@ import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import io.element.android.features.knockrequests.api.KnockRequestPermissions
import io.element.android.features.knockrequests.api.knockRequestPermissions
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsFlow
@BindingContainer
@ContributesTo(RoomScope::class)
@ -25,7 +28,9 @@ object KnockRequestsModule {
fun knockRequestsService(room: JoinedRoom, featureFlagService: FeatureFlagService): KnockRequestsService {
return KnockRequestsService(
knockRequestsFlow = room.knockRequestsFlow,
permissionsFlow = room.knockRequestPermissionsFlow(),
permissionsFlow = room.permissionsFlow(KnockRequestPermissions.DEFAULT) { perms ->
perms.knockRequestPermissions()
},
isKnockFeatureEnabledFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock),
coroutineScope = room.roomCoroutineScope
)

View file

@ -8,6 +8,7 @@
package io.element.android.features.knockrequests.impl.data
import io.element.android.features.knockrequests.api.KnockRequestPermissions
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.knock.KnockRequest

View file

@ -9,7 +9,7 @@
package io.element.android.features.knockrequests.impl.list
import androidx.compose.runtime.Immutable
import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions
import io.element.android.features.knockrequests.api.KnockRequestPermissions
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData

View file

@ -9,7 +9,7 @@
package io.element.android.features.knockrequests.impl.list
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions
import io.element.android.features.knockrequests.api.KnockRequestPermissions
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable
import io.element.android.libraries.architecture.AsyncAction

View file

@ -9,7 +9,7 @@
package io.element.android.features.knockrequests.impl.banner
import com.google.common.truth.Truth.assertThat
import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions
import io.element.android.features.knockrequests.api.KnockRequestPermissions
import io.element.android.features.knockrequests.impl.data.KnockRequestsService
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import io.element.android.libraries.matrix.test.A_USER_ID

View file

@ -11,7 +11,7 @@
package io.element.android.features.knockrequests.impl.list
import com.google.common.truth.Truth.assertThat
import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions
import io.element.android.features.knockrequests.api.KnockRequestPermissions
import io.element.android.features.knockrequests.impl.data.KnockRequestsService
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData

View file

@ -12,12 +12,10 @@ import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
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.saveable.rememberSaveable
@ -76,14 +74,10 @@ import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.getAvatarData
@ -170,7 +164,9 @@ class MessagesPresenter(
val roomCallState = roomCallStatePresenter.present()
val roomMemberModerationState = roomMemberModerationPresenter.present()
val userEventPermissions by userEventPermissions(roomInfo)
val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
perms.userEventPermissions()
}
val roomAvatar by remember {
derivedStateOf { roomInfo.avatarData() }
@ -301,24 +297,6 @@ class MessagesPresenter(
)
}
@Composable
private fun userEventPermissions(roomInfo: RoomInfo): State<UserEventPermissions> {
val key = if (roomInfo.privilegedCreatorRole && roomInfo.creators.contains(room.sessionId)) {
Long.MAX_VALUE
} else {
roomInfo.roomPowerLevels?.hashCode() ?: 0L
}
return produceState(UserEventPermissions.DEFAULT, key1 = key) {
value = UserEventPermissions(
canSendMessage = room.canSendMessage(type = MessageEventType.RoomMessage).getOrElse { true },
canSendReaction = room.canSendMessage(type = MessageEventType.Reaction).getOrElse { true },
canRedactOwn = room.canRedactOwn().getOrElse { false },
canRedactOther = room.canRedactOther().getOrElse { false },
canPinUnpin = room.canPinUnpin().getOrElse { false },
)
}
}
private fun RoomInfo.avatarData(): AvatarData {
return AvatarData(
id = id.value,

View file

@ -41,6 +41,7 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationPermissions
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@ -164,11 +165,9 @@ fun aMessagesState(
)
fun aRoomMemberModerationState(
canKick: Boolean = false,
canBan: Boolean = false,
permissions: RoomMemberModerationPermissions = RoomMemberModerationPermissions.DEFAULT,
) = object : RoomMemberModerationState {
override val canKick: Boolean = canKick
override val canBan: Boolean = canBan
override val permissions: RoomMemberModerationPermissions = permissions
override val eventSink: (RoomMemberModerationEvents) -> Unit = {}
}

View file

@ -8,6 +8,9 @@
package io.element.android.features.messages.impl
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
/**
* Represents the permissions a user has in a room.
* It's dependent of the user's power level in the room.
@ -29,3 +32,13 @@ data class UserEventPermissions(
)
}
}
fun RoomPermissions.userEventPermissions(): UserEventPermissions {
return UserEventPermissions(
canRedactOwn = canOwnUserRedactOwn(),
canRedactOther = canOwnUserRedactOther(),
canSendMessage = canOwnUserSendMessage(MessageEventType.RoomMessage),
canSendReaction = canOwnUserSendMessage(MessageEventType.Reaction),
canPinUnpin = canOwnUserPinUnpin()
)
}

View file

@ -55,6 +55,7 @@ import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.getDirectRoomMember
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.use
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
@ -98,6 +99,7 @@ import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
@Suppress("LargeClass")
@AssistedInject
class MessageComposerPresenter(
@Assisted private val navigator: MessagesNavigator,
@ -396,7 +398,9 @@ class MessageComposerPresenter(
val currentUserId = room.sessionId
suspend fun canSendRoomMention(): Boolean {
val userCanSendAtRoom = room.canUserTriggerRoomNotification(currentUserId).getOrDefault(false)
val userCanSendAtRoom = room.roomPermissions().use(false) { perms ->
perms.canOwnUserTriggerRoomNotification()
}
return !room.isDm() && userCanSendAtRoom
}

View file

@ -10,11 +10,10 @@ package io.element.android.features.messages.impl.pinned.list
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
@ -35,6 +34,7 @@ import io.element.android.features.messages.impl.timeline.factories.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.userEventPermissions
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@ -44,11 +44,9 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.ui.room.isDmAsState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@ -97,31 +95,33 @@ class PinnedMessagesListPresenter(
@Composable
override fun present(): PinnedMessagesListState {
htmlConverterProvider.Update()
val isDm by room.isDmAsState()
val timelineRoomInfo = remember(isDm) {
TimelineRoomInfo(
isDm = isDm,
name = room.info().name,
// We don't need to compute those values
userHasPermissionToSendMessage = false,
userHasPermissionToSendReaction = false,
// We do not care about the call state here.
roomCallState = aStandByCallState(),
// don't compute this value or the pin icon will be shown
pinnedEventIds = persistentListOf(),
typingNotificationState = TypingNotificationState(
renderTypingNotifications = false,
typingMembers = persistentListOf(),
reserveSpace = false,
),
predecessorRoom = room.predecessorRoom(),
)
val roomInfo by room.roomInfoFlow.collectAsState()
val timelineRoomInfo by remember {
derivedStateOf {
TimelineRoomInfo(
isDm = roomInfo.isDm,
name = roomInfo.name,
// We don't need to compute those values
userHasPermissionToSendMessage = false,
userHasPermissionToSendReaction = false,
// We do not care about the call state here.
roomCallState = aStandByCallState(),
// don't compute this value or the pin icon will be shown
pinnedEventIds = persistentListOf(),
typingNotificationState = TypingNotificationState(
renderTypingNotifications = false,
typingMembers = persistentListOf(),
reserveSpace = false,
),
predecessorRoom = room.predecessorRoom(),
)
}
}
val timelineProtectionState = timelineProtectionPresenter.present()
val linkState = linkPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
perms.userEventPermissions()
}
val displayThreadSummaries by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false)
@ -192,19 +192,6 @@ class PinnedMessagesListPresenter(
}
}
@Composable
private fun userEventPermissions(updateKey: Long): State<UserEventPermissions> {
return produceState(UserEventPermissions.DEFAULT, key1 = updateKey) {
value = UserEventPermissions(
canSendMessage = false,
canSendReaction = false,
canRedactOwn = room.canRedactOwn().getOrElse { false },
canRedactOther = room.canRedactOther().getOrElse { false },
canPinUnpin = room.canPinUnpin().getOrElse { false },
)
}
}
@Composable
private fun PinnedMessagesListEffect(onItemsChange: (AsyncData<ImmutableList<TimelineItem>>) -> Unit) {
val updatedOnItemsChange by rememberUpdatedState(onItemsChange)

View file

@ -24,6 +24,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvents
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
@ -32,12 +33,12 @@ import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.userEventPermissions
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
@ -46,14 +47,13 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.asEventId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationTapOpensTimeline
@ -95,6 +95,7 @@ class TimelinePresenter(
private val analyticsService: AnalyticsService,
) : Presenter<TimelineState> {
private val tag = "TimelinePresenter"
@AssistedFactory
interface Factory {
fun create(
@ -128,11 +129,6 @@ class TimelinePresenter(
val roomInfo by room.roomInfoFlow.collectAsState()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.RoomMessage, updateKey = syncUpdateFlow.value)
val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.Reaction, updateKey = syncUpdateFlow.value)
val prevMostRecentItemId = rememberSaveable { mutableStateOf<UniqueId?>(null) }
val newEventState = remember { mutableStateOf(NewEventState.None) }
@ -285,13 +281,16 @@ class TimelinePresenter(
val typingNotificationState = typingNotificationPresenter.present()
val roomCallState = roomCallStatePresenter.present()
val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
perms.userEventPermissions()
}
val timelineRoomInfo by remember(typingNotificationState, roomCallState, roomInfo) {
derivedStateOf {
TimelineRoomInfo(
name = roomInfo.name,
isDm = roomInfo.isDm.orFalse(),
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToSendReaction = userHasPermissionToSendReaction,
isDm = roomInfo.isDm,
userHasPermissionToSendMessage = userEventPermissions.canSendMessage,
userHasPermissionToSendReaction = userEventPermissions.canSendReaction,
roomCallState = roomCallState,
pinnedEventIds = roomInfo.pinnedEventIds,
typingNotificationState = typingNotificationState,

View file

@ -64,6 +64,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.MessageEventType
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.StateEventType
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
@ -86,6 +87,7 @@ import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aTimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
@ -143,11 +145,7 @@ class MessagesPresenterTest {
fun `present - check that the room's unread flag is removed`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
markAsReadResult = { lambdaError() }
),
typingNoticeResult = { Result.success(Unit) },
@ -173,11 +171,7 @@ class MessagesPresenterTest {
}
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
),
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) },
@ -223,11 +217,7 @@ class MessagesPresenterTest {
}
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
),
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) },
@ -288,11 +278,7 @@ class MessagesPresenterTest {
val event = aMessageEvent()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
eventPermalinkResult = { Result.success("a link") },
),
typingNoticeResult = { Result.success(Unit) },
@ -514,11 +500,7 @@ class MessagesPresenterTest {
val liveTimeline = FakeTimeline()
val joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
),
liveTimeline = liveTimeline,
typingNoticeResult = { Result.success(Unit) },
@ -586,11 +568,7 @@ class MessagesPresenterTest {
fun `present - shows prompt to reinvite users in DM`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(isDirect = true, joinedMembersCount = 1, activeMembersCount = 1))
},
@ -619,11 +597,7 @@ class MessagesPresenterTest {
fun `present - doesn't show reinvite prompt in non-direct room`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(isDirect = false, joinedMembersCount = 1, activeMembersCount = 1))
},
@ -645,11 +619,7 @@ class MessagesPresenterTest {
fun `present - doesn't show reinvite prompt if other party is present`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(isDirect = true, joinedMembersCount = 2, activeMembersCount = 2))
},
@ -672,11 +642,7 @@ class MessagesPresenterTest {
val inviteUserResult = lambdaRecorder { _: UserId -> Result.success(Unit) }
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
),
typingNoticeResult = { Result.success(Unit) },
inviteUserResult = inviteUserResult,
@ -707,11 +673,7 @@ class MessagesPresenterTest {
val inviteUserResult = lambdaRecorder { _: UserId -> Result.success(Unit) }
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
),
typingNoticeResult = { Result.success(Unit) },
inviteUserResult = inviteUserResult,
@ -744,11 +706,7 @@ class MessagesPresenterTest {
fun `present - handle reinviting other user when memberlist is not ready`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
),
typingNoticeResult = { Result.success(Unit) },
)
@ -769,11 +727,7 @@ class MessagesPresenterTest {
fun `present - handle reinviting other user when inviting fails`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
),
typingNoticeResult = { Result.success(Unit) },
inviteUserResult = { Result.failure(RuntimeException("Oops!")) },
@ -807,17 +761,7 @@ class MessagesPresenterTest {
fun `present - permission to post`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
canUserSendMessageResult = { _, messageEventType ->
when (messageEventType) {
MessageEventType.RoomMessage -> Result.success(true)
MessageEventType.Reaction -> Result.success(true)
else -> lambdaError()
}
},
roomPermissions = roomPermissions(),
),
typingNoticeResult = { Result.success(Unit) },
)
@ -833,17 +777,9 @@ class MessagesPresenterTest {
fun `present - no permission to post`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
canUserSendMessageResult = { _, messageEventType ->
when (messageEventType) {
MessageEventType.RoomMessage -> Result.success(false)
MessageEventType.Reaction -> Result.success(false)
else -> lambdaError()
}
},
roomPermissions = roomPermissions(
canSendMessage = false
),
),
typingNoticeResult = { Result.success(Unit) },
)
@ -859,11 +795,9 @@ class MessagesPresenterTest {
fun `present - permission to redact own`() = runTest {
val joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOtherResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(
canRedactOther = false
),
),
typingNoticeResult = { Result.success(Unit) },
)
@ -880,11 +814,9 @@ class MessagesPresenterTest {
fun `present - permission to redact other`() = runTest {
val joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canRedactOtherResult = { Result.success(true) },
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(
canRedactOwn = false
),
),
typingNoticeResult = { Result.success(Unit) },
)
@ -929,11 +861,7 @@ class MessagesPresenterTest {
val timeline = FakeTimeline()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
),
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) },
@ -973,11 +901,7 @@ class MessagesPresenterTest {
val analyticsService = FakeAnalyticsService()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
),
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) },
@ -1074,11 +998,7 @@ class MessagesPresenterTest {
}
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
),
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) },
@ -1115,11 +1035,7 @@ class MessagesPresenterTest {
val successorReason = "This room has been moved to a new location"
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(
successorRoom = SuccessorRoom(
roomId = successorRoomId,
@ -1143,11 +1059,7 @@ class MessagesPresenterTest {
fun `present - room without successor room has null successor info in state`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(successorRoom = null)
),
typingNoticeResult = { Result.success(Unit) },
@ -1165,11 +1077,13 @@ class MessagesPresenterTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
sessionId = A_SESSION_ID,
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = FakeRoomPermissions(
canSendState = { true },
canSendMessage = { true },
canRedactOther = true,
canRedactOwn = true,
canPinUnpin = true,
),
initialRoomInfo = aRoomInfo(isDirect = true, isEncrypted = true)
).apply {
givenRoomMembersState(RoomMembersState.Ready(persistentListOf(aRoomMember(userId = A_SESSION_ID), aRoomMember(userId = A_USER_ID_2))))
@ -1312,16 +1226,44 @@ class MessagesPresenterTest {
}
}
private fun roomPermissions(
canStartCall: Boolean = true,
canRedactOther: Boolean = true,
canRedactOwn: Boolean = true,
canSendMessage: Boolean = true,
canSendReaction: Boolean = true,
canPinUnpin: Boolean = true,
) = FakeRoomPermissions(
canSendState = { type ->
when (type) {
StateEventType.CALL_MEMBER -> canStartCall
else -> lambdaError()
}
},
canSendMessage = { type ->
when (type) {
MessageEventType.RoomMessage -> canSendMessage
MessageEventType.Reaction -> canSendReaction
else -> lambdaError()
}
},
canRedactOther = canRedactOther,
canRedactOwn = canRedactOwn,
canPinUnpin = canPinUnpin,
)
private fun TestScope.createMessagesPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
timeline: Timeline = FakeTimeline(),
joinedRoom: FakeJoinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = FakeRoomPermissions(
canSendState = { true },
canSendMessage = { true },
canRedactOther = true,
canRedactOwn = true,
canPinUnpin = true,
),
).apply {
givenRoomInfo(aRoomInfo(id = roomId, name = ""))
},

View file

@ -69,6 +69,7 @@ import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.mediapickers.api.PickerProvider
@ -991,9 +992,12 @@ class MessageComposerPresenterTest {
val invitedUser = aRoomMember(userId = A_USER_ID_3, membership = RoomMembershipState.INVITE)
val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN)
val david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN)
var canUserTriggerRoomNotificationResult = true
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(canUserTriggerRoomNotificationResult = { Result.success(canUserTriggerRoomNotificationResult) }),
baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions(
canTriggerRoomNotification = true,
)
),
typingNoticeResult = { Result.success(Unit) }
).apply {
givenRoomMembersState(
@ -1033,10 +1037,38 @@ class MessageComposerPresenterTest {
// If the suggestion isn't a mention, no suggestions are returned
initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Command, "")))
assertThat(awaitItem().suggestions).isEmpty()
}
}
// If user has no permission to send `@room` mentions, `RoomMemberSuggestion.Room` is not returned
canUserTriggerRoomNotificationResult = false
@Test
fun `present - room mention suggestions no permission`() = runTest {
val currentUser = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.JOIN)
val invitedUser = aRoomMember(userId = A_USER_ID_3, membership = RoomMembershipState.INVITE)
val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN)
val david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN)
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions(
canTriggerRoomNotification = false,
)
),
typingNoticeResult = { Result.success(Unit) }
).apply {
givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(currentUser, invitedUser, bob, david),
)
)
givenRoomInfo(aRoomInfo(isDirect = false))
}
val presenter = createPresenter(room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// An empty suggestion returns the joined members that are not the current user, but not the room
initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
skipItems(1)
assertThat(awaitItem().suggestions)
.containsExactly(ResolvedSuggestion.Member(bob), ResolvedSuggestion.Member(david))
}
@ -1049,7 +1081,9 @@ class MessageComposerPresenterTest {
val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN)
val david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN)
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(canUserTriggerRoomNotificationResult = { Result.success(true) }),
baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions(canTriggerRoomNotification = true),
),
typingNoticeResult = { Result.success(Unit) }
).apply {
givenRoomMembersState(
@ -1069,7 +1103,6 @@ class MessageComposerPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
// An empty suggestion returns the joined members that are not the current user, but not the room
initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
skipItems(1)

View file

@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.test.A_UNIQUE_ID
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aMessageContent
@ -55,9 +56,7 @@ class PinnedMessagesListPresenterTest {
fun `present - initial state`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}
@ -74,9 +73,7 @@ class PinnedMessagesListPresenterTest {
fun `present - timeline failure state`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
},
@ -95,9 +92,7 @@ class PinnedMessagesListPresenterTest {
fun `present - empty state`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf()))
},
@ -117,9 +112,7 @@ class PinnedMessagesListPresenterTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
},
@ -146,9 +139,7 @@ class PinnedMessagesListPresenterTest {
val analyticsService = FakeAnalyticsService()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
},
@ -194,9 +185,7 @@ class PinnedMessagesListPresenterTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
},
@ -225,9 +214,7 @@ class PinnedMessagesListPresenterTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
},
@ -256,9 +243,7 @@ class PinnedMessagesListPresenterTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
},
@ -295,6 +280,16 @@ class PinnedMessagesListPresenterTest {
)
}
private fun roomPermissions(
canRedactOther: Boolean = true,
canRedactOwn: Boolean = true,
canPinUnpin: Boolean = true,
) = FakeRoomPermissions(
canRedactOther = canRedactOther,
canRedactOwn = canRedactOwn,
canPinUnpin = canPinUnpin,
)
private fun TestScope.createPinnedMessagesListPresenter(
navigator: PinnedMessagesListNavigator = FakePinnedMessagesListNavigator(),
room: JoinedRoom = FakeJoinedRoom(),

View file

@ -8,8 +8,6 @@
package io.element.android.features.messages.impl.timeline
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
@ -35,6 +33,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.asEventId
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
@ -55,6 +54,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
@ -66,6 +66,7 @@ import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
@ -97,9 +98,7 @@ class TimelinePresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createTimelinePresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.timelineItems).isEmpty()
assertThat(initialState.isLive).isTrue()
@ -118,9 +117,7 @@ class TimelinePresenterTest {
this.paginateLambda = paginateLambda
}
val presenter = createTimelinePresenter(timeline = timeline)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS))
initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.FORWARDS))
@ -166,9 +163,6 @@ class TimelinePresenterTest {
)
val room = FakeJoinedRoom(
liveTimeline = timeline,
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
)
)
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = isSendPublicReadReceiptsEnabled)
val presenter = createTimelinePresenter(
@ -176,9 +170,7 @@ class TimelinePresenterTest {
room = room,
sessionPreferencesStore = sessionPreferencesStore,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
runCurrent()
@ -211,9 +203,7 @@ class TimelinePresenterTest {
this.sendReadReceiptLambda = sendReadReceiptsLambda
}
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
skipItems(1)
awaitItem().run {
eventSink.invoke(TimelineEvents.OnScrollFinished(1))
@ -252,9 +242,7 @@ class TimelinePresenterTest {
timeline = timeline,
sessionPreferencesStore = sessionPreferencesStore,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
skipItems(1)
awaitItem().run {
eventSink.invoke(TimelineEvents.OnScrollFinished(0))
@ -290,9 +278,7 @@ class TimelinePresenterTest {
this.sendReadReceiptLambda = sendReadReceiptsLambda
}
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
skipItems(1)
awaitItem().run {
eventSink.invoke(TimelineEvents.OnScrollFinished(1))
@ -320,9 +306,7 @@ class TimelinePresenterTest {
this.sendReadReceiptLambda = sendReadReceiptsLambda
}
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
skipItems(1)
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
@ -339,9 +323,7 @@ class TimelinePresenterTest {
markAsReadResult = { Result.success(Unit) },
)
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
assertThat(initialState.timelineItems.size).isEqualTo(0)
@ -390,9 +372,7 @@ class TimelinePresenterTest {
timelineItems = timelineItems,
)
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
assertThat(initialState.timelineItems.size).isEqualTo(0)
@ -446,9 +426,7 @@ class TimelinePresenterTest {
val presenter = createTimelinePresenter(
sendPollResponseAction = sendPollResponseAction,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.SelectPollAnswer(AN_EVENT_ID, "anAnswerId"))
}
@ -462,9 +440,7 @@ class TimelinePresenterTest {
val presenter = createTimelinePresenter(
endPollAction = endPollAction,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.EndPoll(AN_EVENT_ID))
}
@ -481,9 +457,7 @@ class TimelinePresenterTest {
val presenter = createTimelinePresenter(
messagesNavigator = navigator,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
awaitFirstItem().eventSink(TimelineEvents.EditPoll(AN_EVENT_ID))
onEditPollClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
}
@ -500,9 +474,7 @@ class TimelinePresenterTest {
),
redactedVoiceMessageManager = redactedVoiceMessageManager,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(0)
skipItems(2)
assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(1)
@ -528,16 +500,14 @@ class TimelinePresenterTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
threadRootIdForEventResult = { _ -> Result.success(null) },
),
)
val presenter = createTimelinePresenter(
room = room,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
awaitItem().also { state ->
@ -579,15 +549,13 @@ class TimelinePresenterTest {
)
),
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
threadRootIdForEventResult = { Result.success(null) },
),
),
timelineItemIndexer = timelineItemIndexer,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitFirstItem()
advanceUntilIdle()
@ -619,14 +587,12 @@ class TimelinePresenterTest {
),
createTimelineResult = { Result.failure(RuntimeException("An error")) },
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
threadRootIdForEventResult = { _ -> Result.success(null) },
),
)
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
awaitItem().also { state ->
@ -668,7 +634,7 @@ class TimelinePresenterTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
threadRootIdForEventResult = { _ -> Result.success(threadId) },
),
)
@ -679,9 +645,7 @@ class TimelinePresenterTest {
timeline = liveTimeline,
messagesNavigator = navigator,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
@ -729,7 +693,7 @@ class TimelinePresenterTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
threadRootIdForEventResult = { _ -> Result.success(threadId) },
),
)
@ -740,9 +704,7 @@ class TimelinePresenterTest {
timeline = liveTimeline,
messagesNavigator = navigator,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
@ -785,7 +747,7 @@ class TimelinePresenterTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
// Use a different thread id
threadRootIdForEventResult = { _ -> Result.success(A_THREAD_ID_2) },
),
@ -797,9 +759,7 @@ class TimelinePresenterTest {
timeline = liveTimeline,
messagesNavigator = navigator,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
@ -846,7 +806,7 @@ class TimelinePresenterTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
// The event is in the main timeline, not in a thread
threadRootIdForEventResult = { _ -> Result.success(null) },
),
@ -858,9 +818,7 @@ class TimelinePresenterTest {
timeline = liveTimeline,
messagesNavigator = navigator,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
@ -891,9 +849,7 @@ class TimelinePresenterTest {
fun `present - show shield hide shield`() = runTest {
val presenter = createTimelinePresenter()
val shield = aCriticalShield()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.messageShield).isNull()
initialState.eventSink(TimelineEvents.ShowShieldDialog(shield))
@ -929,7 +885,9 @@ class TimelinePresenterTest {
)
val room = FakeJoinedRoom(
liveTimeline = timeline,
baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }),
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
),
).apply {
givenRoomMembersState(RoomMembersState.Unknown)
}
@ -937,9 +895,7 @@ class TimelinePresenterTest {
val avatarUrl = "https://domain.com/avatar.jpg"
val presenter = createTimelinePresenter(timeline, room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = consumeItemsUntilPredicate(30.seconds) { it.timelineItems.isNotEmpty() }.last()
val event = initialState.timelineItems.first() as TimelineItem.Event
assertThat(event.senderAvatar.url).isNull()
@ -963,15 +919,13 @@ class TimelinePresenterTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
predecessorRoomResult = { predecessorRoom }
),
)
val presenter = createTimelinePresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.timelineRoomInfo.predecessorRoom).isNotNull()
assertThat(initialState.timelineRoomInfo.predecessorRoom?.roomId).isEqualTo(predecessorRoomId)
@ -982,14 +936,12 @@ class TimelinePresenterTest {
fun `present - timeline room info no predecessor`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
predecessorRoomResult = { null }
),
)
val presenter = createTimelinePresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.timelineRoomInfo.predecessorRoom).isNull()
}
@ -999,7 +951,7 @@ class TimelinePresenterTest {
fun `present - timeline event navigate to room`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
),
)
val onNavigateToRoomLambda = lambdaRecorder<RoomId, EventId?, List<String>, Unit> { _, _, _ -> }
@ -1025,11 +977,32 @@ class TimelinePresenterTest {
return awaitItem()
}
private fun roomPermissions(
canRedactOther: Boolean = false,
canRedactOwn: Boolean = true,
canSendMessage: Boolean = true,
canSendReaction: Boolean = true,
canPinUnpin: Boolean = false,
) = FakeRoomPermissions(
canSendMessage = { type ->
when (type) {
MessageEventType.RoomMessage -> canSendMessage
MessageEventType.Reaction -> canSendReaction
else -> lambdaError()
}
},
canRedactOther = canRedactOther,
canRedactOwn = canRedactOwn,
canPinUnpin = canPinUnpin,
)
private fun TestScope.createTimelinePresenter(
timeline: Timeline = FakeTimeline(),
room: FakeJoinedRoom = FakeJoinedRoom(
liveTimeline = timeline,
baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }),
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
),
),
redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(),

View file

@ -21,7 +21,8 @@ import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.ui.room.canCall
import io.element.android.libraries.matrix.api.room.powerlevels.canCall
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
@Inject
class RoomCallStatePresenter(
@ -35,8 +36,7 @@ class RoomCallStatePresenter(
value = sessionEnterpriseService.isElementCallAvailable()
}
val roomInfo by room.roomInfoFlow.collectAsState()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val canJoinCall by room.canCall(updateKey = syncUpdateFlow.value)
val canJoinCall by room.permissionsAsState(false) { perms -> perms.canCall() }
val isUserInTheCall by remember {
derivedStateOf {
room.sessionId in roomInfo.activeRoomCallParticipants

View file

@ -15,9 +15,12 @@ import io.element.android.features.call.test.FakeCurrentCallService
import io.element.android.features.enterprise.test.FakeSessionEnterpriseService
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.test
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
@ -28,7 +31,7 @@ class RoomCallStatePresenterTest {
fun `present - initial state`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(false) },
roomPermissions = roomPermissions(false),
)
)
val presenter = createRoomCallStatePresenter(joinedRoom = room)
@ -47,7 +50,7 @@ class RoomCallStatePresenterTest {
fun `present - element call not available`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(false) },
roomPermissions = roomPermissions(false),
)
)
val presenter = createRoomCallStatePresenter(
@ -66,7 +69,7 @@ class RoomCallStatePresenterTest {
fun `present - initial state - user can join call`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(true) },
roomPermissions = roomPermissions(true),
)
)
val presenter = createRoomCallStatePresenter(joinedRoom = room)
@ -85,7 +88,7 @@ class RoomCallStatePresenterTest {
fun `present - call is disabled if user cannot join it even if there is an ongoing call`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(false) },
roomPermissions = roomPermissions(false),
initialRoomInfo = aRoomInfo(hasRoomCall = true),
)
)
@ -106,7 +109,7 @@ class RoomCallStatePresenterTest {
fun `present - user has joined the call on another session`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(true) },
roomPermissions = roomPermissions(true),
).apply {
givenRoomInfo(
aRoomInfo(
@ -133,7 +136,7 @@ class RoomCallStatePresenterTest {
fun `present - user has joined the call locally`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(true) },
roomPermissions = roomPermissions(true),
).apply {
givenRoomInfo(
aRoomInfo(
@ -163,7 +166,7 @@ class RoomCallStatePresenterTest {
fun `present - user leaves the call`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(true) },
roomPermissions = roomPermissions(true),
).apply {
givenRoomInfo(
aRoomInfo(
@ -223,6 +226,17 @@ class RoomCallStatePresenterTest {
}
}
private fun roomPermissions(canJoinCall: Boolean): FakeRoomPermissions {
return FakeRoomPermissions(
canSendState = { stateEvent ->
when (stateEvent) {
StateEventType.CALL_MEMBER -> canJoinCall
else -> lambdaError()
}
}
)
}
private fun createRoomCallStatePresenter(
joinedRoom: JoinedRoom,
currentCallService: CurrentCallService = FakeCurrentCallService(),

View file

@ -10,6 +10,7 @@ package io.element.android.features.roomdetails.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@ -18,11 +19,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.knockrequests.api.knockRequestPermissions
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissionsAsState
import io.element.android.features.roomdetailsedit.api.RoomDetailsEditPermissions
import io.element.android.features.roomdetailsedit.api.roomDetailsEditPermissions
import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@ -36,17 +40,13 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
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.RoomMembersState
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
import io.element.android.libraries.matrix.api.room.powerlevels.canEditRolesAndPermissions
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.ui.room.canHandleKnockRequestsAsState
import io.element.android.libraries.matrix.ui.room.getCurrentRoomMember
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import io.element.android.libraries.matrix.ui.room.isDmAsState
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.ui.strings.CommonStrings
@ -77,8 +77,6 @@ class RoomDetailsPresenter(
val scope = rememberCoroutineScope()
val leaveRoomState = leaveRoomPresenter.present()
val roomInfo by room.roomInfoFlow.collectAsState()
val isUserAdmin = room.isOwnUserAdmin()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val roomAvatar by remember { derivedStateOf { roomInfo.avatarUrl } }
val roomName by remember { derivedStateOf { roomInfo.name?.trim().orEmpty() } }
@ -93,15 +91,11 @@ class RoomDetailsPresenter(
observeNotificationSettings()
}
val isDm = roomInfo.isDm
val membersState by room.membersStateFlow.collectAsState()
val canInvite by getCanInvite(membersState)
val permissions by getPermissions()
val canonicalAlias by remember { derivedStateOf { roomInfo.canonicalAlias } }
val isEncrypted by remember { derivedStateOf { roomInfo.isEncrypted == true } }
val isDm by room.isDmAsState()
val canEditName by getCanSendState(membersState, StateEventType.ROOM_NAME)
val canEditAvatar by getCanSendState(membersState, StateEventType.ROOM_AVATAR)
val canEditTopic by getCanSendState(membersState, StateEventType.ROOM_TOPIC)
val dmMember by room.getDirectRoomMember(membersState)
val currentMember by room.getCurrentRoomMember(membersState)
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
@ -109,16 +103,15 @@ class RoomDetailsPresenter(
val roomCallState = roomCallStatePresenter.present()
val joinedMemberCount by remember { derivedStateOf { roomInfo.joinedMembersCount } }
val topicState = remember(canEditTopic, roomTopic, roomType) {
val topicState = remember(permissions.editDetailsPermissions.canEditTopic, roomTopic, roomType) {
val topic = roomTopic
when {
!topic.isNullOrBlank() -> RoomTopicState.ExistingTopic(topic)
canEditTopic && roomType is RoomDetailsType.Room -> RoomTopicState.CanAddTopic
permissions.editDetailsPermissions.canEditTopic && roomType is RoomDetailsType.Room -> RoomTopicState.CanAddTopic
else -> RoomTopicState.Hidden
}
}
val canHandleKnockRequests by room.canHandleKnockRequestsAsState(syncUpdateFlow.value)
val isKnockRequestsEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
}.collectAsState(false)
@ -126,7 +119,7 @@ class RoomDetailsPresenter(
room.knockRequestsFlow.collect { value = it.size }
}
val canShowKnockRequests by remember {
derivedStateOf { isKnockRequestsEnabled && canHandleKnockRequests && joinRule == JoinRule.Knock }
derivedStateOf { isKnockRequestsEnabled && permissions.canManageKnockRequests && joinRule == JoinRule.Knock }
}
val isDeveloperModeEnabled by remember {
appPreferencesStore.isDeveloperModeEnabledFlow()
@ -162,13 +155,6 @@ class RoomDetailsPresenter(
val roomMemberDetailsState = roomMemberDetailsPresenter?.present()
val securityAndPrivacyPermissions = room.securityAndPrivacyPermissionsAsState(syncUpdateFlow.value)
val canShowSecurityAndPrivacy by remember {
derivedStateOf {
roomType is RoomDetailsType.Room && securityAndPrivacyPermissions.value.hasAny
}
}
val hasMemberVerificationViolations by produceState(false) {
room.roomMemberIdentityStateChange(waitForEncryption = true)
.onEach { identities -> value = identities.any { it.identityState == IdentityState.VerificationViolation } }
@ -185,22 +171,22 @@ class RoomDetailsPresenter(
roomTopic = topicState,
memberCount = joinedMemberCount,
isEncrypted = isEncrypted,
canInvite = canInvite,
canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room,
canInvite = permissions.canInvite,
canEdit = roomType == RoomDetailsType.Room && permissions.editDetailsPermissions.hasAny,
roomCallState = roomCallState,
roomType = roomType,
roomMemberDetailsState = roomMemberDetailsState,
leaveRoomState = leaveRoomState,
roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(),
isFavorite = isFavorite,
displayRolesAndPermissionsSettings = !isDm && isUserAdmin,
displayRolesAndPermissionsSettings = !isDm && permissions.canEditRolesAndPermissions,
isPublic = joinRule == JoinRule.Public,
heroes = roomInfo.heroes.toImmutableList(),
pinnedMessagesCount = pinnedMessagesCount,
snackbarMessage = snackbarMessage,
canShowKnockRequests = canShowKnockRequests,
knockRequestsCount = knockRequestsCount,
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy,
canShowSecurityAndPrivacy = !isDm && permissions.canEditSecurityAndPrivacy,
hasMemberVerificationViolations = hasMemberVerificationViolations,
canReportRoom = canReportRoom,
isTombstoned = roomInfo.successorRoom != null,
@ -232,14 +218,25 @@ class RoomDetailsPresenter(
}
}
@Composable
private fun getCanInvite(membersState: RoomMembersState) = produceState(false, membersState) {
value = room.canInvite().getOrElse { false }
}
private data class Permissions(
val canInvite: Boolean = false,
val editDetailsPermissions: RoomDetailsEditPermissions = RoomDetailsEditPermissions.DEFAULT,
val canManageKnockRequests: Boolean = false,
val canEditRolesAndPermissions: Boolean = false,
val canEditSecurityAndPrivacy: Boolean = false,
)
@Composable
private fun getCanSendState(membersState: RoomMembersState, type: StateEventType) = produceState(false, membersState) {
value = room.canSendState(type).getOrElse { false }
private fun getPermissions(): State<Permissions> {
return room.permissionsAsState(Permissions()) { perms ->
Permissions(
canInvite = perms.canOwnUserInvite(),
editDetailsPermissions = perms.roomDetailsEditPermissions(),
canManageKnockRequests = perms.knockRequestPermissions().hasAny,
canEditRolesAndPermissions = perms.canEditRolesAndPermissions(),
canEditSecurityAndPrivacy = perms.securityAndPrivacyPermissions().hasAny,
)
}
}
private fun CoroutineScope.observeNotificationSettings() {

View file

@ -32,10 +32,10 @@ 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.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
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.PowerLevelRoomMemberComparator
import io.element.android.libraries.matrix.ui.room.canInviteAsState
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
@ -58,8 +58,7 @@ class RoomMemberListPresenter(
override fun present(): RoomMemberListState {
var searchQuery by rememberSaveable { mutableStateOf("") }
val membersState by room.membersStateFlow.collectAsState()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val canInvite by room.canInviteAsState(syncUpdateFlow.value)
val canInvite by room.permissionsAsState(false) { perms -> perms.canOwnUserInvite() }
val roomModerationState = roomMembersModerationPresenter.present()
val roomMemberIdentityStates by produceState(persistentMapOf()) {

View file

@ -26,7 +26,7 @@ data class RoomMemberListState(
val moderationState: RoomMemberModerationState,
val eventSink: (RoomMemberListEvents) -> Unit,
) {
val showBannedSection: Boolean = moderationState.canBan && roomMembers.dataOrNull()?.banned?.isNotEmpty() == true
val showBannedSection: Boolean = moderationState.permissions.canBan && roomMembers.dataOrNull()?.banned?.isNotEmpty() == true
}
enum class SelectedSection {

View file

@ -10,6 +10,7 @@ package io.element.android.features.roomdetails.impl.members
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationPermissions
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.map
@ -99,8 +100,10 @@ fun aRoomMemberModerationState(
canKick: Boolean = false,
): RoomMemberModerationState {
return object : RoomMemberModerationState {
override val canKick: Boolean = canKick
override val canBan: Boolean = canBan
override val permissions: RoomMemberModerationPermissions = RoomMemberModerationPermissions(
canBan = canBan,
canKick = canKick,
)
override val eventSink: (RoomMemberModerationEvents) -> Unit = {}
}
}

View file

@ -13,8 +13,8 @@ import io.element.android.libraries.matrix.api.core.RoomId
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.RoomMember
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.libraries.matrix.test.A_ROOM_ID
@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.test.notificationsettings.FakeNotific
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.tests.testutils.lambda.lambdaError
fun aRoom(
@ -35,6 +36,7 @@ fun aRoom(
topic: String? = A_ROOM_TOPIC,
avatarUrl: String? = AN_AVATAR_URL,
canonicalAlias: RoomAlias? = A_ROOM_ALIAS,
roomPermissions: RoomPermissions = FakeRoomPermissions(),
isEncrypted: Boolean = true,
isPublic: Boolean = true,
isDirect: Boolean = false,
@ -42,29 +44,20 @@ fun aRoom(
activeMemberCount: Long = 1,
joinedMemberCount: Long = 1,
invitedMemberCount: Long = 0,
canInviteResult: (UserId) -> Result<Boolean> = { lambdaError() },
canBanResult: (UserId) -> Result<Boolean> = { lambdaError() },
canKickResult: (UserId) -> Result<Boolean> = { lambdaError() },
canSendStateResult: (UserId, StateEventType) -> Result<Boolean> = { _, _ -> lambdaError() },
userDisplayNameResult: (UserId) -> Result<String?> = { lambdaError() },
userAvatarUrlResult: () -> Result<String?> = { lambdaError() },
canUserJoinCallResult: (UserId) -> Result<Boolean> = { lambdaError() },
getUpdatedMemberResult: (UserId) -> Result<RoomMember> = { lambdaError() },
userRoleResult: () -> Result<RoomMember.Role> = { lambdaError() },
setIsFavoriteResult: (Boolean) -> Result<Unit> = { lambdaError() },
) = FakeBaseRoom(
sessionId = sessionId,
roomId = roomId,
canInviteResult = canInviteResult,
canBanResult = canBanResult,
canKickResult = canKickResult,
canSendStateResult = canSendStateResult,
userDisplayNameResult = userDisplayNameResult,
userAvatarUrlResult = userAvatarUrlResult,
canUserJoinCallResult = canUserJoinCallResult,
getUpdatedMemberResult = getUpdatedMemberResult,
userRoleResult = userRoleResult,
setIsFavoriteResult = setIsFavoriteResult,
roomPermissions = roomPermissions,
initialRoomInfo = aRoomInfo(
name = displayName,
rawName = rawName,
@ -89,6 +82,7 @@ fun aJoinedRoom(
topic: String? = A_ROOM_TOPIC,
avatarUrl: String? = AN_AVATAR_URL,
canonicalAlias: RoomAlias? = A_ROOM_ALIAS,
roomPermissions: RoomPermissions = FakeRoomPermissions(),
isEncrypted: Boolean = true,
isPublic: Boolean = true,
isDirect: Boolean = false,
@ -97,17 +91,12 @@ fun aJoinedRoom(
joinedMemberCount: Long = 1,
invitedMemberCount: Long = 0,
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
canInviteResult: (UserId) -> Result<Boolean> = { lambdaError() },
canBanResult: (UserId) -> Result<Boolean> = { lambdaError() },
canKickResult: (UserId) -> Result<Boolean> = { lambdaError() },
canSendStateResult: (UserId, StateEventType) -> Result<Boolean> = { _, _ -> lambdaError() },
userDisplayNameResult: (UserId) -> Result<String?> = { lambdaError() },
userAvatarUrlResult: () -> Result<String?> = { lambdaError() },
setNameResult: (String) -> Result<Unit> = { lambdaError() },
setTopicResult: (String) -> Result<Unit> = { lambdaError() },
updateAvatarResult: (String, ByteArray) -> Result<Unit> = { _, _ -> lambdaError() },
removeAvatarResult: () -> Result<Unit> = { lambdaError() },
canUserJoinCallResult: (UserId) -> Result<Boolean> = { lambdaError() },
getUpdatedMemberResult: (UserId) -> Result<RoomMember> = { lambdaError() },
userRoleResult: () -> Result<RoomMember.Role> = { lambdaError() },
kickUserResult: (UserId, String?) -> Result<Unit> = { _, _ -> lambdaError() },
@ -132,13 +121,9 @@ fun aJoinedRoom(
baseRoom = aRoom(
sessionId = sessionId,
roomId = roomId,
canInviteResult = canInviteResult,
canBanResult = canBanResult,
canKickResult = canKickResult,
canSendStateResult = canSendStateResult,
roomPermissions = roomPermissions,
userDisplayNameResult = userDisplayNameResult,
userAvatarUrlResult = userAvatarUrlResult,
canUserJoinCallResult = canUserJoinCallResult,
getUpdatedMemberResult = getUpdatedMemberResult,
userRoleResult = userRoleResult,
setIsFavoriteResult = setIsFavoriteResult,

View file

@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
@ -41,6 +42,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.services.analytics.api.AnalyticsService
@ -119,9 +121,7 @@ class RoomDetailsPresenterTest {
@Test
fun `present - initial state is created from initial room info`() = runTest {
val room = aJoinedRoom(
canInviteResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
)
val presenter = createRoomDetailsPresenter(room)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
@ -148,9 +148,7 @@ class RoomDetailsPresenterTest {
pinnedEventIds = listOf(AN_EVENT_ID),
)
val room = aJoinedRoom(
canInviteResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(roomInfo)
}
@ -170,9 +168,7 @@ class RoomDetailsPresenterTest {
fun `present - initial state with no room name`() = runTest {
val room = aJoinedRoom(
displayName = "",
canInviteResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
)
val presenter = createRoomDetailsPresenter(room)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
@ -188,9 +184,7 @@ class RoomDetailsPresenterTest {
val myRoomMember = aRoomMember(A_SESSION_ID)
val otherRoomMember = aRoomMember(A_USER_ID_2)
val room = aJoinedRoom(
canInviteResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
getUpdatedMemberResult = { userId ->
when (userId) {
A_SESSION_ID -> Result.success(myRoomMember)
@ -225,9 +219,9 @@ class RoomDetailsPresenterTest {
@Test
fun `present - initial state when user can invite others to room`() = runTest {
val room = aJoinedRoom(
canInviteResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(
canInvite = true,
),
)
val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
@ -243,26 +237,9 @@ class RoomDetailsPresenterTest {
@Test
fun `present - initial state when user can not invite others to room`() = runTest {
val room = aJoinedRoom(
canInviteResult = { Result.success(false) },
canKickResult = { Result.success(false) },
canBanResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
)
val presenter = createRoomDetailsPresenter(room)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
assertThat(awaitItem().canInvite).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - initial state when canInvite errors`() = runTest {
val room = aJoinedRoom(
canInviteResult = { Result.failure(RuntimeException("Whoops")) },
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(
canInvite = false,
),
)
val presenter = createRoomDetailsPresenter(room)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
@ -275,17 +252,11 @@ class RoomDetailsPresenterTest {
@Test
fun `present - initial state when user can edit one attribute`() = runTest {
val room = aJoinedRoom(
canSendStateResult = { _, stateEventType ->
when (stateEventType) {
StateEventType.ROOM_TOPIC -> Result.success(true)
StateEventType.ROOM_NAME -> Result.success(false)
else -> Result.failure(RuntimeException("Whelp"))
}
},
canBanResult = { Result.success(false) },
canKickResult = { Result.success(false) },
canInviteResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) },
roomPermissions = roomPermissions(
canChangeName = true,
canChangeTopic = false,
canChangeAvatar = false,
),
)
val presenter = createRoomDetailsPresenter(room)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
@ -303,18 +274,7 @@ class RoomDetailsPresenterTest {
val myRoomMember = aRoomMember(A_SESSION_ID)
val otherRoomMember = aRoomMember(A_USER_ID_2)
val room = aJoinedRoom(
canSendStateResult = { _, stateEventType ->
when (stateEventType) {
StateEventType.ROOM_TOPIC,
StateEventType.ROOM_NAME,
StateEventType.ROOM_AVATAR -> Result.success(true)
else -> Result.failure(RuntimeException("Whelp"))
}
},
canKickResult = { Result.success(false) },
canBanResult = { Result.success(false) },
canInviteResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) },
roomPermissions = roomPermissions(),
getUpdatedMemberResult = { userId ->
when (userId) {
A_SESSION_ID -> Result.success(myRoomMember)
@ -354,18 +314,9 @@ class RoomDetailsPresenterTest {
val room = aJoinedRoom(
isDirect = true,
topic = null,
canSendStateResult = { _, stateEventType ->
when (stateEventType) {
StateEventType.ROOM_AVATAR,
StateEventType.ROOM_TOPIC,
StateEventType.ROOM_NAME -> Result.success(true)
else -> Result.failure(RuntimeException("Whelp"))
}
},
roomPermissions = roomPermissions(),
userDisplayNameResult = { Result.success(A_USER_NAME) },
userAvatarUrlResult = { Result.success(AN_AVATAR_URL) },
canInviteResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
getUpdatedMemberResult = { userId ->
when (userId) {
A_SESSION_ID -> Result.success(myRoomMember)
@ -400,24 +351,11 @@ class RoomDetailsPresenterTest {
@Test
fun `present - initial state when user can edit all attributes`() = runTest {
val room = aJoinedRoom(
canSendStateResult = { _, stateEventType ->
when (stateEventType) {
StateEventType.ROOM_TOPIC,
StateEventType.ROOM_NAME,
StateEventType.ROOM_AVATAR -> Result.success(true)
else -> Result.failure(RuntimeException("Whelp"))
}
},
canKickResult = {
Result.success(false)
},
canBanResult = {
Result.success(false)
},
canInviteResult = {
Result.success(false)
},
canUserJoinCallResult = { Result.success(true) },
roomPermissions = roomPermissions(
canChangeAvatar = true,
canChangeName = true,
canChangeTopic = true,
),
)
val presenter = createRoomDetailsPresenter(room)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
@ -433,24 +371,11 @@ class RoomDetailsPresenterTest {
@Test
fun `present - initial state when user can edit no attributes`() = runTest {
val room = aJoinedRoom(
canSendStateResult = { _, stateEventType ->
when (stateEventType) {
StateEventType.ROOM_TOPIC,
StateEventType.ROOM_NAME,
StateEventType.ROOM_AVATAR -> Result.success(false)
else -> Result.failure(RuntimeException("Whelp"))
}
},
canBanResult = {
Result.success(false)
},
canKickResult = {
Result.success(false)
},
canInviteResult = {
Result.success(false)
},
canUserJoinCallResult = { Result.success(true) },
roomPermissions = roomPermissions(
canChangeAvatar = false,
canChangeName = false,
canChangeTopic = false,
),
)
val presenter = createRoomDetailsPresenter(room)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
@ -465,24 +390,9 @@ class RoomDetailsPresenterTest {
fun `present - topic state is hidden when no topic and user has no permission`() = runTest {
val room = aJoinedRoom(
topic = null,
canSendStateResult = { _, stateEventType ->
when (stateEventType) {
StateEventType.ROOM_AVATAR,
StateEventType.ROOM_NAME -> Result.success(true)
StateEventType.ROOM_TOPIC -> Result.success(false)
else -> Result.failure(RuntimeException("Whelp"))
}
},
canKickResult = {
Result.success(false)
},
canBanResult = {
Result.success(false)
},
canInviteResult = {
Result.success(false)
},
canUserJoinCallResult = { Result.success(true) },
roomPermissions = roomPermissions(
canChangeTopic = false
),
)
val presenter = createRoomDetailsPresenter(room)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
@ -497,24 +407,7 @@ class RoomDetailsPresenterTest {
fun `present - topic state is 'can add topic' when no topic and user has permission`() = runTest {
val room = aJoinedRoom(
topic = null,
canSendStateResult = { _, stateEventType ->
when (stateEventType) {
StateEventType.ROOM_AVATAR,
StateEventType.ROOM_TOPIC,
StateEventType.ROOM_NAME -> Result.success(true)
else -> Result.failure(RuntimeException("Whelp"))
}
},
canKickResult = {
Result.success(false)
},
canBanResult = {
Result.success(false)
},
canInviteResult = {
Result.success(false)
},
canUserJoinCallResult = { Result.success(true) },
roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(topic = null))
}
@ -534,9 +427,7 @@ class RoomDetailsPresenterTest {
fun `present - leave room event is passed on to leave room presenter`() = runTest {
val leaveRoomEventRecorder = EventsRecorder<LeaveRoomEvent>()
val room = aJoinedRoom(
canInviteResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
)
val presenter = createRoomDetailsPresenter(
room = room,
@ -555,9 +446,7 @@ class RoomDetailsPresenterTest {
val notificationSettingsService = FakeNotificationSettingsService()
val room = aJoinedRoom(
notificationSettingsService = notificationSettingsService,
canInviteResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
)
val presenter = createRoomDetailsPresenter(
room = room,
@ -584,9 +473,7 @@ class RoomDetailsPresenterTest {
FakeNotificationSettingsService(initialRoomMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
val room = aJoinedRoom(
notificationSettingsService = notificationSettingsService,
canInviteResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
)
val presenter = createRoomDetailsPresenter(
room = room,
@ -612,9 +499,7 @@ class RoomDetailsPresenterTest {
)
val room = aJoinedRoom(
notificationSettingsService = notificationSettingsService,
canInviteResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
)
val presenter = createRoomDetailsPresenter(
room = room,
@ -637,9 +522,7 @@ class RoomDetailsPresenterTest {
val setIsFavoriteResult = lambdaRecorder<Boolean, Result<Unit>> { _ -> Result.success(Unit) }
val room = aJoinedRoom(
setIsFavoriteResult = setIsFavoriteResult,
canInviteResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
)
val analyticsService = FakeAnalyticsService()
val presenter =
@ -665,9 +548,7 @@ class RoomDetailsPresenterTest {
@Test
fun `present - changes in room info updates the is favorite flag`() = runTest {
val room = aJoinedRoom(
canInviteResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
)
val presenter = createRoomDetailsPresenter(room = room)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
@ -686,9 +567,7 @@ class RoomDetailsPresenterTest {
@Test
fun `present - show knock requests`() = runTest {
val room = aJoinedRoom(
canInviteResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
joinRule = JoinRule.Knock,
)
val featureFlagService = FakeFeatureFlagService(
@ -712,9 +591,7 @@ class RoomDetailsPresenterTest {
@Test
fun `present - show security and privacy`() = runTest {
val room = aJoinedRoom(
canInviteResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
)
val featureFlagService = FakeFeatureFlagService()
val presenter = createRoomDetailsPresenter(room = room, featureFlagService = featureFlagService)
@ -729,9 +606,7 @@ class RoomDetailsPresenterTest {
@Test
fun `present - show debug info`() = runTest {
val room = aJoinedRoom(
canInviteResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
)
val inMemoryAppPreferencesStore = InMemoryAppPreferencesStore(
isDeveloperModeEnabled = true,
@ -744,4 +619,41 @@ class RoomDetailsPresenterTest {
}
}
}
private fun roomPermissions(
canInvite: Boolean = true,
canKick: Boolean = true,
canBan: Boolean = true,
canRedactOther: Boolean = true,
canRedactOwn: Boolean = true,
canChangeRoomAccess: Boolean = true,
canChangeHistoryVisibility: Boolean = true,
canChangeEncryption: Boolean = true,
canChangeRoomVisibility: Boolean = true,
canChangeName: Boolean = true,
canChangeTopic: Boolean = true,
canChangeAvatar: Boolean = true,
canChangePowerLevels: Boolean = true,
): RoomPermissions {
return FakeRoomPermissions(
canInvite = canInvite,
canKick = canKick,
canBan = canBan,
canRedactOther = canRedactOther,
canRedactOwn = canRedactOwn,
canSendState = { eventType ->
when (eventType) {
StateEventType.ROOM_JOIN_RULES -> canChangeRoomAccess
StateEventType.ROOM_HISTORY_VISIBILITY -> canChangeHistoryVisibility
StateEventType.ROOM_ENCRYPTION -> canChangeEncryption
StateEventType.ROOM_CANONICAL_ALIAS -> canChangeRoomVisibility
StateEventType.ROOM_AVATAR -> canChangeAvatar
StateEventType.ROOM_NAME -> canChangeName
StateEventType.ROOM_TOPIC -> canChangeTopic
StateEventType.ROOM_POWER_LEVELS -> canChangePowerLevels
else -> lambdaError()
}
}
)
}
}

View file

@ -12,7 +12,6 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.Presenter
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.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
@ -20,6 +19,7 @@ import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
@ -171,20 +171,7 @@ class RoomMemberListPresenterTest {
fun `present - asynchronously sets canInvite when user does not have correct power level`() = runTest {
val presenter = createPresenter(
joinedRoom = createFakeJoinedRoom(
canInviteResult = { Result.success(false) },
)
)
presenter.test {
val loadedState = awaitItem()
assertThat(loadedState.canInvite).isFalse()
}
}
@Test
fun `present - asynchronously sets canInvite when power level check fails`() = runTest {
val presenter = createPresenter(
joinedRoom = createFakeJoinedRoom(
canInviteResult = { Result.failure(RuntimeException("Eek")) },
canInvite = false,
)
)
presenter.test {
@ -207,12 +194,14 @@ class RoomMemberListPresenterTest {
private fun createFakeJoinedRoom(
updateMembersResult: () -> Unit = { },
canInviteResult: (UserId) -> Result<Boolean> = { Result.success(true) },
canInvite: Boolean = true,
): FakeJoinedRoom {
return FakeJoinedRoom(
baseRoom = FakeBaseRoom(
updateMembersResult = updateMembersResult,
canInviteResult = canInviteResult,
roomPermissions = FakeRoomPermissions(
canInvite = canInvite,
),
).apply {
// Needed to avoid discarding the loaded members as a partial and invalid result
givenRoomInfo(aRoomInfo(joinedMembersCount = 2))

View file

@ -0,0 +1,37 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetailsedit.api
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
data class RoomDetailsEditPermissions(
val canEditName: Boolean,
val canEditTopic: Boolean,
val canEditAvatar: Boolean,
) {
val hasAny = canEditName ||
canEditTopic ||
canEditAvatar
companion object {
val DEFAULT = RoomDetailsEditPermissions(
canEditName = false,
canEditTopic = false,
canEditAvatar = false,
)
}
}
fun RoomPermissions.roomDetailsEditPermissions(): RoomDetailsEditPermissions {
return RoomDetailsEditPermissions(
canEditName = canOwnUserSendState(StateEventType.ROOM_NAME),
canEditTopic = canOwnUserSendState(StateEventType.ROOM_TOPIC),
canEditAvatar = canOwnUserSendState(StateEventType.ROOM_AVATAR),
)
}

View file

@ -23,6 +23,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.core.net.toUri
import dev.zacsweers.metro.Inject
import io.element.android.features.roomdetailsedit.api.RoomDetailsEditPermissions
import io.element.android.features.roomdetailsedit.api.roomDetailsEditPermissions
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
@ -30,8 +32,7 @@ import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
@ -58,7 +59,6 @@ class RoomDetailsEditPresenter(
@Composable
override fun present(): RoomDetailsEditState {
val cameraPermissionState = cameraPermissionPresenter.present()
val roomSyncUpdateFlow = room.syncUpdateFlow.collectAsState()
val roomInfo by room.roomInfoFlow.collectAsState()
val roomAvatarUri = roomInfo.avatarUrl
var roomAvatarUriEdited by rememberSaveable { mutableStateOf<String?>(null) }
@ -93,14 +93,8 @@ class RoomDetailsEditPresenter(
}
}
var canChangeName by remember { mutableStateOf(false) }
var canChangeTopic by remember { mutableStateOf(false) }
var canChangeAvatar by remember { mutableStateOf(false) }
LaunchedEffect(roomSyncUpdateFlow.value) {
canChangeName = room.canSendState(StateEventType.ROOM_NAME).getOrElse { false }
canChangeTopic = room.canSendState(StateEventType.ROOM_TOPIC).getOrElse { false }
canChangeAvatar = room.canSendState(StateEventType.ROOM_AVATAR).getOrElse { false }
val permissions by room.permissionsAsState(RoomDetailsEditPermissions.DEFAULT) { perms ->
perms.roomDetailsEditPermissions()
}
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
@ -181,11 +175,11 @@ class RoomDetailsEditPresenter(
return RoomDetailsEditState(
roomId = room.roomId,
roomRawName = roomRawNameEdited,
canChangeName = canChangeName,
canChangeName = permissions.canEditName,
roomTopic = roomTopicEdited,
canChangeTopic = canChangeTopic,
canChangeTopic = permissions.canEditTopic,
roomAvatarUrl = roomAvatarUriEdited,
canChangeAvatar = canChangeAvatar,
canChangeAvatar = permissions.canEditAvatar,
avatarActions = avatarActions,
saveButtonEnabled = saveButtonEnabled,
saveAction = saveAction.value,

View file

@ -13,7 +13,6 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.mimetype.MimeTypes
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.StateEventType
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
@ -23,6 +22,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_TOPIC
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
@ -102,7 +102,6 @@ class RoomDetailsEditPresenterTest {
avatarUrl = AN_AVATAR_URL,
displayName = A_ROOM_NAME,
rawName = A_ROOM_RAW_NAME,
canSendStateResult = { _, _ -> Result.success(true) }
)
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createRoomDetailsEditPresenter(
@ -127,17 +126,15 @@ class RoomDetailsEditPresenterTest {
@Test
fun `present - sets canChangeName if user has permission`() = runTest {
val room = FakeJoinedRoom(
FakeBaseRoom(
canSendStateResult = { _, stateEventType ->
when (stateEventType) {
StateEventType.ROOM_NAME -> Result.success(true)
StateEventType.ROOM_AVATAR -> Result.success(false)
StateEventType.ROOM_TOPIC -> Result.failure(RuntimeException("Oops"))
else -> lambdaError()
}
},
)
val room = aJoinedRoom(
canSendState = { stateEventType ->
when (stateEventType) {
StateEventType.ROOM_NAME -> true
StateEventType.ROOM_AVATAR -> false
StateEventType.ROOM_TOPIC -> false
else -> lambdaError()
}
}
)
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createRoomDetailsEditPresenter(
@ -163,11 +160,11 @@ class RoomDetailsEditPresenterTest {
fun `present - sets canChangeAvatar if user has permission`() = runTest {
val room = aJoinedRoom(
avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, stateEventType ->
canSendState = { stateEventType ->
when (stateEventType) {
StateEventType.ROOM_NAME -> Result.success(false)
StateEventType.ROOM_AVATAR -> Result.success(true)
StateEventType.ROOM_TOPIC -> Result.failure(RuntimeException("Oops"))
StateEventType.ROOM_NAME -> false
StateEventType.ROOM_AVATAR -> true
StateEventType.ROOM_TOPIC -> false
else -> lambdaError()
}
}
@ -195,11 +192,11 @@ class RoomDetailsEditPresenterTest {
fun `present - sets canChangeTopic if user has permission`() = runTest {
val room = aJoinedRoom(
avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, stateEventType ->
canSendState = { stateEventType ->
when (stateEventType) {
StateEventType.ROOM_NAME -> Result.success(false)
StateEventType.ROOM_AVATAR -> Result.failure(RuntimeException("Oops"))
StateEventType.ROOM_TOPIC -> Result.success(true)
StateEventType.ROOM_NAME -> false
StateEventType.ROOM_AVATAR -> false
StateEventType.ROOM_TOPIC -> true
else -> lambdaError()
}
}
@ -229,7 +226,6 @@ class RoomDetailsEditPresenterTest {
topic = "My topic",
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, _ -> Result.success(true) }
)
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createRoomDetailsEditPresenter(
@ -274,7 +270,6 @@ class RoomDetailsEditPresenterTest {
topic = "My topic",
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, _ -> Result.success(true) }
)
fakePickerProvider.givenResult(anotherAvatarUri)
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
@ -298,7 +293,6 @@ class RoomDetailsEditPresenterTest {
topic = "My topic",
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, _ -> Result.success(true) }
)
fakePickerProvider.givenResult(anotherAvatarUri)
val fakePermissionsPresenter = FakePermissionsPresenter()
@ -339,7 +333,6 @@ class RoomDetailsEditPresenterTest {
topic = "My topic",
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, _ -> Result.success(true) }
)
fakePickerProvider.givenResult(roomAvatarUri)
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
@ -389,7 +382,6 @@ class RoomDetailsEditPresenterTest {
topic = null,
displayName = "fallback",
avatarUrl = null,
canSendStateResult = { _, _ -> Result.success(true) }
)
fakePickerProvider.givenResult(roomAvatarUri)
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
@ -445,7 +437,6 @@ class RoomDetailsEditPresenterTest {
setNameResult = setNameResult,
setTopicResult = setTopicResult,
removeAvatarResult = removeAvatarResult,
canSendStateResult = { _, _ -> Result.success(true) }
)
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createRoomDetailsEditPresenter(
@ -471,7 +462,6 @@ class RoomDetailsEditPresenterTest {
topic = "My topic",
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, _ -> Result.success(true) }
)
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createRoomDetailsEditPresenter(
@ -493,7 +483,6 @@ class RoomDetailsEditPresenterTest {
topic = null,
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, _ -> Result.success(true) }
)
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createRoomDetailsEditPresenter(
@ -515,7 +504,6 @@ class RoomDetailsEditPresenterTest {
topic = "My topic",
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, _ -> Result.success(true) }
)
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createRoomDetailsEditPresenter(
@ -539,7 +527,6 @@ class RoomDetailsEditPresenterTest {
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
updateAvatarResult = updateAvatarResult,
canSendStateResult = { _, _ -> Result.success(true) }
)
givenPickerReturnsFile()
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
@ -566,7 +553,6 @@ class RoomDetailsEditPresenterTest {
topic = "My topic",
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, _ -> Result.success(true) }
)
fakePickerProvider.givenResult(anotherAvatarUri)
fakeMediaPreProcessor.givenResult(Result.failure(RuntimeException("Oh no")))
@ -591,7 +577,6 @@ class RoomDetailsEditPresenterTest {
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
setNameResult = { Result.failure(RuntimeException("!")) },
canSendStateResult = { _, _ -> Result.success(true) }
)
saveAndAssertFailure(room, RoomDetailsEditEvent.UpdateRoomName("New name"), deleteCallbackNumberOfInvocation = 1)
}
@ -603,7 +588,6 @@ class RoomDetailsEditPresenterTest {
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
setTopicResult = { Result.failure(RuntimeException("!")) },
canSendStateResult = { _, _ -> Result.success(true) }
)
saveAndAssertFailure(room, RoomDetailsEditEvent.UpdateRoomTopic("New topic"), deleteCallbackNumberOfInvocation = 1)
}
@ -615,7 +599,6 @@ class RoomDetailsEditPresenterTest {
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
removeAvatarResult = { Result.failure(RuntimeException("!")) },
canSendStateResult = { _, _ -> Result.success(true) }
)
saveAndAssertFailure(room, RoomDetailsEditEvent.HandleAvatarAction(AvatarAction.Remove), deleteCallbackNumberOfInvocation = 2)
}
@ -628,7 +611,6 @@ class RoomDetailsEditPresenterTest {
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
updateAvatarResult = { _, _ -> Result.failure(RuntimeException("!")) },
canSendStateResult = { _, _ -> Result.success(true) }
)
saveAndAssertFailure(room, RoomDetailsEditEvent.HandleAvatarAction(AvatarAction.ChoosePhoto), deleteCallbackNumberOfInvocation = 2)
}
@ -641,7 +623,6 @@ class RoomDetailsEditPresenterTest {
displayName = "Name",
avatarUrl = AN_AVATAR_URL,
setTopicResult = { Result.failure(RuntimeException("!")) },
canSendStateResult = { _, _ -> Result.success(true) }
)
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createRoomDetailsEditPresenter(
@ -663,7 +644,6 @@ class RoomDetailsEditPresenterTest {
fun `present - leave without saving - cancel`() = runTest {
val room = aJoinedRoom(
displayName = "Name",
canSendStateResult = { _, _ -> Result.success(true) }
)
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createRoomDetailsEditPresenter(
@ -693,7 +673,6 @@ class RoomDetailsEditPresenterTest {
fun `present - leave no changes, no confirmation`() = runTest {
val room = aJoinedRoom(
displayName = "Name",
canSendStateResult = { _, _ -> Result.success(true) }
)
val presenter = createRoomDetailsEditPresenter(
room = room,
@ -711,7 +690,7 @@ class RoomDetailsEditPresenterTest {
fun `present - leave without saving - confirm`() = runTest {
val room = aJoinedRoom(
displayName = "Name",
canSendStateResult = { _, _ -> Result.success(true) }
canSendState = { _ -> true }
)
val presenter = createRoomDetailsEditPresenter(
room = room,
@ -782,11 +761,13 @@ class RoomDetailsEditPresenterTest {
setTopicResult: (String) -> Result<Unit> = { Result.success(Unit) },
updateAvatarResult: (String, ByteArray) -> Result<Unit> = { _, _ -> Result.success(Unit) },
removeAvatarResult: () -> Result<Unit> = { Result.success(Unit) },
canSendStateResult: (UserId, StateEventType) -> Result<Boolean>,
canSendState: (StateEventType) -> Boolean = { true },
): JoinedRoom {
return FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canSendStateResult = canSendStateResult,
roomPermissions = FakeRoomPermissions(
canSendState = canSendState,
),
initialRoomInfo = aRoomInfo(
name = displayName,
topic = topic,

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roommembermoderation.api
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
data class RoomMemberModerationPermissions(
val canKick: Boolean,
val canBan: Boolean,
) {
companion object {
val DEFAULT = RoomMemberModerationPermissions(
canKick = false,
canBan = false,
)
}
}
fun RoomPermissions.roomMemberModerationPermissions(): RoomMemberModerationPermissions {
return RoomMemberModerationPermissions(
canKick = canOwnUserKick(),
canBan = canOwnUserBan(),
)
}

View file

@ -12,8 +12,7 @@ import androidx.compose.runtime.Immutable
@Immutable
interface RoomMemberModerationState {
val canKick: Boolean
val canBan: Boolean
val permissions: RoomMemberModerationPermissions
val eventSink: (RoomMemberModerationEvents) -> Unit
}

View file

@ -10,14 +10,14 @@ package io.element.android.features.roommembermoderation.impl
import io.element.android.features.roommembermoderation.api.ModerationActionState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationPermissions
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncAction
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,
override val permissions: RoomMemberModerationPermissions,
val selectedUser: MatrixUser?,
val actions: ImmutableList<ModerationActionState>,
val kickUserAsyncAction: AsyncAction<Unit>,

View file

@ -12,6 +12,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roommembermoderation.api.ModerationAction
import io.element.android.features.roommembermoderation.api.ModerationActionState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationPermissions
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
@ -83,8 +84,7 @@ fun anAlice() = MatrixUser(
)
fun aRoomMembersModerationState(
canKick: Boolean = false,
canBan: Boolean = false,
permissions: RoomMemberModerationPermissions = RoomMemberModerationPermissions.DEFAULT,
selectedUser: MatrixUser? = null,
actions: List<ModerationActionState> = emptyList(),
kickUserAsyncAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
@ -92,8 +92,7 @@ fun aRoomMembersModerationState(
unbanUserAsyncAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (RoomMemberModerationEvents) -> Unit = {},
) = InternalRoomMemberModerationState(
canKick = canKick,
canBan = canBan,
permissions = permissions,
selectedUser = selectedUser,
actions = actions.toImmutableList(),
kickUserAsyncAction = kickUserAsyncAction,

View file

@ -21,7 +21,9 @@ import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.features.roommembermoderation.api.ModerationAction
import io.element.android.features.roommembermoderation.api.ModerationActionState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationPermissions
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.features.roommembermoderation.api.roomMemberModerationPermissions
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
@ -30,10 +32,9 @@ 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.RoomMembershipState
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.room.roomMembers
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
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
@ -56,8 +57,9 @@ class RoomMemberModerationPresenter(
override fun present(): RoomMemberModerationState {
val coroutineScope = rememberCoroutineScope()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val canBan = room.canBanAsState(syncUpdateFlow.value)
val canKick = room.canKickAsState(syncUpdateFlow.value)
val permissions by room.permissionsAsState(RoomMemberModerationPermissions.DEFAULT) { perms ->
perms.roomMemberModerationPermissions()
}
val currentUserMemberPowerLevel = room.userPowerLevelAsState(syncUpdateFlow.value)
val kickUserAsyncAction =
@ -80,8 +82,7 @@ class RoomMemberModerationPresenter(
}
moderationActions.value = computeModerationActions(
member = member,
canKick = canKick.value,
canBan = canBan.value,
permissions = permissions,
currentUserMemberPowerLevel = currentUserMemberPowerLevel.value,
)
}
@ -134,8 +135,7 @@ class RoomMemberModerationPresenter(
}
return InternalRoomMemberModerationState(
canKick = canKick.value,
canBan = canBan.value,
permissions = permissions,
selectedUser = selectedUser,
actions = moderationActions.value,
kickUserAsyncAction = kickUserAsyncAction.value,
@ -147,8 +147,7 @@ class RoomMemberModerationPresenter(
private fun computeModerationActions(
member: RoomMember?,
canKick: Boolean,
canBan: Boolean,
permissions: RoomMemberModerationPermissions,
currentUserMemberPowerLevel: Long,
): ImmutableList<ModerationActionState> {
return buildList {
@ -158,11 +157,11 @@ class RoomMemberModerationPresenter(
val canModerateThisUser = currentUserMemberPowerLevel > targetMemberPowerLevel
// Assume the member is joined when it's unknown
val membership = member?.membership ?: RoomMembershipState.JOIN
if (canKick) {
if (permissions.canKick) {
val isKickEnabled = canModerateThisUser && membership.isActive()
add(ModerationActionState(action = ModerationAction.KickUser, isEnabled = isKickEnabled))
}
if (canBan) {
if (permissions.canBan) {
if (membership == RoomMembershipState.BAN) {
add(ModerationActionState(action = ModerationAction.UnbanUser, isEnabled = canModerateThisUser))
} else {

View file

@ -13,6 +13,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.roommembermoderation.api.ModerationAction
import io.element.android.features.roommembermoderation.api.ModerationActionState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationPermissions
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@ -25,6 +26,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
@ -48,8 +50,7 @@ class RoomMemberModerationPresenterTest {
val room = aJoinedRoom()
createRoomMemberModerationPresenter(room = room).test {
val initialState = awaitState()
assertThat(initialState.canKick).isFalse()
assertThat(initialState.canBan).isFalse()
assertThat(initialState.permissions).isEqualTo(RoomMemberModerationPermissions.DEFAULT)
assertThat(initialState.selectedUser).isNull()
assertThat(initialState.banUserAsyncAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(initialState.kickUserAsyncAction).isEqualTo(AsyncAction.Uninitialized)
@ -355,8 +356,10 @@ class RoomMemberModerationPresenterTest {
banUserResult = { _, _ -> banUserResult },
unBanUserResult = { _, _ -> unBanUserResult },
baseRoom = FakeBaseRoom(
canBanResult = { _ -> Result.success(canBan) },
canKickResult = { _ -> Result.success(canKick) },
roomPermissions = FakeRoomPermissions(
canBan = canBan,
canKick = canKick
),
userRoleResult = { Result.success(myUserRole) },
updateMembersResult = { Result.success(Unit) }
),

View file

@ -8,13 +8,8 @@
package io.element.android.features.securityandprivacy.api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions.Companion.DEFAULT
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
data class SecurityAndPrivacyPermissions(
val canChangeRoomAccess: Boolean,
@ -37,14 +32,11 @@ data class SecurityAndPrivacyPermissions(
}
}
@Composable
fun BaseRoom.securityAndPrivacyPermissionsAsState(updateKey: Long): State<SecurityAndPrivacyPermissions> {
return produceState(DEFAULT, key1 = updateKey) {
value = SecurityAndPrivacyPermissions(
canChangeRoomAccess = canSendState(type = StateEventType.ROOM_JOIN_RULES).getOrElse { false },
canChangeHistoryVisibility = canSendState(type = StateEventType.ROOM_HISTORY_VISIBILITY).getOrElse { false },
canChangeEncryption = canSendState(type = StateEventType.ROOM_ENCRYPTION).getOrElse { false },
canChangeRoomVisibility = canSendState(type = StateEventType.ROOM_CANONICAL_ALIAS).getOrElse { false },
)
}
fun RoomPermissions.securityAndPrivacyPermissions(): SecurityAndPrivacyPermissions {
return SecurityAndPrivacyPermissions(
canChangeRoomAccess = canOwnUserSendState(StateEventType.ROOM_JOIN_RULES),
canChangeHistoryVisibility = canOwnUserSendState(StateEventType.ROOM_HISTORY_VISIBILITY),
canChangeEncryption = canOwnUserSendState(StateEventType.ROOM_ENCRYPTION),
canChangeRoomVisibility = canOwnUserSendState(StateEventType.ROOM_CANONICAL_ALIAS),
)
}

View file

@ -21,7 +21,8 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissionsAsState
import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions
import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions
import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator
import io.element.android.features.securityandprivacy.impl.editroomaddress.matchesServer
import io.element.android.libraries.architecture.AsyncAction
@ -37,6 +38,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
@ -65,7 +67,6 @@ class SecurityAndPrivacyPresenter(
}.collectAsState(false)
val saveAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val homeserverName = remember { matrixClient.userIdServerName() }
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val roomInfo by room.roomInfoFlow.collectAsState()
val savedIsVisibleInRoomDirectory = remember { mutableStateOf<AsyncData<Boolean>>(AsyncData.Uninitialized) }
@ -106,7 +107,9 @@ class SecurityAndPrivacyPresenter(
)
var showEnableEncryptionConfirmation by remember(savedSettings.isEncrypted) { mutableStateOf(false) }
val permissions by room.securityAndPrivacyPermissionsAsState(syncUpdateFlow.value)
val permissions by room.permissionsAsState(SecurityAndPrivacyPermissions.DEFAULT) { perms ->
perms.securityAndPrivacyPermissions()
}
fun handleEvent(event: SecurityAndPrivacyEvent) {
when (event) {

View file

@ -18,15 +18,19 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
@ -66,7 +70,7 @@ class SecurityAndPrivacyPresenterTest {
fun `present - room info change updates saved and edited settings`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canSendStateResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(
joinRule = JoinRule.Public,
historyVisibility = RoomHistoryVisibility.WorldReadable,
@ -173,7 +177,7 @@ class SecurityAndPrivacyPresenterTest {
fun `present - room visibility loading and change`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canSendStateResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
getRoomVisibilityResult = { Result.success(RoomVisibility.Private) },
initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared)
)
@ -222,7 +226,7 @@ class SecurityAndPrivacyPresenterTest {
val updateRoomHistoryVisibilityLambda = lambdaRecorder<RoomHistoryVisibility, Result<Unit>> { Result.success(Unit) }
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canSendStateResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
getRoomVisibilityResult = { Result.success(RoomVisibility.Private) },
initialRoomInfo = aRoomInfo(joinRule = JoinRule.Invite, historyVisibility = RoomHistoryVisibility.Shared)
),
@ -272,8 +276,8 @@ class SecurityAndPrivacyPresenterTest {
isEncrypted = true,
)
)
// Saved settings are updated 3 times to match the edited settings
skipItems(3)
// Saved settings are updated 2 times to match the edited settings
skipItems(2)
with(awaitItem()) {
assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit))
assertThat(savedSettings).isEqualTo(editedSettings)
@ -297,7 +301,7 @@ class SecurityAndPrivacyPresenterTest {
val updateRoomHistoryVisibilityLambda = lambdaRecorder<RoomHistoryVisibility, Result<Unit>> { Result.success(Unit) }
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canSendStateResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
getRoomVisibilityResult = { Result.success(RoomVisibility.Private) },
initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Private)
),
@ -340,7 +344,7 @@ class SecurityAndPrivacyPresenterTest {
)
)
// Saved settings are updated 2 times to match the edited settings
skipItems(3)
skipItems(2)
val state = awaitItem()
with(state) {
assertThat(saveAction).isInstanceOf(AsyncAction.Failure::class.java)
@ -374,11 +378,30 @@ class SecurityAndPrivacyPresenterTest {
}
}
private fun roomPermissions(
canChangeRoomAccess: Boolean = true,
canChangeHistoryVisibility: Boolean = true,
canChangeEncryption: Boolean = true,
canChangeRoomVisibility: Boolean = true,
): RoomPermissions {
return FakeRoomPermissions(
canSendState = { eventType ->
when (eventType) {
StateEventType.ROOM_JOIN_RULES -> canChangeRoomAccess
StateEventType.ROOM_HISTORY_VISIBILITY -> canChangeHistoryVisibility
StateEventType.ROOM_ENCRYPTION -> canChangeEncryption
StateEventType.ROOM_CANONICAL_ALIAS -> canChangeRoomVisibility
else -> lambdaError()
}
}
)
}
private fun createSecurityAndPrivacyPresenter(
serverName: String = "matrix.org",
room: FakeJoinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canSendStateResult = { _, _ -> Result.success(true) },
roomPermissions = roomPermissions(),
getRoomVisibilityResult = { Result.success(RoomVisibility.Private) },
initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Private)
),

View file

@ -34,6 +34,8 @@ import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.MatrixClient
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.room.powerlevels.canCall
import io.element.android.libraries.matrix.api.room.powerlevels.use
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
@ -56,7 +58,7 @@ class UserProfilePresenter(
@Composable
private fun getDmRoomId(): State<RoomId?> {
return produceState<RoomId?>(initialValue = null) {
return produceState(initialValue = null) {
value = client.findDM(userId).getOrNull()
}
}
@ -66,7 +68,6 @@ class UserProfilePresenter(
val isElementCallAvailable by produceState(initialValue = false, roomId) {
value = sessionEnterpriseService.isElementCallAvailable()
}
return produceState(initialValue = false, isElementCallAvailable, roomId) {
value = when {
isElementCallAvailable.not() -> false
@ -75,7 +76,7 @@ class UserProfilePresenter(
roomId
?.let { client.getRoom(it) }
?.use { room ->
room.canUserJoinCall(client.sessionId).getOrNull()
room.roomPermissions().use(false) { perms -> perms.canCall() }
}
.orFalse()
}

View file

@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
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.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
@ -36,9 +37,11 @@ import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
@ -89,15 +92,7 @@ class UserProfilePresenterTest {
@Test
fun `present - canCall is false when canUserJoinCall returns false`() {
testCanCall(
canUserJoinCallResult = Result.success(false),
expectedResult = false,
)
}
@Test
fun `present - canCall is false when canUserJoinCall fails`() {
testCanCall(
canUserJoinCallResult = Result.failure(AN_EXCEPTION),
canUserJoinCall = false,
expectedResult = false,
)
}
@ -128,7 +123,7 @@ class UserProfilePresenterTest {
private fun testCanCall(
isElementCallAvailable: Boolean = true,
canUserJoinCallResult: Result<Boolean> = Result.success(true),
canUserJoinCall: Boolean = true,
dmRoom: RoomId? = A_ROOM_ID,
canFindRoom: Boolean = true,
expectedResult: Boolean,
@ -136,7 +131,14 @@ class UserProfilePresenterTest {
checkThatRoomIsDestroyed: Boolean = false,
) = runTest {
val room = FakeBaseRoom(
canUserJoinCallResult = { canUserJoinCallResult },
roomPermissions = FakeRoomPermissions(
canSendState = { type ->
when (type) {
StateEventType.CALL_MEMBER -> canUserJoinCall
else -> lambdaError()
}
}
),
)
val client = createFakeMatrixClient().apply {
if (canFindRoom) {

View file

@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
@ -99,6 +100,11 @@ interface BaseRoom : Closeable {
*/
suspend fun userRole(userId: UserId): Result<RoomMember.Role>
/**
* Gets the permissions of the room.
*/
suspend fun roomPermissions(): Result<RoomPermissions>
/**
* Gets the display name of the user with the provided [userId] in the room.
*/
@ -124,57 +130,6 @@ interface BaseRoom : Closeable {
*/
suspend fun forget(): Result<Unit>
/**
* Returns `true` if the user with the provided [userId] can invite other users to the room.
*/
suspend fun canUserInvite(userId: UserId): Result<Boolean>
/**
* Returns `true` if the user with the provided [userId] can kick other users from the room.
*/
suspend fun canUserKick(userId: UserId): Result<Boolean>
/**
* Returns `true` if the user with the provided [userId] can ban other users from the room.
*/
suspend fun canUserBan(userId: UserId): Result<Boolean>
/**
* Returns `true` if the user with the provided [userId] can redact their own messages.
*/
suspend fun canUserRedactOwn(userId: UserId): Result<Boolean>
/**
* Returns `true` if the user with the provided [userId] can redact messages from other users.
*/
suspend fun canUserRedactOther(userId: UserId): Result<Boolean>
/**
* Returns `true` if the user with the provided [userId] can send state events.
*/
suspend fun canUserSendState(userId: UserId, type: StateEventType): Result<Boolean>
/**
* Returns `true` if the user with the provided [userId] can send messages.
*/
suspend fun canUserSendMessage(userId: UserId, type: MessageEventType): Result<Boolean>
/**
* Returns `true` if the user with the provided [userId] can trigger an `@room` notification.
*/
suspend fun canUserTriggerRoomNotification(userId: UserId): Result<Boolean>
/**
* Returns `true` if the user with the provided [userId] can pin or unpin messages.
*/
suspend fun canUserPinUnpin(userId: UserId): Result<Boolean>
/**
* Returns `true` if the user with the provided [userId] can join or starts calls.
*/
suspend fun canUserJoinCall(userId: UserId): Result<Boolean> =
canUserSendState(userId, StateEventType.CALL_MEMBER)
/**
* Sets the room as favorite or not, based on the [isFavorite] parameter.
*/

View file

@ -0,0 +1,172 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.room.powerlevels
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.StateEventType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/**
* Provides information about the permissions of users in a room.
*/
interface RoomPermissions : AutoCloseable {
fun canOwnUserBan(): Boolean
/**
* Returns true if the current user is able to invite in the room.
*/
fun canOwnUserInvite(): Boolean
/**
* Returns true if the current user is able to kick in the room.
*/
fun canOwnUserKick(): Boolean
/**
* Returns true if the current user is able to pin or unpin events in the
* room.
*/
fun canOwnUserPinUnpin(): Boolean
/**
* Returns true if the current user user is able to redact messages of
* other users in the room.
*/
fun canOwnUserRedactOther(): Boolean
/**
* Returns true if the current user is able to redact their own messages in
* the room.
*/
fun canOwnUserRedactOwn(): Boolean
/**
* Returns true if the current user is able to send a specific message type
* in the room.
*/
fun canOwnUserSendMessage(message: MessageEventType): Boolean
/**
* Returns true if the current user is able to send a specific state event
* type in the room.
*/
fun canOwnUserSendState(stateEvent: StateEventType): Boolean
/**
* Returns true if the current user is able to trigger a notification in
* the room.
*/
fun canOwnUserTriggerRoomNotification(): Boolean
/**
* Returns true if the user with the given userId is able to ban in the
* room.
*/
fun canUserBan(userId: UserId): Boolean
/**
* Returns true if the user with the given userId is able to invite in the
* room.
*/
fun canUserInvite(userId: UserId): Boolean
/**
* Returns true if the user with the given userId is able to kick in the
* room.
*/
fun canUserKick(userId: UserId): Boolean
/**
* Returns true if the user with the given userId is able to pin or unpin
* events in the room.
*/
fun canUserPinUnpin(userId: UserId): Boolean
/**
* Returns true if the user with the given userId is able to redact
* messages of other users in the room.
*/
fun canUserRedactOther(userId: UserId): Boolean
/**
* Returns true if the user with the given userId is able to redact
* their own messages in the room.
*/
fun canUserRedactOwn(userId: UserId): Boolean
/**
* Returns true if the user with the given userId is able to send a
* specific message type in the room.
*/
fun canUserSendMessage(userId: UserId, message: MessageEventType): Boolean
/**
* Returns true if the user with the given userId is able to send a
* specific state event type in the room.
*/
fun canUserSendState(userId: UserId, stateEvent: StateEventType): Boolean
/**
* Returns true if the user with the given userId is able to trigger a
* notification in the room.
*
* The call may fail if there is an error in getting the power levels.
*/
fun canUserTriggerRoomNotification(userId: UserId): Boolean
}
fun RoomPermissions.canEditRolesAndPermissions(): Boolean {
return canOwnUserSendState(StateEventType.ROOM_POWER_LEVELS)
}
fun RoomPermissions.canCall(): Boolean {
return canOwnUserSendState(StateEventType.CALL_MEMBER)
}
fun <T> Result<RoomPermissions>.use(default: T, block: (RoomPermissions) -> T): T {
return fold(
onSuccess = { perms ->
perms.use(block)
},
onFailure = {
default
}
)
}
fun <T> BaseRoom.permissionsFlow(default: T, block: (RoomPermissions) -> T): Flow<T> {
return roomInfoFlow
.map { info ->
// If the user is a privileged creator, we return a constant hashcode to avoid recomputing permissions
// each time the power levels change (as they have all permissions).
if (info.privilegedCreatorRole && info.creators.contains(sessionId)) {
Long.MAX_VALUE
} else {
info.roomPowerLevels?.hashCode() ?: 0L
}
}
.distinctUntilChanged()
.map {
roomPermissions().use(default, block)
}
}
@Composable
fun <T> BaseRoom.permissionsAsState(default: T, block: (RoomPermissions) -> T): State<T> {
return remember(this, default, block) {
permissionsFlow(default, block)
}.collectAsState(default)
}

View file

@ -8,11 +8,6 @@
package io.element.android.libraries.matrix.api.room.powerlevels
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.StateEventType
data class RoomPowerLevelsValues(
val ban: Long,
val invite: Long,
@ -24,50 +19,3 @@ data class RoomPowerLevelsValues(
val roomTopic: Long,
val spaceChild: Long,
)
/**
* Shortcut for calling [BaseRoom.canUserInvite] with our own user.
*/
suspend fun BaseRoom.canInvite(): Result<Boolean> = canUserInvite(sessionId)
/**
* Shortcut for calling [BaseRoom.canUserKick] with our own user.
*/
suspend fun BaseRoom.canKick(): Result<Boolean> = canUserKick(sessionId)
/**
* Shortcut for calling [BaseRoom.canUserBan] with our own user.
*/
suspend fun BaseRoom.canBan(): Result<Boolean> = canUserBan(sessionId)
/**
* Shortcut for calling [BaseRoom.canUserSendState] with our own user.
*/
suspend fun BaseRoom.canSendState(type: StateEventType): Result<Boolean> = canUserSendState(sessionId, type)
/**
* Shortcut for calling [BaseRoom.canUserSendMessage] with our own user.
*/
suspend fun BaseRoom.canSendMessage(type: MessageEventType): Result<Boolean> = canUserSendMessage(sessionId, type)
/**
* Shortcut for calling [BaseRoom.canUserRedactOwn] with our own user.
*/
suspend fun BaseRoom.canRedactOwn(): Result<Boolean> = canUserRedactOwn(sessionId)
/**
* Shortcut for calling [BaseRoom.canRedactOther] with our own user.
*/
suspend fun BaseRoom.canRedactOther(): Result<Boolean> = canUserRedactOther(sessionId)
/**
* Shortcut for checking if current user can handle knock requests.
*/
suspend fun BaseRoom.canHandleKnockRequests(): Result<Boolean> = runCatchingExceptions {
canInvite().getOrThrow() || canBan().getOrThrow() || canKick().getOrThrow()
}
/**
* Shortcut for calling [BaseRoom.canUserPinUnpin] with our own user.
*/
suspend fun BaseRoom.canPinUnpin(): Result<Boolean> = canUserPinUnpin(sessionId)

View file

@ -18,13 +18,12 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomInfo
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.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
@ -33,6 +32,7 @@ import io.element.android.libraries.matrix.impl.room.draft.into
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsValuesMapper
import io.element.android.libraries.matrix.impl.room.powerlevels.RustRoomPermissions
import io.element.android.libraries.matrix.impl.room.tombstone.map
import io.element.android.libraries.matrix.impl.roomdirectory.map
import io.element.android.libraries.matrix.impl.timeline.toRustReceiptType
@ -180,57 +180,9 @@ class RustBaseRoom(
}
}
override suspend fun canUserInvite(userId: UserId): Result<Boolean> = withContext(roomDispatcher) {
override suspend fun roomPermissions(): Result<RoomPermissions> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.getPowerLevels().use { it.canUserInvite(userId.value) }
}
}
override suspend fun canUserKick(userId: UserId): Result<Boolean> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.getPowerLevels().use { it.canUserKick(userId.value) }
}
}
override suspend fun canUserBan(userId: UserId): Result<Boolean> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.getPowerLevels().use { it.canUserBan(userId.value) }
}
}
override suspend fun canUserRedactOwn(userId: UserId): Result<Boolean> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.getPowerLevels().use { it.canUserRedactOwn(userId.value) }
}
}
override suspend fun canUserRedactOther(userId: UserId): Result<Boolean> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.getPowerLevels().use { it.canUserRedactOther(userId.value) }
}
}
override suspend fun canUserSendState(userId: UserId, type: StateEventType): Result<Boolean> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.getPowerLevels().use { it.canUserSendState(userId.value, type.map()) }
}
}
override suspend fun canUserSendMessage(userId: UserId, type: MessageEventType): Result<Boolean> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.getPowerLevels().use { it.canUserSendMessage(userId.value, type.map()) }
}
}
override suspend fun canUserTriggerRoomNotification(userId: UserId): Result<Boolean> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.getPowerLevels().use { it.canUserTriggerRoomNotification(userId.value) }
}
}
override suspend fun canUserPinUnpin(userId: UserId): Result<Boolean> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.getPowerLevels().use { it.canUserPinUnpin(userId.value) }
RustRoomPermissions(innerRoom.getPowerLevels())
}
}

View file

@ -0,0 +1,95 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.room.powerlevels
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
import io.element.android.libraries.matrix.impl.room.map
import org.matrix.rustcomponents.sdk.RoomPowerLevels
class RustRoomPermissions(
private val inner: RoomPowerLevels,
) : RoomPermissions {
override fun canOwnUserBan(): Boolean {
return inner.canOwnUserBan()
}
override fun canOwnUserInvite(): Boolean {
return inner.canOwnUserInvite()
}
override fun canOwnUserKick(): Boolean {
return inner.canOwnUserKick()
}
override fun canOwnUserPinUnpin(): Boolean {
return inner.canOwnUserPinUnpin()
}
override fun canOwnUserRedactOther(): Boolean {
return inner.canOwnUserRedactOther()
}
override fun canOwnUserRedactOwn(): Boolean {
return inner.canOwnUserRedactOwn()
}
override fun canOwnUserSendMessage(message: MessageEventType): Boolean {
return inner.canOwnUserSendMessage(message.map())
}
override fun canOwnUserSendState(stateEvent: StateEventType): Boolean {
return inner.canOwnUserSendState(stateEvent.map())
}
override fun canOwnUserTriggerRoomNotification(): Boolean {
return inner.canOwnUserTriggerRoomNotification()
}
override fun canUserBan(userId: UserId): Boolean {
return inner.canUserBan(userId.value)
}
override fun canUserInvite(userId: UserId): Boolean {
return inner.canUserInvite(userId.value)
}
override fun canUserKick(userId: UserId): Boolean {
return inner.canUserKick(userId.value)
}
override fun canUserPinUnpin(userId: UserId): Boolean {
return inner.canUserPinUnpin(userId.value)
}
override fun canUserRedactOther(userId: UserId): Boolean {
return inner.canUserRedactOther(userId.value)
}
override fun canUserRedactOwn(userId: UserId): Boolean {
return inner.canUserRedactOwn(userId.value)
}
override fun canUserSendMessage(userId: UserId, message: MessageEventType): Boolean {
return inner.canUserSendMessage(userId.value, message.map())
}
override fun canUserSendState(userId: UserId, stateEvent: StateEventType): Boolean {
return inner.canUserSendState(userId.value, stateEvent.map())
}
override fun canUserTriggerRoomNotification(userId: UserId): Boolean {
return inner.canUserTriggerRoomNotification(userId.value)
}
override fun close() {
inner.close()
}
}

View file

@ -15,18 +15,18 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomInfo
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.StateEventType
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.CoroutineScope
@ -40,6 +40,7 @@ class FakeBaseRoom(
override val sessionId: SessionId = A_SESSION_ID,
override val roomId: RoomId = A_ROOM_ID,
initialRoomInfo: RoomInfo = aRoomInfo(),
private val roomPermissions: RoomPermissions = FakeRoomPermissions(),
override val roomCoroutineScope: CoroutineScope = TestScope(),
private var roomPermalinkResult: () -> Result<String> = { lambdaError() },
private var eventPermalinkResult: (EventId) -> Result<String> = { lambdaError() },
@ -48,16 +49,6 @@ class FakeBaseRoom(
private val userRoleResult: () -> Result<RoomMember.Role> = { lambdaError() },
private val getUpdatedMemberResult: (UserId) -> Result<RoomMember> = { lambdaError() },
private val joinRoomResult: () -> Result<Unit> = { lambdaError() },
private val canInviteResult: (UserId) -> Result<Boolean> = { lambdaError() },
private val canKickResult: (UserId) -> Result<Boolean> = { lambdaError() },
private val canBanResult: (UserId) -> Result<Boolean> = { lambdaError() },
private val canRedactOwnResult: (UserId) -> Result<Boolean> = { lambdaError() },
private val canRedactOtherResult: (UserId) -> Result<Boolean> = { lambdaError() },
private val canSendStateResult: (UserId, StateEventType) -> Result<Boolean> = { _, _ -> lambdaError() },
private val canUserSendMessageResult: (UserId, MessageEventType) -> Result<Boolean> = { _, _ -> lambdaError() },
private val canUserTriggerRoomNotificationResult: (UserId) -> Result<Boolean> = { lambdaError() },
private val canUserJoinCallResult: (UserId) -> Result<Boolean> = { lambdaError() },
private val canUserPinUnpinResult: (UserId) -> Result<Boolean> = { lambdaError() },
private val setIsFavoriteResult: (Boolean) -> Result<Unit> = { lambdaError() },
private val markAsReadResult: (ReceiptType) -> Result<Unit> = { Result.success(Unit) },
private val powerLevelsResult: () -> Result<RoomPowerLevelsValues> = { lambdaError() },
@ -129,6 +120,10 @@ class FakeBaseRoom(
return userRoleResult()
}
override suspend fun roomPermissions(): Result<RoomPermissions> {
return Result.success(roomPermissions)
}
override suspend fun getPermalink(): Result<String> {
return roomPermalinkResult()
}
@ -153,46 +148,6 @@ class FakeBaseRoom(
return forgetResult()
}
override suspend fun canUserBan(userId: UserId): Result<Boolean> {
return canBanResult(userId)
}
override suspend fun canUserKick(userId: UserId): Result<Boolean> {
return canKickResult(userId)
}
override suspend fun canUserInvite(userId: UserId): Result<Boolean> {
return canInviteResult(userId)
}
override suspend fun canUserRedactOwn(userId: UserId): Result<Boolean> {
return canRedactOwnResult(userId)
}
override suspend fun canUserRedactOther(userId: UserId): Result<Boolean> {
return canRedactOtherResult(userId)
}
override suspend fun canUserSendState(userId: UserId, type: StateEventType): Result<Boolean> {
return canSendStateResult(userId, type)
}
override suspend fun canUserSendMessage(userId: UserId, type: MessageEventType): Result<Boolean> {
return canUserSendMessageResult(userId, type)
}
override suspend fun canUserTriggerRoomNotification(userId: UserId): Result<Boolean> {
return canUserTriggerRoomNotificationResult(userId)
}
override suspend fun canUserJoinCall(userId: UserId): Result<Boolean> {
return canUserJoinCallResult(userId)
}
override suspend fun canUserPinUnpin(userId: UserId): Result<Boolean> {
return canUserPinUnpinResult(userId)
}
override suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit> {
return setIsFavoriteResult(isFavorite)
}

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.test.room.powerlevels
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
data class FakeRoomPermissions(
private val canBan: Boolean = false,
private val canInvite: Boolean = false,
private val canKick: Boolean = false,
private val canPinUnpin: Boolean = false,
private val canRedactOther: Boolean = false,
private val canRedactOwn: Boolean = false,
private val canTriggerRoomNotification: Boolean = false,
private val canSendMessage: (MessageEventType) -> Boolean = { false },
private val canSendState: (StateEventType) -> Boolean = { false },
private val canUserBan: (UserId) -> Boolean = { false },
private val canUserInvite: (UserId) -> Boolean = { false },
private val canUserKick: (UserId) -> Boolean = { false },
private val canUserPinUnpin: (UserId) -> Boolean = { false },
private val canUserRedactOther: (UserId) -> Boolean = { false },
private val canUserRedactOwn: (UserId) -> Boolean = { false },
private val canUserTriggerRoomNotification: (UserId) -> Boolean = { false },
private val canUserSendMessage: (UserId, MessageEventType) -> Boolean = { _, _ -> false },
private val canUserSendState: (UserId, StateEventType) -> Boolean = { _, _ -> false },
) : RoomPermissions {
override fun canOwnUserBan(): Boolean = canBan
override fun canOwnUserInvite(): Boolean = canInvite
override fun canOwnUserKick(): Boolean = canKick
override fun canOwnUserPinUnpin(): Boolean = canPinUnpin
override fun canOwnUserRedactOther(): Boolean = canRedactOther
override fun canOwnUserRedactOwn(): Boolean = canRedactOwn
override fun canOwnUserSendMessage(message: MessageEventType): Boolean = canSendMessage(message)
override fun canOwnUserSendState(stateEvent: StateEventType): Boolean = canSendState(stateEvent)
override fun canOwnUserTriggerRoomNotification(): Boolean = canTriggerRoomNotification
override fun canUserBan(userId: UserId): Boolean = canUserBan(userId)
override fun canUserInvite(userId: UserId): Boolean = canUserInvite(userId)
override fun canUserKick(userId: UserId): Boolean = canUserKick(userId)
override fun canUserPinUnpin(userId: UserId): Boolean = canUserPinUnpin(userId)
override fun canUserRedactOther(userId: UserId): Boolean = canUserRedactOther(userId)
override fun canUserRedactOwn(userId: UserId): Boolean = canUserRedactOwn(userId)
override fun canUserSendMessage(userId: UserId, message: MessageEventType): Boolean = canUserSendMessage(userId, message)
override fun canUserSendState(userId: UserId, stateEvent: StateEventType): Boolean = canUserSendState(userId, stateEvent)
override fun canUserTriggerRoomNotification(userId: UserId): Boolean = canUserTriggerRoomNotification(userId)
override fun close() {
// no-op for the fake
}
}

View file

@ -14,88 +14,9 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.canBan
import io.element.android.libraries.matrix.api.room.powerlevels.canHandleKnockRequests
import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
import io.element.android.libraries.matrix.api.room.powerlevels.canKick
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
import io.element.android.libraries.matrix.ui.model.roleOf
@Composable
fun BaseRoom.canSendMessageAsState(type: MessageEventType, updateKey: Long): State<Boolean> {
return produceState(initialValue = true, key1 = updateKey) {
value = canSendMessage(type).getOrElse { true }
}
}
@Composable
fun BaseRoom.canInviteAsState(updateKey: Long): State<Boolean> {
return produceState(initialValue = false, key1 = updateKey) {
value = canInvite().getOrElse { false }
}
}
@Composable
fun BaseRoom.canRedactOwnAsState(updateKey: Long): State<Boolean> {
return produceState(initialValue = false, key1 = updateKey) {
value = canRedactOwn().getOrElse { false }
}
}
@Composable
fun BaseRoom.canRedactOtherAsState(updateKey: Long): State<Boolean> {
return produceState(initialValue = false, key1 = updateKey) {
value = canRedactOther().getOrElse { false }
}
}
@Composable
fun BaseRoom.canCall(updateKey: Long): State<Boolean> {
return produceState(initialValue = false, key1 = updateKey) {
value = canUserJoinCall(sessionId).getOrElse { false }
}
}
@Composable
fun BaseRoom.canPinUnpin(updateKey: Long): State<Boolean> {
return produceState(initialValue = false, key1 = updateKey) {
value = canUserPinUnpin(sessionId).getOrElse { false }
}
}
@Composable
fun BaseRoom.isDmAsState(): State<Boolean> {
return produceState(initialValue = false) {
roomInfoFlow.collect { value = it.isDm }
}
}
@Composable
fun BaseRoom.canKickAsState(updateKey: Long): State<Boolean> {
return produceState(initialValue = false, key1 = updateKey) {
value = canKick().getOrElse { false }
}
}
@Composable
fun BaseRoom.canBanAsState(updateKey: Long): State<Boolean> {
return produceState(initialValue = false, key1 = updateKey) {
value = canBan().getOrElse { false }
}
}
@Composable
fun BaseRoom.canHandleKnockRequestsAsState(updateKey: Long): State<Boolean> {
return produceState(initialValue = false, key1 = updateKey) {
value = canHandleKnockRequests().getOrElse { false }
}
}
@Composable
fun BaseRoom.userPowerLevelAsState(updateKey: Long): State<Long> {
return produceState(initialValue = 0, key1 = updateKey) {

View file

@ -30,8 +30,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarM
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
@ -39,8 +38,10 @@ import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetSta
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.MediaPermissions
import io.element.android.libraries.mediaviewer.impl.model.eventId
import io.element.android.libraries.mediaviewer.impl.model.mediaInfo
import io.element.android.libraries.mediaviewer.impl.model.mediaPermissions
import io.element.android.libraries.mediaviewer.impl.model.mediaSource
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@ -80,6 +81,10 @@ class MediaGalleryPresenter(
mediaGalleryDataSource.start()
}
val permissions by room.permissionsAsState(MediaPermissions.DEFAULT) { perms ->
perms.mediaPermissions()
}
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
localMediaActions.Configure()
@ -119,8 +124,8 @@ class MediaGalleryPresenter(
eventId = event.mediaItem.eventId(),
canDelete = when (event.mediaItem.mediaInfo().senderId) {
null -> false
room.sessionId -> room.canRedactOwn().getOrElse { false } && event.mediaItem.eventId() != null
else -> room.canRedactOther().getOrElse { false } && event.mediaItem.eventId() != null
room.sessionId -> permissions.canRedactOwn && event.mediaItem.eventId() != null
else -> permissions.canRedactOther && event.mediaItem.eventId() != null
},
mediaInfo = event.mediaItem.mediaInfo(),
thumbnailSource = when (event.mediaItem) {

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.model
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
data class MediaPermissions(
val canRedactOwn: Boolean,
val canRedactOther: Boolean,
) {
companion object {
val DEFAULT = MediaPermissions(
canRedactOwn = false,
canRedactOther = false,
)
}
}
fun RoomPermissions.mediaPermissions(): MediaPermissions {
return MediaPermissions(
canRedactOwn = canOwnUserRedactOwn(),
canRedactOther = canOwnUserRedactOther(),
)
}

View file

@ -32,8 +32,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
@ -41,6 +40,8 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.R
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
import io.element.android.libraries.mediaviewer.impl.model.MediaPermissions
import io.element.android.libraries.mediaviewer.impl.model.mediaPermissions
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
@ -81,6 +82,9 @@ class MediaViewerPresenter(
NoMoreItemsBackwardSnackBarDisplayer(currentIndex, data)
NoMoreItemsForwardSnackBarDisplayer(currentIndex, data)
val permissions by room.permissionsAsState(MediaPermissions.DEFAULT) { perms ->
perms.mediaPermissions()
}
var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }
DisposableEffect(Unit) {
@ -131,8 +135,8 @@ class MediaViewerPresenter(
eventId = event.data.eventId,
canDelete = when (event.data.mediaInfo.senderId) {
null -> false
room.sessionId -> room.canRedactOwn().getOrElse { false } && event.data.eventId != null
else -> room.canRedactOther().getOrElse { false } && event.data.eventId != null
room.sessionId -> permissions.canRedactOwn && event.data.eventId != null
else -> permissions.canRedactOther && event.data.eventId != null
},
mediaInfo = event.data.mediaInfo,
thumbnailSource = event.data.thumbnailSource,

View file

@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaviewer.impl.datasource.FakeMediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
@ -109,7 +110,9 @@ class MediaGalleryPresenterTest {
baseRoom = FakeBaseRoom(
sessionId = A_USER_ID,
initialRoomInfo = aRoomInfo(name = A_ROOM_NAME),
canRedactOwnResult = { Result.success(canDeleteOwn) }
roomPermissions = FakeRoomPermissions(
canRedactOwn = canDeleteOwn
),
),
)
)
@ -153,7 +156,9 @@ class MediaGalleryPresenterTest {
baseRoom = FakeBaseRoom(
sessionId = A_USER_ID,
initialRoomInfo = aRoomInfo(name = A_ROOM_NAME),
canRedactOtherResult = { Result.success(canDeleteOther) },
roomPermissions = FakeRoomPermissions(
canRedactOther = canDeleteOther
),
),
createTimelineResult = { Result.success(FakeTimeline()) }
)
@ -355,7 +360,9 @@ class MediaGalleryPresenterTest {
room = FakeJoinedRoom(
createTimelineResult = { Result.success(FakeTimeline()) },
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
roomPermissions = FakeRoomPermissions(
canRedactOwn = true
),
),
),
navigator = navigator,
@ -386,7 +393,9 @@ class MediaGalleryPresenterTest {
room = FakeJoinedRoom(
createTimelineResult = { Result.success(FakeTimeline()) },
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
roomPermissions = FakeRoomPermissions(
canRedactOwn = true
),
),
),
navigator = navigator,

View file

@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
import io.element.android.libraries.matrix.test.media.aMediaSource
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
@ -83,7 +84,9 @@ class MediaViewerPresenterTest {
localMediaFactory = localMediaFactory,
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
roomPermissions = FakeRoomPermissions(
canRedactOwn = true
),
)
)
)
@ -104,7 +107,9 @@ class MediaViewerPresenterTest {
canShowInfo = false,
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
roomPermissions = FakeRoomPermissions(
canRedactOwn = true
),
)
)
)
@ -125,7 +130,9 @@ class MediaViewerPresenterTest {
eventId = AN_EVENT_ID,
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
roomPermissions = FakeRoomPermissions(
canRedactOwn = true
),
)
)
)
@ -147,7 +154,9 @@ class MediaViewerPresenterTest {
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
sessionId = A_SESSION_ID_2,
canRedactOtherResult = { Result.success(false) },
roomPermissions = FakeRoomPermissions(
canRedactOther = false
),
)
)
)
@ -236,7 +245,9 @@ class MediaViewerPresenterTest {
mediaGalleryDataSource = mediaGalleryDataSource,
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) },
roomPermissions = FakeRoomPermissions(
canRedactOwn = true
),
)
)
)
@ -460,7 +471,11 @@ class MediaViewerPresenterTest {
localMediaFactory = localMediaFactory,
room = FakeJoinedRoom(
liveTimeline = timeline,
baseRoom = FakeBaseRoom(canRedactOwnResult = { Result.success(true) }),
baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions(
canRedactOwn = true
),
),
),
mediaGalleryDataSource = mediaGalleryDataSource,
mediaViewerNavigator = FakeMediaViewerNavigator(
@ -769,7 +784,11 @@ class MediaViewerPresenterTest {
localMediaFactory = localMediaFactory,
mediaViewerNavigator = navigator,
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(canRedactOwnResult = { Result.success(true) }),
baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions(
canRedactOwn = true
),
),
)
)
presenter.test {
@ -794,7 +813,11 @@ class MediaViewerPresenterTest {
localMediaFactory = localMediaFactory,
mediaViewerNavigator = navigator,
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(canRedactOwnResult = { Result.success(true) }),
baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions(
canRedactOwn = true
),
),
),
)
presenter.test {
@ -821,7 +844,11 @@ class MediaViewerPresenterTest {
localMediaFactory = localMediaFactory,
mediaViewerNavigator = navigator,
room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(canRedactOwnResult = { Result.success(true) }),
baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions(
canRedactOwn = true
),
),
),
)
presenter.test {