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 5bb71db3b1
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 { val shouldShowBanner by remember {
derivedStateOf { 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.ContributesTo
import dev.zacsweers.metro.Provides import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn 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.di.RoomScope
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags 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.JoinedRoom
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsFlow
@BindingContainer @BindingContainer
@ContributesTo(RoomScope::class) @ContributesTo(RoomScope::class)
@ -25,7 +28,9 @@ object KnockRequestsModule {
fun knockRequestsService(room: JoinedRoom, featureFlagService: FeatureFlagService): KnockRequestsService { fun knockRequestsService(room: JoinedRoom, featureFlagService: FeatureFlagService): KnockRequestsService {
return KnockRequestsService( return KnockRequestsService(
knockRequestsFlow = room.knockRequestsFlow, knockRequestsFlow = room.knockRequestsFlow,
permissionsFlow = room.knockRequestPermissionsFlow(), permissionsFlow = room.permissionsFlow(KnockRequestPermissions.DEFAULT) { perms ->
perms.knockRequestPermissions()
},
isKnockFeatureEnabledFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock), isKnockFeatureEnabledFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock),
coroutineScope = room.roomCoroutineScope coroutineScope = room.roomCoroutineScope
) )

View file

@ -8,6 +8,7 @@
package io.element.android.features.knockrequests.impl.data 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.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.knock.KnockRequest import io.element.android.libraries.matrix.api.room.knock.KnockRequest

View file

@ -9,7 +9,7 @@
package io.element.android.features.knockrequests.impl.list package io.element.android.features.knockrequests.impl.list
import androidx.compose.runtime.Immutable 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.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData

View file

@ -9,7 +9,7 @@
package io.element.android.features.knockrequests.impl.list package io.element.android.features.knockrequests.impl.list
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.KnockRequestPresentable
import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction

View file

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

View file

@ -11,7 +11,7 @@
package io.element.android.features.knockrequests.impl.list package io.element.android.features.knockrequests.impl.list
import com.google.common.truth.Truth.assertThat 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.features.knockrequests.impl.data.KnockRequestsService
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable 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.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.permalink.PermalinkParser 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.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.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMembersState 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.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
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.timeline.item.event.EventOrTransactionId 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.messages.reply.map
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
@ -170,7 +164,9 @@ class MessagesPresenter(
val roomCallState = roomCallStatePresenter.present() val roomCallState = roomCallStatePresenter.present()
val roomMemberModerationState = roomMemberModerationPresenter.present() val roomMemberModerationState = roomMemberModerationPresenter.present()
val userEventPermissions by userEventPermissions(roomInfo) val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
perms.userEventPermissions()
}
val roomAvatar by remember { val roomAvatar by remember {
derivedStateOf { roomInfo.avatarData() } 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 { private fun RoomInfo.avatarData(): AvatarData {
return AvatarData( return AvatarData(
id = id.value, 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.RoomCallState
import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents 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.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
@ -164,11 +165,9 @@ fun aMessagesState(
) )
fun aRoomMemberModerationState( fun aRoomMemberModerationState(
canKick: Boolean = false, permissions: RoomMemberModerationPermissions = RoomMemberModerationPermissions.DEFAULT,
canBan: Boolean = false,
) = object : RoomMemberModerationState { ) = object : RoomMemberModerationState {
override val canKick: Boolean = canKick override val permissions: RoomMemberModerationPermissions = permissions
override val canBan: Boolean = canBan
override val eventSink: (RoomMemberModerationEvents) -> Unit = {} override val eventSink: (RoomMemberModerationEvents) -> Unit = {}
} }

View file

@ -8,6 +8,9 @@
package io.element.android.features.messages.impl 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. * Represents the permissions a user has in a room.
* It's dependent of the user's power level in the 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.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.getDirectRoomMember 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.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.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails 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 kotlin.time.Duration.Companion.seconds
import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
@Suppress("LargeClass")
@AssistedInject @AssistedInject
class MessageComposerPresenter( class MessageComposerPresenter(
@Assisted private val navigator: MessagesNavigator, @Assisted private val navigator: MessagesNavigator,
@ -396,7 +398,9 @@ class MessageComposerPresenter(
val currentUserId = room.sessionId val currentUserId = room.sessionId
suspend fun canSendRoomMention(): Boolean { suspend fun canSendRoomMention(): Boolean {
val userCanSendAtRoom = room.canUserTriggerRoomNotification(currentUserId).getOrDefault(false) val userCanSendAtRoom = room.roomPermissions().use(false) { perms ->
perms.canOwnUserTriggerRoomNotification()
}
return !room.isDm() && userCanSendAtRoom 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue 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.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState 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.typing.TypingNotificationState
import io.element.android.features.messages.impl.userEventPermissions
import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter 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.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags 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.JoinedRoom
import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.room.roomMembers 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.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@ -97,31 +95,33 @@ class PinnedMessagesListPresenter(
@Composable @Composable
override fun present(): PinnedMessagesListState { override fun present(): PinnedMessagesListState {
htmlConverterProvider.Update() htmlConverterProvider.Update()
val isDm by room.isDmAsState() val roomInfo by room.roomInfoFlow.collectAsState()
val timelineRoomInfo by remember {
val timelineRoomInfo = remember(isDm) { derivedStateOf {
TimelineRoomInfo( TimelineRoomInfo(
isDm = isDm, isDm = roomInfo.isDm,
name = room.info().name, name = roomInfo.name,
// We don't need to compute those values // We don't need to compute those values
userHasPermissionToSendMessage = false, userHasPermissionToSendMessage = false,
userHasPermissionToSendReaction = false, userHasPermissionToSendReaction = false,
// We do not care about the call state here. // We do not care about the call state here.
roomCallState = aStandByCallState(), roomCallState = aStandByCallState(),
// don't compute this value or the pin icon will be shown // don't compute this value or the pin icon will be shown
pinnedEventIds = persistentListOf(), pinnedEventIds = persistentListOf(),
typingNotificationState = TypingNotificationState( typingNotificationState = TypingNotificationState(
renderTypingNotifications = false, renderTypingNotifications = false,
typingMembers = persistentListOf(), typingMembers = persistentListOf(),
reserveSpace = false, reserveSpace = false,
), ),
predecessorRoom = room.predecessorRoom(), predecessorRoom = room.predecessorRoom(),
) )
}
} }
val timelineProtectionState = timelineProtectionPresenter.present() val timelineProtectionState = timelineProtectionPresenter.present()
val linkState = linkPresenter.present() val linkState = linkPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
val userEventPermissions by userEventPermissions(syncUpdateFlow.value) perms.userEventPermissions()
}
val displayThreadSummaries by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false) 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 @Composable
private fun PinnedMessagesListEffect(onItemsChange: (AsyncData<ImmutableList<TimelineItem>>) -> Unit) { private fun PinnedMessagesListEffect(onItemsChange: (AsyncData<ImmutableList<TimelineItem>>) -> Unit) {
val updatedOnItemsChange by rememberUpdatedState(onItemsChange) 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.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.features.messages.impl.MessagesNavigator 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.ResolveVerifiedUserSendFailureEvents
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory 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.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel 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.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.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
import io.element.android.features.poll.api.actions.EndPollAction import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.architecture.Presenter 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.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService 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.UniqueId
import io.element.android.libraries.matrix.api.core.asEventId 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.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.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.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.ReceiptType 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.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield 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.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.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationTapOpensTimeline import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationTapOpensTimeline
@ -95,6 +95,7 @@ class TimelinePresenter(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
private val tag = "TimelinePresenter" private val tag = "TimelinePresenter"
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create( fun create(
@ -128,11 +129,6 @@ class TimelinePresenter(
val roomInfo by room.roomInfoFlow.collectAsState() 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 prevMostRecentItemId = rememberSaveable { mutableStateOf<UniqueId?>(null) }
val newEventState = remember { mutableStateOf(NewEventState.None) } val newEventState = remember { mutableStateOf(NewEventState.None) }
@ -285,13 +281,16 @@ class TimelinePresenter(
val typingNotificationState = typingNotificationPresenter.present() val typingNotificationState = typingNotificationPresenter.present()
val roomCallState = roomCallStatePresenter.present() val roomCallState = roomCallStatePresenter.present()
val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
perms.userEventPermissions()
}
val timelineRoomInfo by remember(typingNotificationState, roomCallState, roomInfo) { val timelineRoomInfo by remember(typingNotificationState, roomCallState, roomInfo) {
derivedStateOf { derivedStateOf {
TimelineRoomInfo( TimelineRoomInfo(
name = roomInfo.name, name = roomInfo.name,
isDm = roomInfo.isDm.orFalse(), isDm = roomInfo.isDm,
userHasPermissionToSendMessage = userHasPermissionToSendMessage, userHasPermissionToSendMessage = userEventPermissions.canSendMessage,
userHasPermissionToSendReaction = userHasPermissionToSendReaction, userHasPermissionToSendReaction = userEventPermissions.canSendReaction,
roomCallState = roomCallState, roomCallState = roomCallState,
pinnedEventIds = roomInfo.pinnedEventIds, pinnedEventIds = roomInfo.pinnedEventIds,
typingNotificationState = typingNotificationState, 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.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMembersState 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.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.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo 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.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo 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.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.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aTimelineItemDebugInfo import io.element.android.libraries.matrix.test.timeline.aTimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails 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 { fun `present - check that the room's unread flag is removed`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
markAsReadResult = { lambdaError() } markAsReadResult = { lambdaError() }
), ),
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
@ -173,11 +171,7 @@ class MessagesPresenterTest {
} }
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
), ),
liveTimeline = timeline, liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
@ -223,11 +217,7 @@ class MessagesPresenterTest {
} }
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
), ),
liveTimeline = timeline, liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
@ -288,11 +278,7 @@ class MessagesPresenterTest {
val event = aMessageEvent() val event = aMessageEvent()
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
eventPermalinkResult = { Result.success("a link") }, eventPermalinkResult = { Result.success("a link") },
), ),
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
@ -514,11 +500,7 @@ class MessagesPresenterTest {
val liveTimeline = FakeTimeline() val liveTimeline = FakeTimeline()
val joinedRoom = FakeJoinedRoom( val joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
), ),
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
@ -586,11 +568,7 @@ class MessagesPresenterTest {
fun `present - shows prompt to reinvite users in DM`() = runTest { fun `present - shows prompt to reinvite users in DM`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
).apply { ).apply {
givenRoomInfo(aRoomInfo(isDirect = true, joinedMembersCount = 1, activeMembersCount = 1)) 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 { fun `present - doesn't show reinvite prompt in non-direct room`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
).apply { ).apply {
givenRoomInfo(aRoomInfo(isDirect = false, joinedMembersCount = 1, activeMembersCount = 1)) 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 { fun `present - doesn't show reinvite prompt if other party is present`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
).apply { ).apply {
givenRoomInfo(aRoomInfo(isDirect = true, joinedMembersCount = 2, activeMembersCount = 2)) givenRoomInfo(aRoomInfo(isDirect = true, joinedMembersCount = 2, activeMembersCount = 2))
}, },
@ -672,11 +642,7 @@ class MessagesPresenterTest {
val inviteUserResult = lambdaRecorder { _: UserId -> Result.success(Unit) } val inviteUserResult = lambdaRecorder { _: UserId -> Result.success(Unit) }
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
), ),
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
inviteUserResult = inviteUserResult, inviteUserResult = inviteUserResult,
@ -707,11 +673,7 @@ class MessagesPresenterTest {
val inviteUserResult = lambdaRecorder { _: UserId -> Result.success(Unit) } val inviteUserResult = lambdaRecorder { _: UserId -> Result.success(Unit) }
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
), ),
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
inviteUserResult = inviteUserResult, inviteUserResult = inviteUserResult,
@ -744,11 +706,7 @@ class MessagesPresenterTest {
fun `present - handle reinviting other user when memberlist is not ready`() = runTest { fun `present - handle reinviting other user when memberlist is not ready`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
), ),
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) )
@ -769,11 +727,7 @@ class MessagesPresenterTest {
fun `present - handle reinviting other user when inviting fails`() = runTest { fun `present - handle reinviting other user when inviting fails`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
), ),
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
inviteUserResult = { Result.failure(RuntimeException("Oops!")) }, inviteUserResult = { Result.failure(RuntimeException("Oops!")) },
@ -807,17 +761,7 @@ class MessagesPresenterTest {
fun `present - permission to post`() = runTest { fun `present - permission to post`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) }, roomPermissions = roomPermissions(),
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()
}
},
), ),
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) )
@ -833,17 +777,9 @@ class MessagesPresenterTest {
fun `present - no permission to post`() = runTest { fun `present - no permission to post`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) }, roomPermissions = roomPermissions(
canRedactOtherResult = { Result.success(true) }, canSendMessage = false
canUserJoinCallResult = { Result.success(true) }, ),
canUserPinUnpinResult = { Result.success(true) },
canUserSendMessageResult = { _, messageEventType ->
when (messageEventType) {
MessageEventType.RoomMessage -> Result.success(false)
MessageEventType.Reaction -> Result.success(false)
else -> lambdaError()
}
},
), ),
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) )
@ -859,11 +795,9 @@ class MessagesPresenterTest {
fun `present - permission to redact own`() = runTest { fun `present - permission to redact own`() = runTest {
val joinedRoom = FakeJoinedRoom( val joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) }, roomPermissions = roomPermissions(
canUserSendMessageResult = { _, _ -> Result.success(true) }, canRedactOther = false
canRedactOtherResult = { Result.success(false) }, ),
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
), ),
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) )
@ -880,11 +814,9 @@ class MessagesPresenterTest {
fun `present - permission to redact other`() = runTest { fun `present - permission to redact other`() = runTest {
val joinedRoom = FakeJoinedRoom( val joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canRedactOtherResult = { Result.success(true) }, roomPermissions = roomPermissions(
canUserSendMessageResult = { _, _ -> Result.success(true) }, canRedactOwn = false
canRedactOwnResult = { Result.success(false) }, ),
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
), ),
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) )
@ -929,11 +861,7 @@ class MessagesPresenterTest {
val timeline = FakeTimeline() val timeline = FakeTimeline()
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
), ),
liveTimeline = timeline, liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
@ -973,11 +901,7 @@ class MessagesPresenterTest {
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
), ),
liveTimeline = timeline, liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
@ -1074,11 +998,7 @@ class MessagesPresenterTest {
} }
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
), ),
liveTimeline = timeline, liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
@ -1115,11 +1035,7 @@ class MessagesPresenterTest {
val successorReason = "This room has been moved to a new location" val successorReason = "This room has been moved to a new location"
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
initialRoomInfo = aRoomInfo( initialRoomInfo = aRoomInfo(
successorRoom = SuccessorRoom( successorRoom = SuccessorRoom(
roomId = successorRoomId, roomId = successorRoomId,
@ -1143,11 +1059,7 @@ class MessagesPresenterTest {
fun `present - room without successor room has null successor info in state`() = runTest { fun `present - room without successor room has null successor info in state`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
initialRoomInfo = aRoomInfo(successorRoom = null) initialRoomInfo = aRoomInfo(successorRoom = null)
), ),
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
@ -1165,11 +1077,13 @@ class MessagesPresenterTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = FakeRoomPermissions(
canRedactOwnResult = { Result.success(true) }, canSendState = { true },
canRedactOtherResult = { Result.success(true) }, canSendMessage = { true },
canUserJoinCallResult = { Result.success(true) }, canRedactOther = true,
canUserPinUnpinResult = { Result.success(true) }, canRedactOwn = true,
canPinUnpin = true,
),
initialRoomInfo = aRoomInfo(isDirect = true, isEncrypted = true) initialRoomInfo = aRoomInfo(isDirect = true, isEncrypted = true)
).apply { ).apply {
givenRoomMembersState(RoomMembersState.Ready(persistentListOf(aRoomMember(userId = A_SESSION_ID), aRoomMember(userId = A_USER_ID_2)))) 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( private fun TestScope.createMessagesPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
timeline: Timeline = FakeTimeline(), timeline: Timeline = FakeTimeline(),
joinedRoom: FakeJoinedRoom = FakeJoinedRoom( joinedRoom: FakeJoinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = FakeRoomPermissions(
canRedactOwnResult = { Result.success(true) }, canSendState = { true },
canRedactOtherResult = { Result.success(true) }, canSendMessage = { true },
canUserJoinCallResult = { Result.success(true) }, canRedactOther = true,
canUserPinUnpinResult = { Result.success(true) }, canRedactOwn = true,
canPinUnpin = true,
),
).apply { ).apply {
givenRoomInfo(aRoomInfo(id = roomId, name = "")) 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.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo 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.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.FakeTimeline
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.mediapickers.api.PickerProvider 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 invitedUser = aRoomMember(userId = A_USER_ID_3, membership = RoomMembershipState.INVITE)
val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN) 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 david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN)
var canUserTriggerRoomNotificationResult = true
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(canUserTriggerRoomNotificationResult = { Result.success(canUserTriggerRoomNotificationResult) }), baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions(
canTriggerRoomNotification = true,
)
),
typingNoticeResult = { Result.success(Unit) } typingNoticeResult = { Result.success(Unit) }
).apply { ).apply {
givenRoomMembersState( givenRoomMembersState(
@ -1033,10 +1037,38 @@ class MessageComposerPresenterTest {
// If the suggestion isn't a mention, no suggestions are returned // If the suggestion isn't a mention, no suggestions are returned
initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Command, ""))) initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Command, "")))
assertThat(awaitItem().suggestions).isEmpty() assertThat(awaitItem().suggestions).isEmpty()
}
}
// If user has no permission to send `@room` mentions, `RoomMemberSuggestion.Room` is not returned @Test
canUserTriggerRoomNotificationResult = false 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, ""))) initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
skipItems(1)
assertThat(awaitItem().suggestions) assertThat(awaitItem().suggestions)
.containsExactly(ResolvedSuggestion.Member(bob), ResolvedSuggestion.Member(david)) .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 bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN)
val david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN) val david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN)
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(canUserTriggerRoomNotificationResult = { Result.success(true) }), baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions(canTriggerRoomNotification = true),
),
typingNoticeResult = { Result.success(Unit) } typingNoticeResult = { Result.success(Unit) }
).apply { ).apply {
givenRoomMembersState( givenRoomMembersState(
@ -1069,7 +1103,6 @@ class MessageComposerPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
// An empty suggestion returns the joined members that are not the current user, but not the room // 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, ""))) initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
skipItems(1) 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.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom 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.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.sync.FakeSyncService
import io.element.android.libraries.matrix.test.timeline.FakeTimeline 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.aMessageContent
@ -55,9 +56,7 @@ class PinnedMessagesListPresenterTest {
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
).apply { ).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
} }
@ -74,9 +73,7 @@ class PinnedMessagesListPresenterTest {
fun `present - timeline failure state`() = runTest { fun `present - timeline failure state`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
).apply { ).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}, },
@ -95,9 +92,7 @@ class PinnedMessagesListPresenterTest {
fun `present - empty state`() = runTest { fun `present - empty state`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
).apply { ).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf())) givenRoomInfo(aRoomInfo(pinnedEventIds = listOf()))
}, },
@ -117,9 +112,7 @@ class PinnedMessagesListPresenterTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline() val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
).apply { ).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}, },
@ -146,9 +139,7 @@ class PinnedMessagesListPresenterTest {
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
).apply { ).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}, },
@ -194,9 +185,7 @@ class PinnedMessagesListPresenterTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline() val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
).apply { ).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}, },
@ -225,9 +214,7 @@ class PinnedMessagesListPresenterTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline() val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
).apply { ).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}, },
@ -256,9 +243,7 @@ class PinnedMessagesListPresenterTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline() val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) }, roomPermissions = roomPermissions(),
canRedactOtherResult = { Result.success(true) },
canUserPinUnpinResult = { Result.success(true) },
).apply { ).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) 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( private fun TestScope.createPinnedMessagesListPresenter(
navigator: PinnedMessagesListNavigator = FakePinnedMessagesListNavigator(), navigator: PinnedMessagesListNavigator = FakePinnedMessagesListNavigator(),
room: JoinedRoom = FakeJoinedRoom(), room: JoinedRoom = FakeJoinedRoom(),

View file

@ -8,8 +8,6 @@
package io.element.android.features.messages.impl.timeline 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.ReceiveTurbine
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat 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.ThreadId
import io.element.android.libraries.matrix.api.core.UniqueId 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.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.RoomMembersState
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem 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.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom 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.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.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aMessageContent import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem 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.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.assert 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.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
@ -97,9 +98,7 @@ class TimelinePresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createTimelinePresenter() val presenter = createTimelinePresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.timelineItems).isEmpty() assertThat(initialState.timelineItems).isEmpty()
assertThat(initialState.isLive).isTrue() assertThat(initialState.isLive).isTrue()
@ -118,9 +117,7 @@ class TimelinePresenterTest {
this.paginateLambda = paginateLambda this.paginateLambda = paginateLambda
} }
val presenter = createTimelinePresenter(timeline = timeline) val presenter = createTimelinePresenter(timeline = timeline)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS)) initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS))
initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.FORWARDS)) initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.FORWARDS))
@ -166,9 +163,6 @@ class TimelinePresenterTest {
) )
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
liveTimeline = timeline, liveTimeline = timeline,
baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) },
)
) )
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = isSendPublicReadReceiptsEnabled) val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = isSendPublicReadReceiptsEnabled)
val presenter = createTimelinePresenter( val presenter = createTimelinePresenter(
@ -176,9 +170,7 @@ class TimelinePresenterTest {
room = room, room = room,
sessionPreferencesStore = sessionPreferencesStore, sessionPreferencesStore = sessionPreferencesStore,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
runCurrent() runCurrent()
@ -211,9 +203,7 @@ class TimelinePresenterTest {
this.sendReadReceiptLambda = sendReadReceiptsLambda this.sendReadReceiptLambda = sendReadReceiptsLambda
} }
val presenter = createTimelinePresenter(timeline) val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
awaitItem().run { awaitItem().run {
eventSink.invoke(TimelineEvents.OnScrollFinished(1)) eventSink.invoke(TimelineEvents.OnScrollFinished(1))
@ -252,9 +242,7 @@ class TimelinePresenterTest {
timeline = timeline, timeline = timeline,
sessionPreferencesStore = sessionPreferencesStore, sessionPreferencesStore = sessionPreferencesStore,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
awaitItem().run { awaitItem().run {
eventSink.invoke(TimelineEvents.OnScrollFinished(0)) eventSink.invoke(TimelineEvents.OnScrollFinished(0))
@ -290,9 +278,7 @@ class TimelinePresenterTest {
this.sendReadReceiptLambda = sendReadReceiptsLambda this.sendReadReceiptLambda = sendReadReceiptsLambda
} }
val presenter = createTimelinePresenter(timeline) val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
awaitItem().run { awaitItem().run {
eventSink.invoke(TimelineEvents.OnScrollFinished(1)) eventSink.invoke(TimelineEvents.OnScrollFinished(1))
@ -320,9 +306,7 @@ class TimelinePresenterTest {
this.sendReadReceiptLambda = sendReadReceiptsLambda this.sendReadReceiptLambda = sendReadReceiptsLambda
} }
val presenter = createTimelinePresenter(timeline) val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
@ -339,9 +323,7 @@ class TimelinePresenterTest {
markAsReadResult = { Result.success(Unit) }, markAsReadResult = { Result.success(Unit) },
) )
val presenter = createTimelinePresenter(timeline) val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.newEventState).isEqualTo(NewEventState.None) assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
assertThat(initialState.timelineItems.size).isEqualTo(0) assertThat(initialState.timelineItems.size).isEqualTo(0)
@ -390,9 +372,7 @@ class TimelinePresenterTest {
timelineItems = timelineItems, timelineItems = timelineItems,
) )
val presenter = createTimelinePresenter(timeline) val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.newEventState).isEqualTo(NewEventState.None) assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
assertThat(initialState.timelineItems.size).isEqualTo(0) assertThat(initialState.timelineItems.size).isEqualTo(0)
@ -446,9 +426,7 @@ class TimelinePresenterTest {
val presenter = createTimelinePresenter( val presenter = createTimelinePresenter(
sendPollResponseAction = sendPollResponseAction, sendPollResponseAction = sendPollResponseAction,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.SelectPollAnswer(AN_EVENT_ID, "anAnswerId")) initialState.eventSink.invoke(TimelineEvents.SelectPollAnswer(AN_EVENT_ID, "anAnswerId"))
} }
@ -462,9 +440,7 @@ class TimelinePresenterTest {
val presenter = createTimelinePresenter( val presenter = createTimelinePresenter(
endPollAction = endPollAction, endPollAction = endPollAction,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.EndPoll(AN_EVENT_ID)) initialState.eventSink.invoke(TimelineEvents.EndPoll(AN_EVENT_ID))
} }
@ -481,9 +457,7 @@ class TimelinePresenterTest {
val presenter = createTimelinePresenter( val presenter = createTimelinePresenter(
messagesNavigator = navigator, messagesNavigator = navigator,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
awaitFirstItem().eventSink(TimelineEvents.EditPoll(AN_EVENT_ID)) awaitFirstItem().eventSink(TimelineEvents.EditPoll(AN_EVENT_ID))
onEditPollClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) onEditPollClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
} }
@ -500,9 +474,7 @@ class TimelinePresenterTest {
), ),
redactedVoiceMessageManager = redactedVoiceMessageManager, redactedVoiceMessageManager = redactedVoiceMessageManager,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(0) assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(0)
skipItems(2) skipItems(2)
assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(1) assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(1)
@ -528,16 +500,14 @@ class TimelinePresenterTest {
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) }, createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
threadRootIdForEventResult = { _ -> Result.success(null) }, threadRootIdForEventResult = { _ -> Result.success(null) },
), ),
) )
val presenter = createTimelinePresenter( val presenter = createTimelinePresenter(
room = room, room = room,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
awaitItem().also { state -> awaitItem().also { state ->
@ -579,15 +549,13 @@ class TimelinePresenterTest {
) )
), ),
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
threadRootIdForEventResult = { Result.success(null) }, threadRootIdForEventResult = { Result.success(null) },
), ),
), ),
timelineItemIndexer = timelineItemIndexer, timelineItemIndexer = timelineItemIndexer,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
advanceUntilIdle() advanceUntilIdle()
@ -619,14 +587,12 @@ class TimelinePresenterTest {
), ),
createTimelineResult = { Result.failure(RuntimeException("An error")) }, createTimelineResult = { Result.failure(RuntimeException("An error")) },
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
threadRootIdForEventResult = { _ -> Result.success(null) }, threadRootIdForEventResult = { _ -> Result.success(null) },
), ),
) )
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) initialState.eventSink(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
awaitItem().also { state -> awaitItem().also { state ->
@ -668,7 +634,7 @@ class TimelinePresenterTest {
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) }, createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
threadRootIdForEventResult = { _ -> Result.success(threadId) }, threadRootIdForEventResult = { _ -> Result.success(threadId) },
), ),
) )
@ -679,9 +645,7 @@ class TimelinePresenterTest {
timeline = liveTimeline, timeline = liveTimeline,
messagesNavigator = navigator, messagesNavigator = navigator,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
@ -729,7 +693,7 @@ class TimelinePresenterTest {
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) }, createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
threadRootIdForEventResult = { _ -> Result.success(threadId) }, threadRootIdForEventResult = { _ -> Result.success(threadId) },
), ),
) )
@ -740,9 +704,7 @@ class TimelinePresenterTest {
timeline = liveTimeline, timeline = liveTimeline,
messagesNavigator = navigator, messagesNavigator = navigator,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
@ -785,7 +747,7 @@ class TimelinePresenterTest {
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) }, createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
// Use a different thread id // Use a different thread id
threadRootIdForEventResult = { _ -> Result.success(A_THREAD_ID_2) }, threadRootIdForEventResult = { _ -> Result.success(A_THREAD_ID_2) },
), ),
@ -797,9 +759,7 @@ class TimelinePresenterTest {
timeline = liveTimeline, timeline = liveTimeline,
messagesNavigator = navigator, messagesNavigator = navigator,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
@ -846,7 +806,7 @@ class TimelinePresenterTest {
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) }, createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
// The event is in the main timeline, not in a thread // The event is in the main timeline, not in a thread
threadRootIdForEventResult = { _ -> Result.success(null) }, threadRootIdForEventResult = { _ -> Result.success(null) },
), ),
@ -858,9 +818,7 @@ class TimelinePresenterTest {
timeline = liveTimeline, timeline = liveTimeline,
messagesNavigator = navigator, messagesNavigator = navigator,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
@ -891,9 +849,7 @@ class TimelinePresenterTest {
fun `present - show shield hide shield`() = runTest { fun `present - show shield hide shield`() = runTest {
val presenter = createTimelinePresenter() val presenter = createTimelinePresenter()
val shield = aCriticalShield() val shield = aCriticalShield()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.messageShield).isNull() assertThat(initialState.messageShield).isNull()
initialState.eventSink(TimelineEvents.ShowShieldDialog(shield)) initialState.eventSink(TimelineEvents.ShowShieldDialog(shield))
@ -929,7 +885,9 @@ class TimelinePresenterTest {
) )
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
liveTimeline = timeline, liveTimeline = timeline,
baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }), baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
),
).apply { ).apply {
givenRoomMembersState(RoomMembersState.Unknown) givenRoomMembersState(RoomMembersState.Unknown)
} }
@ -937,9 +895,7 @@ class TimelinePresenterTest {
val avatarUrl = "https://domain.com/avatar.jpg" val avatarUrl = "https://domain.com/avatar.jpg"
val presenter = createTimelinePresenter(timeline, room) val presenter = createTimelinePresenter(timeline, room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = consumeItemsUntilPredicate(30.seconds) { it.timelineItems.isNotEmpty() }.last() val initialState = consumeItemsUntilPredicate(30.seconds) { it.timelineItems.isNotEmpty() }.last()
val event = initialState.timelineItems.first() as TimelineItem.Event val event = initialState.timelineItems.first() as TimelineItem.Event
assertThat(event.senderAvatar.url).isNull() assertThat(event.senderAvatar.url).isNull()
@ -963,15 +919,13 @@ class TimelinePresenterTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
predecessorRoomResult = { predecessorRoom } predecessorRoomResult = { predecessorRoom }
), ),
) )
val presenter = createTimelinePresenter(room = room) val presenter = createTimelinePresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.timelineRoomInfo.predecessorRoom).isNotNull() assertThat(initialState.timelineRoomInfo.predecessorRoom).isNotNull()
assertThat(initialState.timelineRoomInfo.predecessorRoom?.roomId).isEqualTo(predecessorRoomId) assertThat(initialState.timelineRoomInfo.predecessorRoom?.roomId).isEqualTo(predecessorRoomId)
@ -982,14 +936,12 @@ class TimelinePresenterTest {
fun `present - timeline room info no predecessor`() = runTest { fun `present - timeline room info no predecessor`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
predecessorRoomResult = { null } predecessorRoomResult = { null }
), ),
) )
val presenter = createTimelinePresenter(room = room) val presenter = createTimelinePresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
assertThat(initialState.timelineRoomInfo.predecessorRoom).isNull() assertThat(initialState.timelineRoomInfo.predecessorRoom).isNull()
} }
@ -999,7 +951,7 @@ class TimelinePresenterTest {
fun `present - timeline event navigate to room`() = runTest { fun `present - timeline event navigate to room`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserSendMessageResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
), ),
) )
val onNavigateToRoomLambda = lambdaRecorder<RoomId, EventId?, List<String>, Unit> { _, _, _ -> } val onNavigateToRoomLambda = lambdaRecorder<RoomId, EventId?, List<String>, Unit> { _, _, _ -> }
@ -1025,11 +977,32 @@ class TimelinePresenterTest {
return awaitItem() 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( private fun TestScope.createTimelinePresenter(
timeline: Timeline = FakeTimeline(), timeline: Timeline = FakeTimeline(),
room: FakeJoinedRoom = FakeJoinedRoom( room: FakeJoinedRoom = FakeJoinedRoom(
liveTimeline = timeline, liveTimeline = timeline,
baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }), baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
),
), ),
redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(), redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(), 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.features.roomcall.api.RoomCallState
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.JoinedRoom 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 @Inject
class RoomCallStatePresenter( class RoomCallStatePresenter(
@ -35,8 +36,7 @@ class RoomCallStatePresenter(
value = sessionEnterpriseService.isElementCallAvailable() value = sessionEnterpriseService.isElementCallAvailable()
} }
val roomInfo by room.roomInfoFlow.collectAsState() val roomInfo by room.roomInfoFlow.collectAsState()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val canJoinCall by room.permissionsAsState(false) { perms -> perms.canCall() }
val canJoinCall by room.canCall(updateKey = syncUpdateFlow.value)
val isUserInTheCall by remember { val isUserInTheCall by remember {
derivedStateOf { derivedStateOf {
room.sessionId in roomInfo.activeRoomCallParticipants 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.enterprise.test.FakeSessionEnterpriseService
import io.element.android.features.roomcall.api.RoomCallState 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.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.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom 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.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 io.element.android.tests.testutils.test
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -28,7 +31,7 @@ class RoomCallStatePresenterTest {
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(false) }, roomPermissions = roomPermissions(false),
) )
) )
val presenter = createRoomCallStatePresenter(joinedRoom = room) val presenter = createRoomCallStatePresenter(joinedRoom = room)
@ -47,7 +50,7 @@ class RoomCallStatePresenterTest {
fun `present - element call not available`() = runTest { fun `present - element call not available`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(false) }, roomPermissions = roomPermissions(false),
) )
) )
val presenter = createRoomCallStatePresenter( val presenter = createRoomCallStatePresenter(
@ -66,7 +69,7 @@ class RoomCallStatePresenterTest {
fun `present - initial state - user can join call`() = runTest { fun `present - initial state - user can join call`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(true) }, roomPermissions = roomPermissions(true),
) )
) )
val presenter = createRoomCallStatePresenter(joinedRoom = room) 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 { fun `present - call is disabled if user cannot join it even if there is an ongoing call`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(false) }, roomPermissions = roomPermissions(false),
initialRoomInfo = aRoomInfo(hasRoomCall = true), initialRoomInfo = aRoomInfo(hasRoomCall = true),
) )
) )
@ -106,7 +109,7 @@ class RoomCallStatePresenterTest {
fun `present - user has joined the call on another session`() = runTest { fun `present - user has joined the call on another session`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(true) }, roomPermissions = roomPermissions(true),
).apply { ).apply {
givenRoomInfo( givenRoomInfo(
aRoomInfo( aRoomInfo(
@ -133,7 +136,7 @@ class RoomCallStatePresenterTest {
fun `present - user has joined the call locally`() = runTest { fun `present - user has joined the call locally`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(true) }, roomPermissions = roomPermissions(true),
).apply { ).apply {
givenRoomInfo( givenRoomInfo(
aRoomInfo( aRoomInfo(
@ -163,7 +166,7 @@ class RoomCallStatePresenterTest {
fun `present - user leaves the call`() = runTest { fun `present - user leaves the call`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canUserJoinCallResult = { Result.success(true) }, roomPermissions = roomPermissions(true),
).apply { ).apply {
givenRoomInfo( givenRoomInfo(
aRoomInfo( 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( private fun createRoomCallStatePresenter(
joinedRoom: JoinedRoom, joinedRoom: JoinedRoom,
currentCallService: CurrentCallService = FakeCurrentCallService(), 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -18,11 +19,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import im.vector.app.features.analytics.plan.Interaction 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.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.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.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers 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.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.JoinedRoom 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.RoomMember
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.StateEventType
import io.element.android.libraries.matrix.api.room.join.JoinRule 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.canEditRolesAndPermissions
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.api.room.roomNotificationSettings 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.getCurrentRoomMember
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember 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.matrix.ui.room.roomMemberIdentityStateChange
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -77,8 +77,6 @@ class RoomDetailsPresenter(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val leaveRoomState = leaveRoomPresenter.present() val leaveRoomState = leaveRoomPresenter.present()
val roomInfo by room.roomInfoFlow.collectAsState() val roomInfo by room.roomInfoFlow.collectAsState()
val isUserAdmin = room.isOwnUserAdmin()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val roomAvatar by remember { derivedStateOf { roomInfo.avatarUrl } } val roomAvatar by remember { derivedStateOf { roomInfo.avatarUrl } }
val roomName by remember { derivedStateOf { roomInfo.name?.trim().orEmpty() } } val roomName by remember { derivedStateOf { roomInfo.name?.trim().orEmpty() } }
@ -93,15 +91,11 @@ class RoomDetailsPresenter(
observeNotificationSettings() observeNotificationSettings()
} }
val isDm = roomInfo.isDm
val membersState by room.membersStateFlow.collectAsState() val membersState by room.membersStateFlow.collectAsState()
val canInvite by getCanInvite(membersState) val permissions by getPermissions()
val canonicalAlias by remember { derivedStateOf { roomInfo.canonicalAlias } } val canonicalAlias by remember { derivedStateOf { roomInfo.canonicalAlias } }
val isEncrypted by remember { derivedStateOf { roomInfo.isEncrypted == true } } 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 dmMember by room.getDirectRoomMember(membersState)
val currentMember by room.getCurrentRoomMember(membersState) val currentMember by room.getCurrentRoomMember(membersState)
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember) val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
@ -109,16 +103,15 @@ class RoomDetailsPresenter(
val roomCallState = roomCallStatePresenter.present() val roomCallState = roomCallStatePresenter.present()
val joinedMemberCount by remember { derivedStateOf { roomInfo.joinedMembersCount } } val joinedMemberCount by remember { derivedStateOf { roomInfo.joinedMembersCount } }
val topicState = remember(canEditTopic, roomTopic, roomType) { val topicState = remember(permissions.editDetailsPermissions.canEditTopic, roomTopic, roomType) {
val topic = roomTopic val topic = roomTopic
when { when {
!topic.isNullOrBlank() -> RoomTopicState.ExistingTopic(topic) !topic.isNullOrBlank() -> RoomTopicState.ExistingTopic(topic)
canEditTopic && roomType is RoomDetailsType.Room -> RoomTopicState.CanAddTopic permissions.editDetailsPermissions.canEditTopic && roomType is RoomDetailsType.Room -> RoomTopicState.CanAddTopic
else -> RoomTopicState.Hidden else -> RoomTopicState.Hidden
} }
} }
val canHandleKnockRequests by room.canHandleKnockRequestsAsState(syncUpdateFlow.value)
val isKnockRequestsEnabled by remember { val isKnockRequestsEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock) featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
}.collectAsState(false) }.collectAsState(false)
@ -126,7 +119,7 @@ class RoomDetailsPresenter(
room.knockRequestsFlow.collect { value = it.size } room.knockRequestsFlow.collect { value = it.size }
} }
val canShowKnockRequests by remember { val canShowKnockRequests by remember {
derivedStateOf { isKnockRequestsEnabled && canHandleKnockRequests && joinRule == JoinRule.Knock } derivedStateOf { isKnockRequestsEnabled && permissions.canManageKnockRequests && joinRule == JoinRule.Knock }
} }
val isDeveloperModeEnabled by remember { val isDeveloperModeEnabled by remember {
appPreferencesStore.isDeveloperModeEnabledFlow() appPreferencesStore.isDeveloperModeEnabledFlow()
@ -162,13 +155,6 @@ class RoomDetailsPresenter(
val roomMemberDetailsState = roomMemberDetailsPresenter?.present() 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) { val hasMemberVerificationViolations by produceState(false) {
room.roomMemberIdentityStateChange(waitForEncryption = true) room.roomMemberIdentityStateChange(waitForEncryption = true)
.onEach { identities -> value = identities.any { it.identityState == IdentityState.VerificationViolation } } .onEach { identities -> value = identities.any { it.identityState == IdentityState.VerificationViolation } }
@ -185,22 +171,22 @@ class RoomDetailsPresenter(
roomTopic = topicState, roomTopic = topicState,
memberCount = joinedMemberCount, memberCount = joinedMemberCount,
isEncrypted = isEncrypted, isEncrypted = isEncrypted,
canInvite = canInvite, canInvite = permissions.canInvite,
canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room, canEdit = roomType == RoomDetailsType.Room && permissions.editDetailsPermissions.hasAny,
roomCallState = roomCallState, roomCallState = roomCallState,
roomType = roomType, roomType = roomType,
roomMemberDetailsState = roomMemberDetailsState, roomMemberDetailsState = roomMemberDetailsState,
leaveRoomState = leaveRoomState, leaveRoomState = leaveRoomState,
roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(), roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(),
isFavorite = isFavorite, isFavorite = isFavorite,
displayRolesAndPermissionsSettings = !isDm && isUserAdmin, displayRolesAndPermissionsSettings = !isDm && permissions.canEditRolesAndPermissions,
isPublic = joinRule == JoinRule.Public, isPublic = joinRule == JoinRule.Public,
heroes = roomInfo.heroes.toImmutableList(), heroes = roomInfo.heroes.toImmutableList(),
pinnedMessagesCount = pinnedMessagesCount, pinnedMessagesCount = pinnedMessagesCount,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
canShowKnockRequests = canShowKnockRequests, canShowKnockRequests = canShowKnockRequests,
knockRequestsCount = knockRequestsCount, knockRequestsCount = knockRequestsCount,
canShowSecurityAndPrivacy = canShowSecurityAndPrivacy, canShowSecurityAndPrivacy = !isDm && permissions.canEditSecurityAndPrivacy,
hasMemberVerificationViolations = hasMemberVerificationViolations, hasMemberVerificationViolations = hasMemberVerificationViolations,
canReportRoom = canReportRoom, canReportRoom = canReportRoom,
isTombstoned = roomInfo.successorRoom != null, isTombstoned = roomInfo.successorRoom != null,
@ -232,14 +218,25 @@ class RoomDetailsPresenter(
} }
} }
@Composable private data class Permissions(
private fun getCanInvite(membersState: RoomMembersState) = produceState(false, membersState) { val canInvite: Boolean = false,
value = room.canInvite().getOrElse { false } val editDetailsPermissions: RoomDetailsEditPermissions = RoomDetailsEditPermissions.DEFAULT,
} val canManageKnockRequests: Boolean = false,
val canEditRolesAndPermissions: Boolean = false,
val canEditSecurityAndPrivacy: Boolean = false,
)
@Composable @Composable
private fun getCanSendState(membersState: RoomMembersState, type: StateEventType) = produceState(false, membersState) { private fun getPermissions(): State<Permissions> {
value = room.canSendState(type).getOrElse { false } 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() { 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.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembersState 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.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.roomMembers
import io.element.android.libraries.matrix.api.room.toMatrixUser 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.PowerLevelRoomMemberComparator
import io.element.android.libraries.matrix.ui.room.canInviteAsState
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
@ -58,8 +58,7 @@ class RoomMemberListPresenter(
override fun present(): RoomMemberListState { override fun present(): RoomMemberListState {
var searchQuery by rememberSaveable { mutableStateOf("") } var searchQuery by rememberSaveable { mutableStateOf("") }
val membersState by room.membersStateFlow.collectAsState() val membersState by room.membersStateFlow.collectAsState()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val canInvite by room.permissionsAsState(false) { perms -> perms.canOwnUserInvite() }
val canInvite by room.canInviteAsState(syncUpdateFlow.value)
val roomModerationState = roomMembersModerationPresenter.present() val roomModerationState = roomMembersModerationPresenter.present()
val roomMemberIdentityStates by produceState(persistentMapOf()) { val roomMemberIdentityStates by produceState(persistentMapOf()) {

View file

@ -26,7 +26,7 @@ data class RoomMemberListState(
val moderationState: RoomMemberModerationState, val moderationState: RoomMemberModerationState,
val eventSink: (RoomMemberListEvents) -> Unit, 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 { 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents 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.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.map import io.element.android.libraries.architecture.map
@ -99,8 +100,10 @@ fun aRoomMemberModerationState(
canKick: Boolean = false, canKick: Boolean = false,
): RoomMemberModerationState { ): RoomMemberModerationState {
return object : RoomMemberModerationState { return object : RoomMemberModerationState {
override val canKick: Boolean = canKick override val permissions: RoomMemberModerationPermissions = RoomMemberModerationPermissions(
override val canBan: Boolean = canBan canBan = canBan,
canKick = canKick,
)
override val eventSink: (RoomMemberModerationEvents) -> Unit = {} 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.SessionId
import io.element.android.libraries.matrix.api.core.UserId 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.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.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_AVATAR_URL
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.libraries.matrix.test.A_ROOM_ID 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.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom 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.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.lambda.lambdaError
fun aRoom( fun aRoom(
@ -35,6 +36,7 @@ fun aRoom(
topic: String? = A_ROOM_TOPIC, topic: String? = A_ROOM_TOPIC,
avatarUrl: String? = AN_AVATAR_URL, avatarUrl: String? = AN_AVATAR_URL,
canonicalAlias: RoomAlias? = A_ROOM_ALIAS, canonicalAlias: RoomAlias? = A_ROOM_ALIAS,
roomPermissions: RoomPermissions = FakeRoomPermissions(),
isEncrypted: Boolean = true, isEncrypted: Boolean = true,
isPublic: Boolean = true, isPublic: Boolean = true,
isDirect: Boolean = false, isDirect: Boolean = false,
@ -42,29 +44,20 @@ fun aRoom(
activeMemberCount: Long = 1, activeMemberCount: Long = 1,
joinedMemberCount: Long = 1, joinedMemberCount: Long = 1,
invitedMemberCount: Long = 0, 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() }, userDisplayNameResult: (UserId) -> Result<String?> = { lambdaError() },
userAvatarUrlResult: () -> Result<String?> = { lambdaError() }, userAvatarUrlResult: () -> Result<String?> = { lambdaError() },
canUserJoinCallResult: (UserId) -> Result<Boolean> = { lambdaError() },
getUpdatedMemberResult: (UserId) -> Result<RoomMember> = { lambdaError() }, getUpdatedMemberResult: (UserId) -> Result<RoomMember> = { lambdaError() },
userRoleResult: () -> Result<RoomMember.Role> = { lambdaError() }, userRoleResult: () -> Result<RoomMember.Role> = { lambdaError() },
setIsFavoriteResult: (Boolean) -> Result<Unit> = { lambdaError() }, setIsFavoriteResult: (Boolean) -> Result<Unit> = { lambdaError() },
) = FakeBaseRoom( ) = FakeBaseRoom(
sessionId = sessionId, sessionId = sessionId,
roomId = roomId, roomId = roomId,
canInviteResult = canInviteResult,
canBanResult = canBanResult,
canKickResult = canKickResult,
canSendStateResult = canSendStateResult,
userDisplayNameResult = userDisplayNameResult, userDisplayNameResult = userDisplayNameResult,
userAvatarUrlResult = userAvatarUrlResult, userAvatarUrlResult = userAvatarUrlResult,
canUserJoinCallResult = canUserJoinCallResult,
getUpdatedMemberResult = getUpdatedMemberResult, getUpdatedMemberResult = getUpdatedMemberResult,
userRoleResult = userRoleResult, userRoleResult = userRoleResult,
setIsFavoriteResult = setIsFavoriteResult, setIsFavoriteResult = setIsFavoriteResult,
roomPermissions = roomPermissions,
initialRoomInfo = aRoomInfo( initialRoomInfo = aRoomInfo(
name = displayName, name = displayName,
rawName = rawName, rawName = rawName,
@ -89,6 +82,7 @@ fun aJoinedRoom(
topic: String? = A_ROOM_TOPIC, topic: String? = A_ROOM_TOPIC,
avatarUrl: String? = AN_AVATAR_URL, avatarUrl: String? = AN_AVATAR_URL,
canonicalAlias: RoomAlias? = A_ROOM_ALIAS, canonicalAlias: RoomAlias? = A_ROOM_ALIAS,
roomPermissions: RoomPermissions = FakeRoomPermissions(),
isEncrypted: Boolean = true, isEncrypted: Boolean = true,
isPublic: Boolean = true, isPublic: Boolean = true,
isDirect: Boolean = false, isDirect: Boolean = false,
@ -97,17 +91,12 @@ fun aJoinedRoom(
joinedMemberCount: Long = 1, joinedMemberCount: Long = 1,
invitedMemberCount: Long = 0, invitedMemberCount: Long = 0,
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), 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() }, userDisplayNameResult: (UserId) -> Result<String?> = { lambdaError() },
userAvatarUrlResult: () -> Result<String?> = { lambdaError() }, userAvatarUrlResult: () -> Result<String?> = { lambdaError() },
setNameResult: (String) -> Result<Unit> = { lambdaError() }, setNameResult: (String) -> Result<Unit> = { lambdaError() },
setTopicResult: (String) -> Result<Unit> = { lambdaError() }, setTopicResult: (String) -> Result<Unit> = { lambdaError() },
updateAvatarResult: (String, ByteArray) -> Result<Unit> = { _, _ -> lambdaError() }, updateAvatarResult: (String, ByteArray) -> Result<Unit> = { _, _ -> lambdaError() },
removeAvatarResult: () -> Result<Unit> = { lambdaError() }, removeAvatarResult: () -> Result<Unit> = { lambdaError() },
canUserJoinCallResult: (UserId) -> Result<Boolean> = { lambdaError() },
getUpdatedMemberResult: (UserId) -> Result<RoomMember> = { lambdaError() }, getUpdatedMemberResult: (UserId) -> Result<RoomMember> = { lambdaError() },
userRoleResult: () -> Result<RoomMember.Role> = { lambdaError() }, userRoleResult: () -> Result<RoomMember.Role> = { lambdaError() },
kickUserResult: (UserId, String?) -> Result<Unit> = { _, _ -> lambdaError() }, kickUserResult: (UserId, String?) -> Result<Unit> = { _, _ -> lambdaError() },
@ -132,13 +121,9 @@ fun aJoinedRoom(
baseRoom = aRoom( baseRoom = aRoom(
sessionId = sessionId, sessionId = sessionId,
roomId = roomId, roomId = roomId,
canInviteResult = canInviteResult, roomPermissions = roomPermissions,
canBanResult = canBanResult,
canKickResult = canKickResult,
canSendStateResult = canSendStateResult,
userDisplayNameResult = userDisplayNameResult, userDisplayNameResult = userDisplayNameResult,
userAvatarUrlResult = userAvatarUrlResult, userAvatarUrlResult = userAvatarUrlResult,
canUserJoinCallResult = canUserJoinCallResult,
getUpdatedMemberResult = getUpdatedMemberResult, getUpdatedMemberResult = getUpdatedMemberResult,
userRoleResult = userRoleResult, userRoleResult = userRoleResult,
setIsFavoriteResult = setIsFavoriteResult, 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.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.StateEventType 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.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_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME 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.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService 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.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.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@ -119,9 +121,7 @@ class RoomDetailsPresenterTest {
@Test @Test
fun `present - initial state is created from initial room info`() = runTest { fun `present - initial state is created from initial room info`() = runTest {
val room = aJoinedRoom( val room = aJoinedRoom(
canInviteResult = { Result.success(true) }, roomPermissions = roomPermissions(),
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
) )
val presenter = createRoomDetailsPresenter(room) val presenter = createRoomDetailsPresenter(room)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
@ -148,9 +148,7 @@ class RoomDetailsPresenterTest {
pinnedEventIds = listOf(AN_EVENT_ID), pinnedEventIds = listOf(AN_EVENT_ID),
) )
val room = aJoinedRoom( val room = aJoinedRoom(
canInviteResult = { Result.success(true) }, roomPermissions = roomPermissions(),
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
).apply { ).apply {
givenRoomInfo(roomInfo) givenRoomInfo(roomInfo)
} }
@ -170,9 +168,7 @@ class RoomDetailsPresenterTest {
fun `present - initial state with no room name`() = runTest { fun `present - initial state with no room name`() = runTest {
val room = aJoinedRoom( val room = aJoinedRoom(
displayName = "", displayName = "",
canInviteResult = { Result.success(true) }, roomPermissions = roomPermissions(),
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
) )
val presenter = createRoomDetailsPresenter(room) val presenter = createRoomDetailsPresenter(room)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
@ -188,9 +184,7 @@ class RoomDetailsPresenterTest {
val myRoomMember = aRoomMember(A_SESSION_ID) val myRoomMember = aRoomMember(A_SESSION_ID)
val otherRoomMember = aRoomMember(A_USER_ID_2) val otherRoomMember = aRoomMember(A_USER_ID_2)
val room = aJoinedRoom( val room = aJoinedRoom(
canInviteResult = { Result.success(true) }, roomPermissions = roomPermissions(),
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
getUpdatedMemberResult = { userId -> getUpdatedMemberResult = { userId ->
when (userId) { when (userId) {
A_SESSION_ID -> Result.success(myRoomMember) A_SESSION_ID -> Result.success(myRoomMember)
@ -225,9 +219,9 @@ class RoomDetailsPresenterTest {
@Test @Test
fun `present - initial state when user can invite others to room`() = runTest { fun `present - initial state when user can invite others to room`() = runTest {
val room = aJoinedRoom( val room = aJoinedRoom(
canInviteResult = { Result.success(true) }, roomPermissions = roomPermissions(
canUserJoinCallResult = { Result.success(true) }, canInvite = true,
canSendStateResult = { _, _ -> Result.success(true) }, ),
) )
val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers()) val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
@ -243,26 +237,9 @@ class RoomDetailsPresenterTest {
@Test @Test
fun `present - initial state when user can not invite others to room`() = runTest { fun `present - initial state when user can not invite others to room`() = runTest {
val room = aJoinedRoom( val room = aJoinedRoom(
canInviteResult = { Result.success(false) }, roomPermissions = roomPermissions(
canKickResult = { Result.success(false) }, canInvite = 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) },
) )
val presenter = createRoomDetailsPresenter(room) val presenter = createRoomDetailsPresenter(room)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
@ -275,17 +252,11 @@ class RoomDetailsPresenterTest {
@Test @Test
fun `present - initial state when user can edit one attribute`() = runTest { fun `present - initial state when user can edit one attribute`() = runTest {
val room = aJoinedRoom( val room = aJoinedRoom(
canSendStateResult = { _, stateEventType -> roomPermissions = roomPermissions(
when (stateEventType) { canChangeName = true,
StateEventType.ROOM_TOPIC -> Result.success(true) canChangeTopic = false,
StateEventType.ROOM_NAME -> Result.success(false) canChangeAvatar = false,
else -> Result.failure(RuntimeException("Whelp")) ),
}
},
canBanResult = { Result.success(false) },
canKickResult = { Result.success(false) },
canInviteResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) },
) )
val presenter = createRoomDetailsPresenter(room) val presenter = createRoomDetailsPresenter(room)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
@ -303,18 +274,7 @@ class RoomDetailsPresenterTest {
val myRoomMember = aRoomMember(A_SESSION_ID) val myRoomMember = aRoomMember(A_SESSION_ID)
val otherRoomMember = aRoomMember(A_USER_ID_2) val otherRoomMember = aRoomMember(A_USER_ID_2)
val room = aJoinedRoom( val room = aJoinedRoom(
canSendStateResult = { _, stateEventType -> roomPermissions = roomPermissions(),
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) },
getUpdatedMemberResult = { userId -> getUpdatedMemberResult = { userId ->
when (userId) { when (userId) {
A_SESSION_ID -> Result.success(myRoomMember) A_SESSION_ID -> Result.success(myRoomMember)
@ -354,18 +314,9 @@ class RoomDetailsPresenterTest {
val room = aJoinedRoom( val room = aJoinedRoom(
isDirect = true, isDirect = true,
topic = null, topic = null,
canSendStateResult = { _, stateEventType -> roomPermissions = roomPermissions(),
when (stateEventType) {
StateEventType.ROOM_AVATAR,
StateEventType.ROOM_TOPIC,
StateEventType.ROOM_NAME -> Result.success(true)
else -> Result.failure(RuntimeException("Whelp"))
}
},
userDisplayNameResult = { Result.success(A_USER_NAME) }, userDisplayNameResult = { Result.success(A_USER_NAME) },
userAvatarUrlResult = { Result.success(AN_AVATAR_URL) }, userAvatarUrlResult = { Result.success(AN_AVATAR_URL) },
canInviteResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
getUpdatedMemberResult = { userId -> getUpdatedMemberResult = { userId ->
when (userId) { when (userId) {
A_SESSION_ID -> Result.success(myRoomMember) A_SESSION_ID -> Result.success(myRoomMember)
@ -400,24 +351,11 @@ class RoomDetailsPresenterTest {
@Test @Test
fun `present - initial state when user can edit all attributes`() = runTest { fun `present - initial state when user can edit all attributes`() = runTest {
val room = aJoinedRoom( val room = aJoinedRoom(
canSendStateResult = { _, stateEventType -> roomPermissions = roomPermissions(
when (stateEventType) { canChangeAvatar = true,
StateEventType.ROOM_TOPIC, canChangeName = true,
StateEventType.ROOM_NAME, canChangeTopic = true,
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) },
) )
val presenter = createRoomDetailsPresenter(room) val presenter = createRoomDetailsPresenter(room)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
@ -433,24 +371,11 @@ class RoomDetailsPresenterTest {
@Test @Test
fun `present - initial state when user can edit no attributes`() = runTest { fun `present - initial state when user can edit no attributes`() = runTest {
val room = aJoinedRoom( val room = aJoinedRoom(
canSendStateResult = { _, stateEventType -> roomPermissions = roomPermissions(
when (stateEventType) { canChangeAvatar = false,
StateEventType.ROOM_TOPIC, canChangeName = false,
StateEventType.ROOM_NAME, canChangeTopic = false,
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) },
) )
val presenter = createRoomDetailsPresenter(room) val presenter = createRoomDetailsPresenter(room)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { 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 { fun `present - topic state is hidden when no topic and user has no permission`() = runTest {
val room = aJoinedRoom( val room = aJoinedRoom(
topic = null, topic = null,
canSendStateResult = { _, stateEventType -> roomPermissions = roomPermissions(
when (stateEventType) { canChangeTopic = false
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) },
) )
val presenter = createRoomDetailsPresenter(room) val presenter = createRoomDetailsPresenter(room)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { 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 { fun `present - topic state is 'can add topic' when no topic and user has permission`() = runTest {
val room = aJoinedRoom( val room = aJoinedRoom(
topic = null, topic = null,
canSendStateResult = { _, stateEventType -> roomPermissions = roomPermissions(),
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) },
).apply { ).apply {
givenRoomInfo(aRoomInfo(topic = null)) givenRoomInfo(aRoomInfo(topic = null))
} }
@ -534,9 +427,7 @@ class RoomDetailsPresenterTest {
fun `present - leave room event is passed on to leave room presenter`() = runTest { fun `present - leave room event is passed on to leave room presenter`() = runTest {
val leaveRoomEventRecorder = EventsRecorder<LeaveRoomEvent>() val leaveRoomEventRecorder = EventsRecorder<LeaveRoomEvent>()
val room = aJoinedRoom( val room = aJoinedRoom(
canInviteResult = { Result.success(true) }, roomPermissions = roomPermissions(),
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
) )
val presenter = createRoomDetailsPresenter( val presenter = createRoomDetailsPresenter(
room = room, room = room,
@ -555,9 +446,7 @@ class RoomDetailsPresenterTest {
val notificationSettingsService = FakeNotificationSettingsService() val notificationSettingsService = FakeNotificationSettingsService()
val room = aJoinedRoom( val room = aJoinedRoom(
notificationSettingsService = notificationSettingsService, notificationSettingsService = notificationSettingsService,
canInviteResult = { Result.success(true) }, roomPermissions = roomPermissions(),
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
) )
val presenter = createRoomDetailsPresenter( val presenter = createRoomDetailsPresenter(
room = room, room = room,
@ -584,9 +473,7 @@ class RoomDetailsPresenterTest {
FakeNotificationSettingsService(initialRoomMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) FakeNotificationSettingsService(initialRoomMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
val room = aJoinedRoom( val room = aJoinedRoom(
notificationSettingsService = notificationSettingsService, notificationSettingsService = notificationSettingsService,
canInviteResult = { Result.success(true) }, roomPermissions = roomPermissions(),
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
) )
val presenter = createRoomDetailsPresenter( val presenter = createRoomDetailsPresenter(
room = room, room = room,
@ -612,9 +499,7 @@ class RoomDetailsPresenterTest {
) )
val room = aJoinedRoom( val room = aJoinedRoom(
notificationSettingsService = notificationSettingsService, notificationSettingsService = notificationSettingsService,
canInviteResult = { Result.success(true) }, roomPermissions = roomPermissions(),
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
) )
val presenter = createRoomDetailsPresenter( val presenter = createRoomDetailsPresenter(
room = room, room = room,
@ -637,9 +522,7 @@ class RoomDetailsPresenterTest {
val setIsFavoriteResult = lambdaRecorder<Boolean, Result<Unit>> { _ -> Result.success(Unit) } val setIsFavoriteResult = lambdaRecorder<Boolean, Result<Unit>> { _ -> Result.success(Unit) }
val room = aJoinedRoom( val room = aJoinedRoom(
setIsFavoriteResult = setIsFavoriteResult, setIsFavoriteResult = setIsFavoriteResult,
canInviteResult = { Result.success(true) }, roomPermissions = roomPermissions(),
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
) )
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val presenter = val presenter =
@ -665,9 +548,7 @@ class RoomDetailsPresenterTest {
@Test @Test
fun `present - changes in room info updates the is favorite flag`() = runTest { fun `present - changes in room info updates the is favorite flag`() = runTest {
val room = aJoinedRoom( val room = aJoinedRoom(
canInviteResult = { Result.success(true) }, roomPermissions = roomPermissions(),
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
) )
val presenter = createRoomDetailsPresenter(room = room) val presenter = createRoomDetailsPresenter(room = room)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
@ -686,9 +567,7 @@ class RoomDetailsPresenterTest {
@Test @Test
fun `present - show knock requests`() = runTest { fun `present - show knock requests`() = runTest {
val room = aJoinedRoom( val room = aJoinedRoom(
canInviteResult = { Result.success(true) }, roomPermissions = roomPermissions(),
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
joinRule = JoinRule.Knock, joinRule = JoinRule.Knock,
) )
val featureFlagService = FakeFeatureFlagService( val featureFlagService = FakeFeatureFlagService(
@ -712,9 +591,7 @@ class RoomDetailsPresenterTest {
@Test @Test
fun `present - show security and privacy`() = runTest { fun `present - show security and privacy`() = runTest {
val room = aJoinedRoom( val room = aJoinedRoom(
canInviteResult = { Result.success(true) }, roomPermissions = roomPermissions(),
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
) )
val featureFlagService = FakeFeatureFlagService() val featureFlagService = FakeFeatureFlagService()
val presenter = createRoomDetailsPresenter(room = room, featureFlagService = featureFlagService) val presenter = createRoomDetailsPresenter(room = room, featureFlagService = featureFlagService)
@ -729,9 +606,7 @@ class RoomDetailsPresenterTest {
@Test @Test
fun `present - show debug info`() = runTest { fun `present - show debug info`() = runTest {
val room = aJoinedRoom( val room = aJoinedRoom(
canInviteResult = { Result.success(true) }, roomPermissions = roomPermissions(),
canUserJoinCallResult = { Result.success(true) },
canSendStateResult = { _, _ -> Result.success(true) },
) )
val inMemoryAppPreferencesStore = InMemoryAppPreferencesStore( val inMemoryAppPreferencesStore = InMemoryAppPreferencesStore(
isDeveloperModeEnabled = true, 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.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers 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.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMembersState 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.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.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom 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.aRoomInfo
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers 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 { fun `present - asynchronously sets canInvite when user does not have correct power level`() = runTest {
val presenter = createPresenter( val presenter = createPresenter(
joinedRoom = createFakeJoinedRoom( joinedRoom = createFakeJoinedRoom(
canInviteResult = { Result.success(false) }, canInvite = 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")) },
) )
) )
presenter.test { presenter.test {
@ -207,12 +194,14 @@ class RoomMemberListPresenterTest {
private fun createFakeJoinedRoom( private fun createFakeJoinedRoom(
updateMembersResult: () -> Unit = { }, updateMembersResult: () -> Unit = { },
canInviteResult: (UserId) -> Result<Boolean> = { Result.success(true) }, canInvite: Boolean = true,
): FakeJoinedRoom { ): FakeJoinedRoom {
return FakeJoinedRoom( return FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
updateMembersResult = updateMembersResult, updateMembersResult = updateMembersResult,
canInviteResult = canInviteResult, roomPermissions = FakeRoomPermissions(
canInvite = canInvite,
),
).apply { ).apply {
// Needed to avoid discarding the loaded members as a partial and invalid result // Needed to avoid discarding the loaded members as a partial and invalid result
givenRoomInfo(aRoomInfo(joinedMembersCount = 2)) 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.compose.runtime.setValue
import androidx.core.net.toUri import androidx.core.net.toUri
import dev.zacsweers.metro.Inject 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.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter 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.extensions.runCatchingExceptions
import io.element.android.libraries.core.mimetype.MimeTypes 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.JoinedRoom
import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
@ -58,7 +59,6 @@ class RoomDetailsEditPresenter(
@Composable @Composable
override fun present(): RoomDetailsEditState { override fun present(): RoomDetailsEditState {
val cameraPermissionState = cameraPermissionPresenter.present() val cameraPermissionState = cameraPermissionPresenter.present()
val roomSyncUpdateFlow = room.syncUpdateFlow.collectAsState()
val roomInfo by room.roomInfoFlow.collectAsState() val roomInfo by room.roomInfoFlow.collectAsState()
val roomAvatarUri = roomInfo.avatarUrl val roomAvatarUri = roomInfo.avatarUrl
var roomAvatarUriEdited by rememberSaveable { mutableStateOf<String?>(null) } var roomAvatarUriEdited by rememberSaveable { mutableStateOf<String?>(null) }
@ -93,14 +93,8 @@ class RoomDetailsEditPresenter(
} }
} }
var canChangeName by remember { mutableStateOf(false) } val permissions by room.permissionsAsState(RoomDetailsEditPermissions.DEFAULT) { perms ->
var canChangeTopic by remember { mutableStateOf(false) } perms.roomDetailsEditPermissions()
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 cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
@ -181,11 +175,11 @@ class RoomDetailsEditPresenter(
return RoomDetailsEditState( return RoomDetailsEditState(
roomId = room.roomId, roomId = room.roomId,
roomRawName = roomRawNameEdited, roomRawName = roomRawNameEdited,
canChangeName = canChangeName, canChangeName = permissions.canEditName,
roomTopic = roomTopicEdited, roomTopic = roomTopicEdited,
canChangeTopic = canChangeTopic, canChangeTopic = permissions.canEditTopic,
roomAvatarUrl = roomAvatarUriEdited, roomAvatarUrl = roomAvatarUriEdited,
canChangeAvatar = canChangeAvatar, canChangeAvatar = permissions.canEditAvatar,
avatarActions = avatarActions, avatarActions = avatarActions,
saveButtonEnabled = saveButtonEnabled, saveButtonEnabled = saveButtonEnabled,
saveAction = saveAction.value, 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.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.mimetype.MimeTypes 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.JoinedRoom
import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.test.AN_AVATAR_URL 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.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom 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.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.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
@ -102,7 +102,6 @@ class RoomDetailsEditPresenterTest {
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
displayName = A_ROOM_NAME, displayName = A_ROOM_NAME,
rawName = A_ROOM_RAW_NAME, rawName = A_ROOM_RAW_NAME,
canSendStateResult = { _, _ -> Result.success(true) }
) )
val deleteCallback = lambdaRecorder<Uri?, Unit> {} val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createRoomDetailsEditPresenter( val presenter = createRoomDetailsEditPresenter(
@ -127,17 +126,15 @@ class RoomDetailsEditPresenterTest {
@Test @Test
fun `present - sets canChangeName if user has permission`() = runTest { fun `present - sets canChangeName if user has permission`() = runTest {
val room = FakeJoinedRoom( val room = aJoinedRoom(
FakeBaseRoom( canSendState = { stateEventType ->
canSendStateResult = { _, stateEventType -> when (stateEventType) {
when (stateEventType) { StateEventType.ROOM_NAME -> true
StateEventType.ROOM_NAME -> Result.success(true) StateEventType.ROOM_AVATAR -> false
StateEventType.ROOM_AVATAR -> Result.success(false) StateEventType.ROOM_TOPIC -> false
StateEventType.ROOM_TOPIC -> Result.failure(RuntimeException("Oops")) else -> lambdaError()
else -> lambdaError() }
} }
},
)
) )
val deleteCallback = lambdaRecorder<Uri?, Unit> {} val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createRoomDetailsEditPresenter( val presenter = createRoomDetailsEditPresenter(
@ -163,11 +160,11 @@ class RoomDetailsEditPresenterTest {
fun `present - sets canChangeAvatar if user has permission`() = runTest { fun `present - sets canChangeAvatar if user has permission`() = runTest {
val room = aJoinedRoom( val room = aJoinedRoom(
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, stateEventType -> canSendState = { stateEventType ->
when (stateEventType) { when (stateEventType) {
StateEventType.ROOM_NAME -> Result.success(false) StateEventType.ROOM_NAME -> false
StateEventType.ROOM_AVATAR -> Result.success(true) StateEventType.ROOM_AVATAR -> true
StateEventType.ROOM_TOPIC -> Result.failure(RuntimeException("Oops")) StateEventType.ROOM_TOPIC -> false
else -> lambdaError() else -> lambdaError()
} }
} }
@ -195,11 +192,11 @@ class RoomDetailsEditPresenterTest {
fun `present - sets canChangeTopic if user has permission`() = runTest { fun `present - sets canChangeTopic if user has permission`() = runTest {
val room = aJoinedRoom( val room = aJoinedRoom(
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, stateEventType -> canSendState = { stateEventType ->
when (stateEventType) { when (stateEventType) {
StateEventType.ROOM_NAME -> Result.success(false) StateEventType.ROOM_NAME -> false
StateEventType.ROOM_AVATAR -> Result.failure(RuntimeException("Oops")) StateEventType.ROOM_AVATAR -> false
StateEventType.ROOM_TOPIC -> Result.success(true) StateEventType.ROOM_TOPIC -> true
else -> lambdaError() else -> lambdaError()
} }
} }
@ -229,7 +226,6 @@ class RoomDetailsEditPresenterTest {
topic = "My topic", topic = "My topic",
displayName = "Name", displayName = "Name",
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, _ -> Result.success(true) }
) )
val deleteCallback = lambdaRecorder<Uri?, Unit> {} val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createRoomDetailsEditPresenter( val presenter = createRoomDetailsEditPresenter(
@ -274,7 +270,6 @@ class RoomDetailsEditPresenterTest {
topic = "My topic", topic = "My topic",
displayName = "Name", displayName = "Name",
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, _ -> Result.success(true) }
) )
fakePickerProvider.givenResult(anotherAvatarUri) fakePickerProvider.givenResult(anotherAvatarUri)
val deleteCallback = lambdaRecorder<Uri?, Unit> {} val deleteCallback = lambdaRecorder<Uri?, Unit> {}
@ -298,7 +293,6 @@ class RoomDetailsEditPresenterTest {
topic = "My topic", topic = "My topic",
displayName = "Name", displayName = "Name",
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, _ -> Result.success(true) }
) )
fakePickerProvider.givenResult(anotherAvatarUri) fakePickerProvider.givenResult(anotherAvatarUri)
val fakePermissionsPresenter = FakePermissionsPresenter() val fakePermissionsPresenter = FakePermissionsPresenter()
@ -339,7 +333,6 @@ class RoomDetailsEditPresenterTest {
topic = "My topic", topic = "My topic",
displayName = "Name", displayName = "Name",
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, _ -> Result.success(true) }
) )
fakePickerProvider.givenResult(roomAvatarUri) fakePickerProvider.givenResult(roomAvatarUri)
val deleteCallback = lambdaRecorder<Uri?, Unit> {} val deleteCallback = lambdaRecorder<Uri?, Unit> {}
@ -389,7 +382,6 @@ class RoomDetailsEditPresenterTest {
topic = null, topic = null,
displayName = "fallback", displayName = "fallback",
avatarUrl = null, avatarUrl = null,
canSendStateResult = { _, _ -> Result.success(true) }
) )
fakePickerProvider.givenResult(roomAvatarUri) fakePickerProvider.givenResult(roomAvatarUri)
val deleteCallback = lambdaRecorder<Uri?, Unit> {} val deleteCallback = lambdaRecorder<Uri?, Unit> {}
@ -445,7 +437,6 @@ class RoomDetailsEditPresenterTest {
setNameResult = setNameResult, setNameResult = setNameResult,
setTopicResult = setTopicResult, setTopicResult = setTopicResult,
removeAvatarResult = removeAvatarResult, removeAvatarResult = removeAvatarResult,
canSendStateResult = { _, _ -> Result.success(true) }
) )
val deleteCallback = lambdaRecorder<Uri?, Unit> {} val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createRoomDetailsEditPresenter( val presenter = createRoomDetailsEditPresenter(
@ -471,7 +462,6 @@ class RoomDetailsEditPresenterTest {
topic = "My topic", topic = "My topic",
displayName = "Name", displayName = "Name",
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, _ -> Result.success(true) }
) )
val deleteCallback = lambdaRecorder<Uri?, Unit> {} val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createRoomDetailsEditPresenter( val presenter = createRoomDetailsEditPresenter(
@ -493,7 +483,6 @@ class RoomDetailsEditPresenterTest {
topic = null, topic = null,
displayName = "Name", displayName = "Name",
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, _ -> Result.success(true) }
) )
val deleteCallback = lambdaRecorder<Uri?, Unit> {} val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createRoomDetailsEditPresenter( val presenter = createRoomDetailsEditPresenter(
@ -515,7 +504,6 @@ class RoomDetailsEditPresenterTest {
topic = "My topic", topic = "My topic",
displayName = "Name", displayName = "Name",
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, _ -> Result.success(true) }
) )
val deleteCallback = lambdaRecorder<Uri?, Unit> {} val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createRoomDetailsEditPresenter( val presenter = createRoomDetailsEditPresenter(
@ -539,7 +527,6 @@ class RoomDetailsEditPresenterTest {
displayName = "Name", displayName = "Name",
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
updateAvatarResult = updateAvatarResult, updateAvatarResult = updateAvatarResult,
canSendStateResult = { _, _ -> Result.success(true) }
) )
givenPickerReturnsFile() givenPickerReturnsFile()
val deleteCallback = lambdaRecorder<Uri?, Unit> {} val deleteCallback = lambdaRecorder<Uri?, Unit> {}
@ -566,7 +553,6 @@ class RoomDetailsEditPresenterTest {
topic = "My topic", topic = "My topic",
displayName = "Name", displayName = "Name",
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
canSendStateResult = { _, _ -> Result.success(true) }
) )
fakePickerProvider.givenResult(anotherAvatarUri) fakePickerProvider.givenResult(anotherAvatarUri)
fakeMediaPreProcessor.givenResult(Result.failure(RuntimeException("Oh no"))) fakeMediaPreProcessor.givenResult(Result.failure(RuntimeException("Oh no")))
@ -591,7 +577,6 @@ class RoomDetailsEditPresenterTest {
displayName = "Name", displayName = "Name",
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
setNameResult = { Result.failure(RuntimeException("!")) }, setNameResult = { Result.failure(RuntimeException("!")) },
canSendStateResult = { _, _ -> Result.success(true) }
) )
saveAndAssertFailure(room, RoomDetailsEditEvent.UpdateRoomName("New name"), deleteCallbackNumberOfInvocation = 1) saveAndAssertFailure(room, RoomDetailsEditEvent.UpdateRoomName("New name"), deleteCallbackNumberOfInvocation = 1)
} }
@ -603,7 +588,6 @@ class RoomDetailsEditPresenterTest {
displayName = "Name", displayName = "Name",
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
setTopicResult = { Result.failure(RuntimeException("!")) }, setTopicResult = { Result.failure(RuntimeException("!")) },
canSendStateResult = { _, _ -> Result.success(true) }
) )
saveAndAssertFailure(room, RoomDetailsEditEvent.UpdateRoomTopic("New topic"), deleteCallbackNumberOfInvocation = 1) saveAndAssertFailure(room, RoomDetailsEditEvent.UpdateRoomTopic("New topic"), deleteCallbackNumberOfInvocation = 1)
} }
@ -615,7 +599,6 @@ class RoomDetailsEditPresenterTest {
displayName = "Name", displayName = "Name",
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
removeAvatarResult = { Result.failure(RuntimeException("!")) }, removeAvatarResult = { Result.failure(RuntimeException("!")) },
canSendStateResult = { _, _ -> Result.success(true) }
) )
saveAndAssertFailure(room, RoomDetailsEditEvent.HandleAvatarAction(AvatarAction.Remove), deleteCallbackNumberOfInvocation = 2) saveAndAssertFailure(room, RoomDetailsEditEvent.HandleAvatarAction(AvatarAction.Remove), deleteCallbackNumberOfInvocation = 2)
} }
@ -628,7 +611,6 @@ class RoomDetailsEditPresenterTest {
displayName = "Name", displayName = "Name",
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
updateAvatarResult = { _, _ -> Result.failure(RuntimeException("!")) }, updateAvatarResult = { _, _ -> Result.failure(RuntimeException("!")) },
canSendStateResult = { _, _ -> Result.success(true) }
) )
saveAndAssertFailure(room, RoomDetailsEditEvent.HandleAvatarAction(AvatarAction.ChoosePhoto), deleteCallbackNumberOfInvocation = 2) saveAndAssertFailure(room, RoomDetailsEditEvent.HandleAvatarAction(AvatarAction.ChoosePhoto), deleteCallbackNumberOfInvocation = 2)
} }
@ -641,7 +623,6 @@ class RoomDetailsEditPresenterTest {
displayName = "Name", displayName = "Name",
avatarUrl = AN_AVATAR_URL, avatarUrl = AN_AVATAR_URL,
setTopicResult = { Result.failure(RuntimeException("!")) }, setTopicResult = { Result.failure(RuntimeException("!")) },
canSendStateResult = { _, _ -> Result.success(true) }
) )
val deleteCallback = lambdaRecorder<Uri?, Unit> {} val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createRoomDetailsEditPresenter( val presenter = createRoomDetailsEditPresenter(
@ -663,7 +644,6 @@ class RoomDetailsEditPresenterTest {
fun `present - leave without saving - cancel`() = runTest { fun `present - leave without saving - cancel`() = runTest {
val room = aJoinedRoom( val room = aJoinedRoom(
displayName = "Name", displayName = "Name",
canSendStateResult = { _, _ -> Result.success(true) }
) )
val deleteCallback = lambdaRecorder<Uri?, Unit> {} val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createRoomDetailsEditPresenter( val presenter = createRoomDetailsEditPresenter(
@ -693,7 +673,6 @@ class RoomDetailsEditPresenterTest {
fun `present - leave no changes, no confirmation`() = runTest { fun `present - leave no changes, no confirmation`() = runTest {
val room = aJoinedRoom( val room = aJoinedRoom(
displayName = "Name", displayName = "Name",
canSendStateResult = { _, _ -> Result.success(true) }
) )
val presenter = createRoomDetailsEditPresenter( val presenter = createRoomDetailsEditPresenter(
room = room, room = room,
@ -711,7 +690,7 @@ class RoomDetailsEditPresenterTest {
fun `present - leave without saving - confirm`() = runTest { fun `present - leave without saving - confirm`() = runTest {
val room = aJoinedRoom( val room = aJoinedRoom(
displayName = "Name", displayName = "Name",
canSendStateResult = { _, _ -> Result.success(true) } canSendState = { _ -> true }
) )
val presenter = createRoomDetailsEditPresenter( val presenter = createRoomDetailsEditPresenter(
room = room, room = room,
@ -782,11 +761,13 @@ class RoomDetailsEditPresenterTest {
setTopicResult: (String) -> Result<Unit> = { Result.success(Unit) }, setTopicResult: (String) -> Result<Unit> = { Result.success(Unit) },
updateAvatarResult: (String, ByteArray) -> Result<Unit> = { _, _ -> Result.success(Unit) }, updateAvatarResult: (String, ByteArray) -> Result<Unit> = { _, _ -> Result.success(Unit) },
removeAvatarResult: () -> Result<Unit> = { Result.success(Unit) }, removeAvatarResult: () -> Result<Unit> = { Result.success(Unit) },
canSendStateResult: (UserId, StateEventType) -> Result<Boolean>, canSendState: (StateEventType) -> Boolean = { true },
): JoinedRoom { ): JoinedRoom {
return FakeJoinedRoom( return FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canSendStateResult = canSendStateResult, roomPermissions = FakeRoomPermissions(
canSendState = canSendState,
),
initialRoomInfo = aRoomInfo( initialRoomInfo = aRoomInfo(
name = displayName, name = displayName,
topic = topic, 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 @Immutable
interface RoomMemberModerationState { interface RoomMemberModerationState {
val canKick: Boolean val permissions: RoomMemberModerationPermissions
val canBan: Boolean
val eventSink: (RoomMemberModerationEvents) -> Unit 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.ModerationActionState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents 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.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class InternalRoomMemberModerationState( data class InternalRoomMemberModerationState(
override val canKick: Boolean, override val permissions: RoomMemberModerationPermissions,
override val canBan: Boolean,
val selectedUser: MatrixUser?, val selectedUser: MatrixUser?,
val actions: ImmutableList<ModerationActionState>, val actions: ImmutableList<ModerationActionState>,
val kickUserAsyncAction: AsyncAction<Unit>, 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.ModerationAction
import io.element.android.features.roommembermoderation.api.ModerationActionState import io.element.android.features.roommembermoderation.api.ModerationActionState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents 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.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
@ -83,8 +84,7 @@ fun anAlice() = MatrixUser(
) )
fun aRoomMembersModerationState( fun aRoomMembersModerationState(
canKick: Boolean = false, permissions: RoomMemberModerationPermissions = RoomMemberModerationPermissions.DEFAULT,
canBan: Boolean = false,
selectedUser: MatrixUser? = null, selectedUser: MatrixUser? = null,
actions: List<ModerationActionState> = emptyList(), actions: List<ModerationActionState> = emptyList(),
kickUserAsyncAction: AsyncAction<Unit> = AsyncAction.Uninitialized, kickUserAsyncAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
@ -92,8 +92,7 @@ fun aRoomMembersModerationState(
unbanUserAsyncAction: AsyncAction<Unit> = AsyncAction.Uninitialized, unbanUserAsyncAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (RoomMemberModerationEvents) -> Unit = {}, eventSink: (RoomMemberModerationEvents) -> Unit = {},
) = InternalRoomMemberModerationState( ) = InternalRoomMemberModerationState(
canKick = canKick, permissions = permissions,
canBan = canBan,
selectedUser = selectedUser, selectedUser = selectedUser,
actions = actions.toImmutableList(), actions = actions.toImmutableList(),
kickUserAsyncAction = kickUserAsyncAction, 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.ModerationAction
import io.element.android.features.roommembermoderation.api.ModerationActionState import io.element.android.features.roommembermoderation.api.ModerationActionState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents 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.RoomMemberModerationState
import io.element.android.features.roommembermoderation.api.roomMemberModerationPermissions
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState 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.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState 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.roomMembers
import io.element.android.libraries.matrix.api.user.MatrixUser 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.libraries.matrix.ui.room.userPowerLevelAsState
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -56,8 +57,9 @@ class RoomMemberModerationPresenter(
override fun present(): RoomMemberModerationState { override fun present(): RoomMemberModerationState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val canBan = room.canBanAsState(syncUpdateFlow.value) val permissions by room.permissionsAsState(RoomMemberModerationPermissions.DEFAULT) { perms ->
val canKick = room.canKickAsState(syncUpdateFlow.value) perms.roomMemberModerationPermissions()
}
val currentUserMemberPowerLevel = room.userPowerLevelAsState(syncUpdateFlow.value) val currentUserMemberPowerLevel = room.userPowerLevelAsState(syncUpdateFlow.value)
val kickUserAsyncAction = val kickUserAsyncAction =
@ -80,8 +82,7 @@ class RoomMemberModerationPresenter(
} }
moderationActions.value = computeModerationActions( moderationActions.value = computeModerationActions(
member = member, member = member,
canKick = canKick.value, permissions = permissions,
canBan = canBan.value,
currentUserMemberPowerLevel = currentUserMemberPowerLevel.value, currentUserMemberPowerLevel = currentUserMemberPowerLevel.value,
) )
} }
@ -134,8 +135,7 @@ class RoomMemberModerationPresenter(
} }
return InternalRoomMemberModerationState( return InternalRoomMemberModerationState(
canKick = canKick.value, permissions = permissions,
canBan = canBan.value,
selectedUser = selectedUser, selectedUser = selectedUser,
actions = moderationActions.value, actions = moderationActions.value,
kickUserAsyncAction = kickUserAsyncAction.value, kickUserAsyncAction = kickUserAsyncAction.value,
@ -147,8 +147,7 @@ class RoomMemberModerationPresenter(
private fun computeModerationActions( private fun computeModerationActions(
member: RoomMember?, member: RoomMember?,
canKick: Boolean, permissions: RoomMemberModerationPermissions,
canBan: Boolean,
currentUserMemberPowerLevel: Long, currentUserMemberPowerLevel: Long,
): ImmutableList<ModerationActionState> { ): ImmutableList<ModerationActionState> {
return buildList { return buildList {
@ -158,11 +157,11 @@ class RoomMemberModerationPresenter(
val canModerateThisUser = currentUserMemberPowerLevel > targetMemberPowerLevel val canModerateThisUser = currentUserMemberPowerLevel > targetMemberPowerLevel
// Assume the member is joined when it's unknown // Assume the member is joined when it's unknown
val membership = member?.membership ?: RoomMembershipState.JOIN val membership = member?.membership ?: RoomMembershipState.JOIN
if (canKick) { if (permissions.canKick) {
val isKickEnabled = canModerateThisUser && membership.isActive() val isKickEnabled = canModerateThisUser && membership.isActive()
add(ModerationActionState(action = ModerationAction.KickUser, isEnabled = isKickEnabled)) add(ModerationActionState(action = ModerationAction.KickUser, isEnabled = isKickEnabled))
} }
if (canBan) { if (permissions.canBan) {
if (membership == RoomMembershipState.BAN) { if (membership == RoomMembershipState.BAN) {
add(ModerationActionState(action = ModerationAction.UnbanUser, isEnabled = canModerateThisUser)) add(ModerationActionState(action = ModerationAction.UnbanUser, isEnabled = canModerateThisUser))
} else { } 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.ModerationAction
import io.element.android.features.roommembermoderation.api.ModerationActionState import io.element.android.features.roommembermoderation.api.ModerationActionState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents 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.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.coroutine.CoroutineDispatchers 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.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom 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.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.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
@ -48,8 +50,7 @@ class RoomMemberModerationPresenterTest {
val room = aJoinedRoom() val room = aJoinedRoom()
createRoomMemberModerationPresenter(room = room).test { createRoomMemberModerationPresenter(room = room).test {
val initialState = awaitState() val initialState = awaitState()
assertThat(initialState.canKick).isFalse() assertThat(initialState.permissions).isEqualTo(RoomMemberModerationPermissions.DEFAULT)
assertThat(initialState.canBan).isFalse()
assertThat(initialState.selectedUser).isNull() assertThat(initialState.selectedUser).isNull()
assertThat(initialState.banUserAsyncAction).isEqualTo(AsyncAction.Uninitialized) assertThat(initialState.banUserAsyncAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(initialState.kickUserAsyncAction).isEqualTo(AsyncAction.Uninitialized) assertThat(initialState.kickUserAsyncAction).isEqualTo(AsyncAction.Uninitialized)
@ -355,8 +356,10 @@ class RoomMemberModerationPresenterTest {
banUserResult = { _, _ -> banUserResult }, banUserResult = { _, _ -> banUserResult },
unBanUserResult = { _, _ -> unBanUserResult }, unBanUserResult = { _, _ -> unBanUserResult },
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canBanResult = { _ -> Result.success(canBan) }, roomPermissions = FakeRoomPermissions(
canKickResult = { _ -> Result.success(canKick) }, canBan = canBan,
canKick = canKick
),
userRoleResult = { Result.success(myUserRole) }, userRoleResult = { Result.success(myUserRole) },
updateMembersResult = { Result.success(Unit) } updateMembersResult = { Result.success(Unit) }
), ),

View file

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

View file

@ -21,7 +21,8 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject 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.SecurityAndPrivacyNavigator
import io.element.android.features.securityandprivacy.impl.editroomaddress.matchesServer import io.element.android.features.securityandprivacy.impl.editroomaddress.matchesServer
import io.element.android.libraries.architecture.AsyncAction 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.RoomInfo
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility 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.join.JoinRule
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async import kotlinx.coroutines.async
@ -65,7 +67,6 @@ class SecurityAndPrivacyPresenter(
}.collectAsState(false) }.collectAsState(false)
val saveAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) } val saveAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val homeserverName = remember { matrixClient.userIdServerName() } val homeserverName = remember { matrixClient.userIdServerName() }
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val roomInfo by room.roomInfoFlow.collectAsState() val roomInfo by room.roomInfoFlow.collectAsState()
val savedIsVisibleInRoomDirectory = remember { mutableStateOf<AsyncData<Boolean>>(AsyncData.Uninitialized) } val savedIsVisibleInRoomDirectory = remember { mutableStateOf<AsyncData<Boolean>>(AsyncData.Uninitialized) }
@ -106,7 +107,9 @@ class SecurityAndPrivacyPresenter(
) )
var showEnableEncryptionConfirmation by remember(savedSettings.isEncrypted) { mutableStateOf(false) } 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) { fun handleEvent(event: SecurityAndPrivacyEvent) {
when (event) { 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.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService 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.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule 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.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS 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.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeBaseRoom 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.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo 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.assert
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -66,7 +70,7 @@ class SecurityAndPrivacyPresenterTest {
fun `present - room info change updates saved and edited settings`() = runTest { fun `present - room info change updates saved and edited settings`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canSendStateResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo( initialRoomInfo = aRoomInfo(
joinRule = JoinRule.Public, joinRule = JoinRule.Public,
historyVisibility = RoomHistoryVisibility.WorldReadable, historyVisibility = RoomHistoryVisibility.WorldReadable,
@ -173,7 +177,7 @@ class SecurityAndPrivacyPresenterTest {
fun `present - room visibility loading and change`() = runTest { fun `present - room visibility loading and change`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canSendStateResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
getRoomVisibilityResult = { Result.success(RoomVisibility.Private) }, getRoomVisibilityResult = { Result.success(RoomVisibility.Private) },
initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared) initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared)
) )
@ -222,7 +226,7 @@ class SecurityAndPrivacyPresenterTest {
val updateRoomHistoryVisibilityLambda = lambdaRecorder<RoomHistoryVisibility, Result<Unit>> { Result.success(Unit) } val updateRoomHistoryVisibilityLambda = lambdaRecorder<RoomHistoryVisibility, Result<Unit>> { Result.success(Unit) }
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canSendStateResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
getRoomVisibilityResult = { Result.success(RoomVisibility.Private) }, getRoomVisibilityResult = { Result.success(RoomVisibility.Private) },
initialRoomInfo = aRoomInfo(joinRule = JoinRule.Invite, historyVisibility = RoomHistoryVisibility.Shared) initialRoomInfo = aRoomInfo(joinRule = JoinRule.Invite, historyVisibility = RoomHistoryVisibility.Shared)
), ),
@ -272,8 +276,8 @@ class SecurityAndPrivacyPresenterTest {
isEncrypted = true, isEncrypted = true,
) )
) )
// Saved settings are updated 3 times to match the edited settings // Saved settings are updated 2 times to match the edited settings
skipItems(3) skipItems(2)
with(awaitItem()) { with(awaitItem()) {
assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit)) assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit))
assertThat(savedSettings).isEqualTo(editedSettings) assertThat(savedSettings).isEqualTo(editedSettings)
@ -297,7 +301,7 @@ class SecurityAndPrivacyPresenterTest {
val updateRoomHistoryVisibilityLambda = lambdaRecorder<RoomHistoryVisibility, Result<Unit>> { Result.success(Unit) } val updateRoomHistoryVisibilityLambda = lambdaRecorder<RoomHistoryVisibility, Result<Unit>> { Result.success(Unit) }
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canSendStateResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
getRoomVisibilityResult = { Result.success(RoomVisibility.Private) }, getRoomVisibilityResult = { Result.success(RoomVisibility.Private) },
initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.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 // Saved settings are updated 2 times to match the edited settings
skipItems(3) skipItems(2)
val state = awaitItem() val state = awaitItem()
with(state) { with(state) {
assertThat(saveAction).isInstanceOf(AsyncAction.Failure::class.java) 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( private fun createSecurityAndPrivacyPresenter(
serverName: String = "matrix.org", serverName: String = "matrix.org",
room: FakeJoinedRoom = FakeJoinedRoom( room: FakeJoinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canSendStateResult = { _, _ -> Result.success(true) }, roomPermissions = roomPermissions(),
getRoomVisibilityResult = { Result.success(RoomVisibility.Private) }, getRoomVisibilityResult = { Result.success(RoomVisibility.Private) },
initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.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.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
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 io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@ -56,7 +58,7 @@ class UserProfilePresenter(
@Composable @Composable
private fun getDmRoomId(): State<RoomId?> { private fun getDmRoomId(): State<RoomId?> {
return produceState<RoomId?>(initialValue = null) { return produceState(initialValue = null) {
value = client.findDM(userId).getOrNull() value = client.findDM(userId).getOrNull()
} }
} }
@ -66,7 +68,6 @@ class UserProfilePresenter(
val isElementCallAvailable by produceState(initialValue = false, roomId) { val isElementCallAvailable by produceState(initialValue = false, roomId) {
value = sessionEnterpriseService.isElementCallAvailable() value = sessionEnterpriseService.isElementCallAvailable()
} }
return produceState(initialValue = false, isElementCallAvailable, roomId) { return produceState(initialValue = false, isElementCallAvailable, roomId) {
value = when { value = when {
isElementCallAvailable.not() -> false isElementCallAvailable.not() -> false
@ -75,7 +76,7 @@ class UserProfilePresenter(
roomId roomId
?.let { client.getRoom(it) } ?.let { client.getRoom(it) }
?.use { room -> ?.use { room ->
room.canUserJoinCall(client.sessionId).getOrNull() room.roomPermissions().use(false) { perms -> perms.canCall() }
} }
.orFalse() .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.RoomId
import io.element.android.libraries.matrix.api.core.UserId 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.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.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID 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.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService 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.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.any 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.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
@ -89,15 +92,7 @@ class UserProfilePresenterTest {
@Test @Test
fun `present - canCall is false when canUserJoinCall returns false`() { fun `present - canCall is false when canUserJoinCall returns false`() {
testCanCall( testCanCall(
canUserJoinCallResult = Result.success(false), canUserJoinCall = false,
expectedResult = false,
)
}
@Test
fun `present - canCall is false when canUserJoinCall fails`() {
testCanCall(
canUserJoinCallResult = Result.failure(AN_EXCEPTION),
expectedResult = false, expectedResult = false,
) )
} }
@ -128,7 +123,7 @@ class UserProfilePresenterTest {
private fun testCanCall( private fun testCanCall(
isElementCallAvailable: Boolean = true, isElementCallAvailable: Boolean = true,
canUserJoinCallResult: Result<Boolean> = Result.success(true), canUserJoinCall: Boolean = true,
dmRoom: RoomId? = A_ROOM_ID, dmRoom: RoomId? = A_ROOM_ID,
canFindRoom: Boolean = true, canFindRoom: Boolean = true,
expectedResult: Boolean, expectedResult: Boolean,
@ -136,7 +131,14 @@ class UserProfilePresenterTest {
checkThatRoomIsDestroyed: Boolean = false, checkThatRoomIsDestroyed: Boolean = false,
) = runTest { ) = runTest {
val room = FakeBaseRoom( val room = FakeBaseRoom(
canUserJoinCallResult = { canUserJoinCallResult }, roomPermissions = FakeRoomPermissions(
canSendState = { type ->
when (type) {
StateEventType.CALL_MEMBER -> canUserJoinCall
else -> lambdaError()
}
}
),
) )
val client = createFakeMatrixClient().apply { val client = createFakeMatrixClient().apply {
if (canFindRoom) { 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.ThreadId
import io.element.android.libraries.matrix.api.core.UserId 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.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.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom 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.roomdirectory.RoomVisibility
@ -99,6 +100,11 @@ interface BaseRoom : Closeable {
*/ */
suspend fun userRole(userId: UserId): Result<RoomMember.Role> 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. * 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> 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. * 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 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( data class RoomPowerLevelsValues(
val ban: Long, val ban: Long,
val invite: Long, val invite: Long,
@ -24,50 +19,3 @@ data class RoomPowerLevelsValues(
val roomTopic: Long, val roomTopic: Long,
val spaceChild: 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.ThreadId
import io.element.android.libraries.matrix.api.core.UserId 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.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.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembersState 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.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.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.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom 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.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.RoomMemberListFetcher
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper 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.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.room.tombstone.map
import io.element.android.libraries.matrix.impl.roomdirectory.map import io.element.android.libraries.matrix.impl.roomdirectory.map
import io.element.android.libraries.matrix.impl.timeline.toRustReceiptType 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 { runCatchingExceptions {
innerRoom.getPowerLevels().use { it.canUserInvite(userId.value) } RustRoomPermissions(innerRoom.getPowerLevels())
}
}
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) }
} }
} }

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.ThreadId
import io.element.android.libraries.matrix.api.core.UserId 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.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.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembersState 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.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.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom 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.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.timeline.ReceiptType 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_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_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.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -40,6 +40,7 @@ class FakeBaseRoom(
override val sessionId: SessionId = A_SESSION_ID, override val sessionId: SessionId = A_SESSION_ID,
override val roomId: RoomId = A_ROOM_ID, override val roomId: RoomId = A_ROOM_ID,
initialRoomInfo: RoomInfo = aRoomInfo(), initialRoomInfo: RoomInfo = aRoomInfo(),
private val roomPermissions: RoomPermissions = FakeRoomPermissions(),
override val roomCoroutineScope: CoroutineScope = TestScope(), override val roomCoroutineScope: CoroutineScope = TestScope(),
private var roomPermalinkResult: () -> Result<String> = { lambdaError() }, private var roomPermalinkResult: () -> Result<String> = { lambdaError() },
private var eventPermalinkResult: (EventId) -> 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 userRoleResult: () -> Result<RoomMember.Role> = { lambdaError() },
private val getUpdatedMemberResult: (UserId) -> Result<RoomMember> = { lambdaError() }, private val getUpdatedMemberResult: (UserId) -> Result<RoomMember> = { lambdaError() },
private val joinRoomResult: () -> Result<Unit> = { 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 setIsFavoriteResult: (Boolean) -> Result<Unit> = { lambdaError() },
private val markAsReadResult: (ReceiptType) -> Result<Unit> = { Result.success(Unit) }, private val markAsReadResult: (ReceiptType) -> Result<Unit> = { Result.success(Unit) },
private val powerLevelsResult: () -> Result<RoomPowerLevelsValues> = { lambdaError() }, private val powerLevelsResult: () -> Result<RoomPowerLevelsValues> = { lambdaError() },
@ -129,6 +120,10 @@ class FakeBaseRoom(
return userRoleResult() return userRoleResult()
} }
override suspend fun roomPermissions(): Result<RoomPermissions> {
return Result.success(roomPermissions)
}
override suspend fun getPermalink(): Result<String> { override suspend fun getPermalink(): Result<String> {
return roomPermalinkResult() return roomPermalinkResult()
} }
@ -153,46 +148,6 @@ class FakeBaseRoom(
return forgetResult() 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> { override suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit> {
return setIsFavoriteResult(isFavorite) 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.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import io.element.android.libraries.matrix.api.room.BaseRoom 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.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 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 @Composable
fun BaseRoom.userPowerLevelAsState(updateKey: Long): State<Long> { fun BaseRoom.userPowerLevelAsState(updateKey: Long): State<Long> {
return produceState(initialValue = 0, key1 = updateKey) { 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.core.EventId
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader 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.BaseRoom
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource 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.local.LocalMediaActions
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems 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.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.eventId
import io.element.android.libraries.mediaviewer.impl.model.mediaInfo 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.mediaviewer.impl.model.mediaSource
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -80,6 +81,10 @@ class MediaGalleryPresenter(
mediaGalleryDataSource.start() mediaGalleryDataSource.start()
} }
val permissions by room.permissionsAsState(MediaPermissions.DEFAULT) { perms ->
perms.mediaPermissions()
}
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
localMediaActions.Configure() localMediaActions.Configure()
@ -119,8 +124,8 @@ class MediaGalleryPresenter(
eventId = event.mediaItem.eventId(), eventId = event.mediaItem.eventId(),
canDelete = when (event.mediaItem.mediaInfo().senderId) { canDelete = when (event.mediaItem.mediaInfo().senderId) {
null -> false null -> false
room.sessionId -> room.canRedactOwn().getOrElse { false } && event.mediaItem.eventId() != null room.sessionId -> permissions.canRedactOwn && event.mediaItem.eventId() != null
else -> room.canRedactOther().getOrElse { false } && event.mediaItem.eventId() != null else -> permissions.canRedactOther && event.mediaItem.eventId() != null
}, },
mediaInfo = event.mediaItem.mediaInfo(), mediaInfo = event.mediaItem.mediaInfo(),
thumbnailSource = when (event.mediaItem) { 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.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom 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.permissionsAsState
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.timeline.Timeline 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.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint 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.R
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState 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.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 io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -81,6 +82,9 @@ class MediaViewerPresenter(
NoMoreItemsBackwardSnackBarDisplayer(currentIndex, data) NoMoreItemsBackwardSnackBarDisplayer(currentIndex, data)
NoMoreItemsForwardSnackBarDisplayer(currentIndex, data) NoMoreItemsForwardSnackBarDisplayer(currentIndex, data)
val permissions by room.permissionsAsState(MediaPermissions.DEFAULT) { perms ->
perms.mediaPermissions()
}
var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) } var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }
DisposableEffect(Unit) { DisposableEffect(Unit) {
@ -131,8 +135,8 @@ class MediaViewerPresenter(
eventId = event.data.eventId, eventId = event.data.eventId,
canDelete = when (event.data.mediaInfo.senderId) { canDelete = when (event.data.mediaInfo.senderId) {
null -> false null -> false
room.sessionId -> room.canRedactOwn().getOrElse { false } && event.data.eventId != null room.sessionId -> permissions.canRedactOwn && event.data.eventId != null
else -> room.canRedactOther().getOrElse { false } && event.data.eventId != null else -> permissions.canRedactOther && event.data.eventId != null
}, },
mediaInfo = event.data.mediaInfo, mediaInfo = event.data.mediaInfo,
thumbnailSource = event.data.thumbnailSource, 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.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom 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.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.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaviewer.impl.datasource.FakeMediaGalleryDataSource import io.element.android.libraries.mediaviewer.impl.datasource.FakeMediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
@ -109,7 +110,9 @@ class MediaGalleryPresenterTest {
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
sessionId = A_USER_ID, sessionId = A_USER_ID,
initialRoomInfo = aRoomInfo(name = A_ROOM_NAME), initialRoomInfo = aRoomInfo(name = A_ROOM_NAME),
canRedactOwnResult = { Result.success(canDeleteOwn) } roomPermissions = FakeRoomPermissions(
canRedactOwn = canDeleteOwn
),
), ),
) )
) )
@ -153,7 +156,9 @@ class MediaGalleryPresenterTest {
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
sessionId = A_USER_ID, sessionId = A_USER_ID,
initialRoomInfo = aRoomInfo(name = A_ROOM_NAME), initialRoomInfo = aRoomInfo(name = A_ROOM_NAME),
canRedactOtherResult = { Result.success(canDeleteOther) }, roomPermissions = FakeRoomPermissions(
canRedactOther = canDeleteOther
),
), ),
createTimelineResult = { Result.success(FakeTimeline()) } createTimelineResult = { Result.success(FakeTimeline()) }
) )
@ -355,7 +360,9 @@ class MediaGalleryPresenterTest {
room = FakeJoinedRoom( room = FakeJoinedRoom(
createTimelineResult = { Result.success(FakeTimeline()) }, createTimelineResult = { Result.success(FakeTimeline()) },
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) }, roomPermissions = FakeRoomPermissions(
canRedactOwn = true
),
), ),
), ),
navigator = navigator, navigator = navigator,
@ -386,7 +393,9 @@ class MediaGalleryPresenterTest {
room = FakeJoinedRoom( room = FakeJoinedRoom(
createTimelineResult = { Result.success(FakeTimeline()) }, createTimelineResult = { Result.success(FakeTimeline()) },
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) }, roomPermissions = FakeRoomPermissions(
canRedactOwn = true
),
), ),
), ),
navigator = navigator, 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.media.aMediaSource
import io.element.android.libraries.matrix.test.room.FakeBaseRoom 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.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.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
@ -83,7 +84,9 @@ class MediaViewerPresenterTest {
localMediaFactory = localMediaFactory, localMediaFactory = localMediaFactory,
room = FakeJoinedRoom( room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) }, roomPermissions = FakeRoomPermissions(
canRedactOwn = true
),
) )
) )
) )
@ -104,7 +107,9 @@ class MediaViewerPresenterTest {
canShowInfo = false, canShowInfo = false,
room = FakeJoinedRoom( room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) }, roomPermissions = FakeRoomPermissions(
canRedactOwn = true
),
) )
) )
) )
@ -125,7 +130,9 @@ class MediaViewerPresenterTest {
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
room = FakeJoinedRoom( room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) }, roomPermissions = FakeRoomPermissions(
canRedactOwn = true
),
) )
) )
) )
@ -147,7 +154,9 @@ class MediaViewerPresenterTest {
room = FakeJoinedRoom( room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
sessionId = A_SESSION_ID_2, sessionId = A_SESSION_ID_2,
canRedactOtherResult = { Result.success(false) }, roomPermissions = FakeRoomPermissions(
canRedactOther = false
),
) )
) )
) )
@ -236,7 +245,9 @@ class MediaViewerPresenterTest {
mediaGalleryDataSource = mediaGalleryDataSource, mediaGalleryDataSource = mediaGalleryDataSource,
room = FakeJoinedRoom( room = FakeJoinedRoom(
baseRoom = FakeBaseRoom( baseRoom = FakeBaseRoom(
canRedactOwnResult = { Result.success(true) }, roomPermissions = FakeRoomPermissions(
canRedactOwn = true
),
) )
) )
) )
@ -460,7 +471,11 @@ class MediaViewerPresenterTest {
localMediaFactory = localMediaFactory, localMediaFactory = localMediaFactory,
room = FakeJoinedRoom( room = FakeJoinedRoom(
liveTimeline = timeline, liveTimeline = timeline,
baseRoom = FakeBaseRoom(canRedactOwnResult = { Result.success(true) }), baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions(
canRedactOwn = true
),
),
), ),
mediaGalleryDataSource = mediaGalleryDataSource, mediaGalleryDataSource = mediaGalleryDataSource,
mediaViewerNavigator = FakeMediaViewerNavigator( mediaViewerNavigator = FakeMediaViewerNavigator(
@ -769,7 +784,11 @@ class MediaViewerPresenterTest {
localMediaFactory = localMediaFactory, localMediaFactory = localMediaFactory,
mediaViewerNavigator = navigator, mediaViewerNavigator = navigator,
room = FakeJoinedRoom( room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(canRedactOwnResult = { Result.success(true) }), baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions(
canRedactOwn = true
),
),
) )
) )
presenter.test { presenter.test {
@ -794,7 +813,11 @@ class MediaViewerPresenterTest {
localMediaFactory = localMediaFactory, localMediaFactory = localMediaFactory,
mediaViewerNavigator = navigator, mediaViewerNavigator = navigator,
room = FakeJoinedRoom( room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(canRedactOwnResult = { Result.success(true) }), baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions(
canRedactOwn = true
),
),
), ),
) )
presenter.test { presenter.test {
@ -821,7 +844,11 @@ class MediaViewerPresenterTest {
localMediaFactory = localMediaFactory, localMediaFactory = localMediaFactory,
mediaViewerNavigator = navigator, mediaViewerNavigator = navigator,
room = FakeJoinedRoom( room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(canRedactOwnResult = { Result.success(true) }), baseRoom = FakeBaseRoom(
roomPermissions = FakeRoomPermissions(
canRedactOwn = true
),
),
), ),
) )
presenter.test { presenter.test {