diff --git a/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/KnockRequestPermissions.kt b/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/KnockRequestPermissions.kt new file mode 100644 index 0000000000..82bceb5be0 --- /dev/null +++ b/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/KnockRequestPermissions.kt @@ -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(), + ) +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt index 641efffaa3..f340d597b1 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt @@ -49,7 +49,7 @@ class KnockRequestsBannerPresenter( val shouldShowBanner by remember { derivedStateOf { - permissions.canHandle && knockRequests.isNotEmpty() + permissions.hasAny && knockRequests.isNotEmpty() } } diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPermissions.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPermissions.kt deleted file mode 100644 index 2ca4d4df74..0000000000 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPermissions.kt +++ /dev/null @@ -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 { - return syncUpdateFlow.map { - val canAccept = canInvite().getOrDefault(false) - val canDecline = canKick().getOrDefault(false) - val canBan = canBan().getOrDefault(false) - KnockRequestPermissions(canAccept, canDecline, canBan) - } -} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt index eeba54f87d..b51b78f105 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt @@ -12,10 +12,13 @@ import dev.zacsweers.metro.BindingContainer import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.Provides import dev.zacsweers.metro.SingleIn +import io.element.android.features.knockrequests.api.KnockRequestPermissions +import io.element.android.features.knockrequests.api.knockRequestPermissions import io.element.android.libraries.di.RoomScope import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsFlow @BindingContainer @ContributesTo(RoomScope::class) @@ -25,7 +28,9 @@ object KnockRequestsModule { fun knockRequestsService(room: JoinedRoom, featureFlagService: FeatureFlagService): KnockRequestsService { return KnockRequestsService( knockRequestsFlow = room.knockRequestsFlow, - permissionsFlow = room.knockRequestPermissionsFlow(), + permissionsFlow = room.permissionsFlow(KnockRequestPermissions.DEFAULT) { perms -> + perms.knockRequestPermissions() + }, isKnockFeatureEnabledFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock), coroutineScope = room.roomCoroutineScope ) diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt index 04c2f7b316..98570e6b28 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt @@ -8,6 +8,7 @@ package io.element.android.features.knockrequests.impl.data +import io.element.android.features.knockrequests.api.KnockRequestPermissions import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.knock.KnockRequest diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt index a1bb90cae8..ae770a297d 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt @@ -9,7 +9,7 @@ package io.element.android.features.knockrequests.impl.list import androidx.compose.runtime.Immutable -import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions +import io.element.android.features.knockrequests.api.KnockRequestPermissions import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt index 2c4c92a6b6..85fc0675ad 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt @@ -9,7 +9,7 @@ package io.element.android.features.knockrequests.impl.list import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions +import io.element.android.features.knockrequests.api.KnockRequestPermissions import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable import io.element.android.libraries.architecture.AsyncAction diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt index 9eaa51cecb..3161d3e81f 100644 --- a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt +++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt @@ -9,7 +9,7 @@ package io.element.android.features.knockrequests.impl.banner import com.google.common.truth.Truth.assertThat -import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions +import io.element.android.features.knockrequests.api.KnockRequestPermissions import io.element.android.features.knockrequests.impl.data.KnockRequestsService import io.element.android.libraries.matrix.api.room.knock.KnockRequest import io.element.android.libraries.matrix.test.A_USER_ID diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt index b0d0b68c91..7102b01773 100644 --- a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt +++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt @@ -11,7 +11,7 @@ package io.element.android.features.knockrequests.impl.list import com.google.common.truth.Truth.assertThat -import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions +import io.element.android.features.knockrequests.api.KnockRequestPermissions import io.element.android.features.knockrequests.impl.data.KnockRequestsService import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index e912722c6d..20160ad8c2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -12,12 +12,10 @@ import android.os.Build import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable @@ -75,14 +73,10 @@ import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.room.JoinedRoom -import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.isDm -import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin -import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther -import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn -import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.matrix.ui.model.getAvatarData @@ -167,7 +161,9 @@ class MessagesPresenter( val roomCallState = roomCallStatePresenter.present() val roomMemberModerationState = roomMemberModerationPresenter.present() - val userEventPermissions by userEventPermissions(roomInfo) + val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms -> + perms.userEventPermissions() + } val roomAvatar by remember { derivedStateOf { roomInfo.avatarData() } @@ -297,24 +293,6 @@ class MessagesPresenter( ) } - @Composable - private fun userEventPermissions(roomInfo: RoomInfo): State { - val key = if (roomInfo.privilegedCreatorRole && roomInfo.creators.contains(room.sessionId)) { - Long.MAX_VALUE - } else { - roomInfo.roomPowerLevels?.hashCode() ?: 0L - } - return produceState(UserEventPermissions.DEFAULT, key1 = key) { - value = UserEventPermissions( - canSendMessage = room.canSendMessage(type = MessageEventType.RoomMessage).getOrElse { true }, - canSendReaction = room.canSendMessage(type = MessageEventType.Reaction).getOrElse { true }, - canRedactOwn = room.canRedactOwn().getOrElse { false }, - canRedactOther = room.canRedactOther().getOrElse { false }, - canPinUnpin = room.canPinUnpin().getOrElse { false }, - ) - } - } - private fun RoomInfo.avatarData(): AvatarData { return AvatarData( id = id.value, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/UserEventPermissions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/UserEventPermissions.kt index f7d221950b..349c8e58dc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/UserEventPermissions.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/UserEventPermissions.kt @@ -8,6 +8,9 @@ package io.element.android.features.messages.impl +import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions + /** * Represents the permissions a user has in a room. * It's dependent of the user's power level in the room. @@ -29,3 +32,13 @@ data class UserEventPermissions( ) } } + +fun RoomPermissions.userEventPermissions(): UserEventPermissions { + return UserEventPermissions( + canRedactOwn = canOwnUserRedactOwn(), + canRedactOther = canOwnUserRedactOther(), + canSendMessage = canOwnUserSendMessage(MessageEventType.RoomMessage), + canSendReaction = canOwnUserSendMessage(MessageEventType.Reaction), + canPinUnpin = canOwnUserPinUnpin() + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 276499d5bb..2d3ab5ac31 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -55,6 +55,7 @@ import io.element.android.libraries.matrix.api.room.draft.ComposerDraft import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType import io.element.android.libraries.matrix.api.room.getDirectRoomMember import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.room.powerlevels.use import io.element.android.libraries.matrix.api.timeline.TimelineException import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails @@ -396,7 +397,9 @@ class MessageComposerPresenter( val currentUserId = room.sessionId suspend fun canSendRoomMention(): Boolean { - val userCanSendAtRoom = room.canUserTriggerRoomNotification(currentUserId).getOrDefault(false) + val userCanSendAtRoom = room.roomPermissions().use(false){ perms -> + perms.canOwnUserTriggerRoomNotification() + } return !room.isDm() && userCanSendAtRoom } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt index cdc1f85f1d..b2d2caa7f9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt @@ -10,11 +10,10 @@ package io.element.android.features.messages.impl.pinned.list import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue @@ -35,6 +34,7 @@ import io.element.android.features.messages.impl.timeline.factories.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.typing.TypingNotificationState +import io.element.android.features.messages.impl.userEventPermissions import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter @@ -44,11 +44,9 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.room.JoinedRoom -import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin -import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther -import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn +import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.room.roomMembers -import io.element.android.libraries.matrix.ui.room.isDmAsState import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analyticsproviders.api.trackers.captureInteraction @@ -97,31 +95,33 @@ class PinnedMessagesListPresenter( @Composable override fun present(): PinnedMessagesListState { htmlConverterProvider.Update() - val isDm by room.isDmAsState() - - val timelineRoomInfo = remember(isDm) { - TimelineRoomInfo( - isDm = isDm, - name = room.info().name, - // We don't need to compute those values - userHasPermissionToSendMessage = false, - userHasPermissionToSendReaction = false, - // We do not care about the call state here. - roomCallState = aStandByCallState(), - // don't compute this value or the pin icon will be shown - pinnedEventIds = persistentListOf(), - typingNotificationState = TypingNotificationState( - renderTypingNotifications = false, - typingMembers = persistentListOf(), - reserveSpace = false, - ), - predecessorRoom = room.predecessorRoom(), - ) + val roomInfo by room.roomInfoFlow.collectAsState() + val timelineRoomInfo by remember { + derivedStateOf { + TimelineRoomInfo( + isDm = roomInfo.isDm, + name = roomInfo.name, + // We don't need to compute those values + userHasPermissionToSendMessage = false, + userHasPermissionToSendReaction = false, + // We do not care about the call state here. + roomCallState = aStandByCallState(), + // don't compute this value or the pin icon will be shown + pinnedEventIds = persistentListOf(), + typingNotificationState = TypingNotificationState( + renderTypingNotifications = false, + typingMembers = persistentListOf(), + reserveSpace = false, + ), + predecessorRoom = room.predecessorRoom(), + ) + } } val timelineProtectionState = timelineProtectionPresenter.present() val linkState = linkPresenter.present() - val syncUpdateFlow = room.syncUpdateFlow.collectAsState() - val userEventPermissions by userEventPermissions(syncUpdateFlow.value) + val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms -> + perms.userEventPermissions() + } val displayThreadSummaries by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false) @@ -192,19 +192,6 @@ class PinnedMessagesListPresenter( } } - @Composable - private fun userEventPermissions(updateKey: Long): State { - return produceState(UserEventPermissions.DEFAULT, key1 = updateKey) { - value = UserEventPermissions( - canSendMessage = false, - canSendReaction = false, - canRedactOwn = room.canRedactOwn().getOrElse { false }, - canRedactOther = room.canRedactOther().getOrElse { false }, - canPinUnpin = room.canPinUnpin().getOrElse { false }, - ) - } - } - @Composable private fun PinnedMessagesListEffect(onItemsChange: (AsyncData>) -> Unit) { val updatedOnItemsChange by rememberUpdatedState(onItemsChange) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index e44cac4f27..4815602c8e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -24,6 +24,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import io.element.android.features.messages.impl.MessagesNavigator +import io.element.android.features.messages.impl.UserEventPermissions import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvents import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory @@ -32,6 +33,7 @@ import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel import io.element.android.features.messages.impl.typing.TypingNotificationState +import io.element.android.features.messages.impl.userEventPermissions import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager import io.element.android.features.poll.api.actions.EndPollAction import io.element.android.features.poll.api.actions.SendPollResponseAction @@ -46,14 +48,13 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.asEventId import io.element.android.libraries.matrix.api.room.JoinedRoom -import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin -import io.element.android.libraries.matrix.ui.room.canSendMessageAsState import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationTapOpensTimeline @@ -128,11 +129,6 @@ class TimelinePresenter( val roomInfo by room.roomInfoFlow.collectAsState() - val syncUpdateFlow = room.syncUpdateFlow.collectAsState() - - val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.RoomMessage, updateKey = syncUpdateFlow.value) - val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.Reaction, updateKey = syncUpdateFlow.value) - val prevMostRecentItemId = rememberSaveable { mutableStateOf(null) } val newEventState = remember { mutableStateOf(NewEventState.None) } @@ -285,13 +281,16 @@ class TimelinePresenter( val typingNotificationState = typingNotificationPresenter.present() val roomCallState = roomCallStatePresenter.present() + val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms -> + perms.userEventPermissions() + } val timelineRoomInfo by remember(typingNotificationState, roomCallState, roomInfo) { derivedStateOf { TimelineRoomInfo( name = roomInfo.name, - isDm = roomInfo.isDm.orFalse(), - userHasPermissionToSendMessage = userHasPermissionToSendMessage, - userHasPermissionToSendReaction = userHasPermissionToSendReaction, + isDm = roomInfo.isDm, + userHasPermissionToSendMessage = userEventPermissions.canSendMessage, + userHasPermissionToSendReaction = userEventPermissions.canSendReaction, roomCallState = roomCallState, pinnedEventIds = roomInfo.pinnedEventIds, typingNotificationState = typingNotificationState, diff --git a/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenter.kt b/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenter.kt index 5de47f9ff2..b18a2772a3 100644 --- a/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenter.kt +++ b/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenter.kt @@ -21,7 +21,8 @@ import io.element.android.features.enterprise.api.SessionEnterpriseService import io.element.android.features.roomcall.api.RoomCallState import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.room.JoinedRoom -import io.element.android.libraries.matrix.ui.room.canCall +import io.element.android.libraries.matrix.api.room.powerlevels.canCall +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState @Inject class RoomCallStatePresenter( @@ -35,8 +36,7 @@ class RoomCallStatePresenter( value = sessionEnterpriseService.isElementCallAvailable() } val roomInfo by room.roomInfoFlow.collectAsState() - val syncUpdateFlow = room.syncUpdateFlow.collectAsState() - val canJoinCall by room.canCall(updateKey = syncUpdateFlow.value) + val canJoinCall by room.permissionsAsState(false) { perms -> perms.canCall() } val isUserInTheCall by remember { derivedStateOf { room.sessionId in roomInfo.activeRoomCallParticipants diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt index 95c4f1e9e2..83840ad604 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt @@ -10,6 +10,7 @@ package io.element.android.features.roomdetails.impl import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -18,11 +19,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import dev.zacsweers.metro.Inject import im.vector.app.features.analytics.plan.Interaction +import io.element.android.features.knockrequests.api.knockRequestPermissions import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter -import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissionsAsState +import io.element.android.features.roomdetailsedit.api.RoomDetailsEditPermissions +import io.element.android.features.roomdetailsedit.api.roomDetailsEditPermissions +import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers @@ -36,17 +40,13 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.RoomMember -import io.element.android.libraries.matrix.api.room.RoomMembersState -import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.join.JoinRule -import io.element.android.libraries.matrix.api.room.powerlevels.canInvite -import io.element.android.libraries.matrix.api.room.powerlevels.canSendState +import io.element.android.libraries.matrix.api.room.powerlevels.canEditRolesAndPermissions +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.room.roomNotificationSettings -import io.element.android.libraries.matrix.ui.room.canHandleKnockRequestsAsState import io.element.android.libraries.matrix.ui.room.getCurrentRoomMember import io.element.android.libraries.matrix.ui.room.getDirectRoomMember -import io.element.android.libraries.matrix.ui.room.isDmAsState -import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.ui.strings.CommonStrings @@ -77,8 +77,6 @@ class RoomDetailsPresenter( val scope = rememberCoroutineScope() val leaveRoomState = leaveRoomPresenter.present() val roomInfo by room.roomInfoFlow.collectAsState() - val isUserAdmin = room.isOwnUserAdmin() - val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val roomAvatar by remember { derivedStateOf { roomInfo.avatarUrl } } val roomName by remember { derivedStateOf { roomInfo.name?.trim().orEmpty() } } @@ -93,15 +91,11 @@ class RoomDetailsPresenter( observeNotificationSettings() } + val isDm = roomInfo.isDm val membersState by room.membersStateFlow.collectAsState() - val canInvite by getCanInvite(membersState) - + val permissions by getPermissions() val canonicalAlias by remember { derivedStateOf { roomInfo.canonicalAlias } } val isEncrypted by remember { derivedStateOf { roomInfo.isEncrypted == true } } - val isDm by room.isDmAsState() - val canEditName by getCanSendState(membersState, StateEventType.ROOM_NAME) - val canEditAvatar by getCanSendState(membersState, StateEventType.ROOM_AVATAR) - val canEditTopic by getCanSendState(membersState, StateEventType.ROOM_TOPIC) val dmMember by room.getDirectRoomMember(membersState) val currentMember by room.getCurrentRoomMember(membersState) val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember) @@ -109,16 +103,15 @@ class RoomDetailsPresenter( val roomCallState = roomCallStatePresenter.present() val joinedMemberCount by remember { derivedStateOf { roomInfo.joinedMembersCount } } - val topicState = remember(canEditTopic, roomTopic, roomType) { + val topicState = remember(permissions.editDetailsPermissions.canEditTopic, roomTopic, roomType) { val topic = roomTopic when { !topic.isNullOrBlank() -> RoomTopicState.ExistingTopic(topic) - canEditTopic && roomType is RoomDetailsType.Room -> RoomTopicState.CanAddTopic + permissions.editDetailsPermissions.canEditTopic && roomType is RoomDetailsType.Room -> RoomTopicState.CanAddTopic else -> RoomTopicState.Hidden } } - val canHandleKnockRequests by room.canHandleKnockRequestsAsState(syncUpdateFlow.value) val isKnockRequestsEnabled by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock) }.collectAsState(false) @@ -126,7 +119,7 @@ class RoomDetailsPresenter( room.knockRequestsFlow.collect { value = it.size } } val canShowKnockRequests by remember { - derivedStateOf { isKnockRequestsEnabled && canHandleKnockRequests && joinRule == JoinRule.Knock } + derivedStateOf { isKnockRequestsEnabled && permissions.canManageKnockRequests && joinRule == JoinRule.Knock } } val isDeveloperModeEnabled by remember { appPreferencesStore.isDeveloperModeEnabledFlow() @@ -162,13 +155,6 @@ class RoomDetailsPresenter( val roomMemberDetailsState = roomMemberDetailsPresenter?.present() - val securityAndPrivacyPermissions = room.securityAndPrivacyPermissionsAsState(syncUpdateFlow.value) - val canShowSecurityAndPrivacy by remember { - derivedStateOf { - roomType is RoomDetailsType.Room && securityAndPrivacyPermissions.value.hasAny - } - } - val hasMemberVerificationViolations by produceState(false) { room.roomMemberIdentityStateChange(waitForEncryption = true) .onEach { identities -> value = identities.any { it.identityState == IdentityState.VerificationViolation } } @@ -185,22 +171,22 @@ class RoomDetailsPresenter( roomTopic = topicState, memberCount = joinedMemberCount, isEncrypted = isEncrypted, - canInvite = canInvite, - canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room, + canInvite = permissions.canInvite, + canEdit = roomType == RoomDetailsType.Room && permissions.editDetailsPermissions.hasAny, roomCallState = roomCallState, roomType = roomType, roomMemberDetailsState = roomMemberDetailsState, leaveRoomState = leaveRoomState, roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(), isFavorite = isFavorite, - displayRolesAndPermissionsSettings = !isDm && isUserAdmin, + displayRolesAndPermissionsSettings = !isDm && permissions.canEditRolesAndPermissions, isPublic = joinRule == JoinRule.Public, heroes = roomInfo.heroes.toImmutableList(), pinnedMessagesCount = pinnedMessagesCount, snackbarMessage = snackbarMessage, canShowKnockRequests = canShowKnockRequests, knockRequestsCount = knockRequestsCount, - canShowSecurityAndPrivacy = canShowSecurityAndPrivacy, + canShowSecurityAndPrivacy = !isDm && permissions.canEditSecurityAndPrivacy, hasMemberVerificationViolations = hasMemberVerificationViolations, canReportRoom = canReportRoom, isTombstoned = roomInfo.successorRoom != null, @@ -232,14 +218,25 @@ class RoomDetailsPresenter( } } - @Composable - private fun getCanInvite(membersState: RoomMembersState) = produceState(false, membersState) { - value = room.canInvite().getOrElse { false } - } + private data class Permissions( + val canInvite: Boolean = false, + val editDetailsPermissions: RoomDetailsEditPermissions = RoomDetailsEditPermissions.DEFAULT, + val canManageKnockRequests: Boolean = false, + val canEditRolesAndPermissions: Boolean = false, + val canEditSecurityAndPrivacy: Boolean = false, + ) @Composable - private fun getCanSendState(membersState: RoomMembersState, type: StateEventType) = produceState(false, membersState) { - value = room.canSendState(type).getOrElse { false } + private fun getPermissions(): State { + 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() { diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt index f1d3f61f7e..6917057a06 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt @@ -32,10 +32,10 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.room.toMatrixUser import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator -import io.element.android.libraries.matrix.ui.room.canInviteAsState import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentMapOf @@ -58,8 +58,7 @@ class RoomMemberListPresenter( override fun present(): RoomMemberListState { var searchQuery by rememberSaveable { mutableStateOf("") } val membersState by room.membersStateFlow.collectAsState() - val syncUpdateFlow = room.syncUpdateFlow.collectAsState() - val canInvite by room.canInviteAsState(syncUpdateFlow.value) + val canInvite by room.permissionsAsState(false) { perms -> perms.canOwnUserInvite() } val roomModerationState = roomMembersModerationPresenter.present() val roomMemberIdentityStates by produceState(persistentMapOf()) { diff --git a/features/roomdetailsedit/api/src/main/kotlin/io/element/android/features/roomdetailsedit/api/RoomDetailsEditPermissions.kt b/features/roomdetailsedit/api/src/main/kotlin/io/element/android/features/roomdetailsedit/api/RoomDetailsEditPermissions.kt new file mode 100644 index 0000000000..f95f4466f2 --- /dev/null +++ b/features/roomdetailsedit/api/src/main/kotlin/io/element/android/features/roomdetailsedit/api/RoomDetailsEditPermissions.kt @@ -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), + ) +} diff --git a/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditPresenter.kt b/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditPresenter.kt index 4a2dfa3f11..4713ab9255 100644 --- a/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditPresenter.kt +++ b/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditPresenter.kt @@ -23,6 +23,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.core.net.toUri import dev.zacsweers.metro.Inject +import io.element.android.features.roomdetailsedit.api.RoomDetailsEditPermissions +import io.element.android.features.roomdetailsedit.api.roomDetailsEditPermissions import io.element.android.libraries.androidutils.file.TemporaryUriDeleter import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter @@ -30,8 +32,7 @@ import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.room.JoinedRoom -import io.element.android.libraries.matrix.api.room.StateEventType -import io.element.android.libraries.matrix.api.room.powerlevels.canSendState +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider @@ -93,14 +94,8 @@ class RoomDetailsEditPresenter( } } - var canChangeName by remember { mutableStateOf(false) } - var canChangeTopic by remember { mutableStateOf(false) } - var canChangeAvatar by remember { mutableStateOf(false) } - - LaunchedEffect(roomSyncUpdateFlow.value) { - canChangeName = room.canSendState(StateEventType.ROOM_NAME).getOrElse { false } - canChangeTopic = room.canSendState(StateEventType.ROOM_TOPIC).getOrElse { false } - canChangeAvatar = room.canSendState(StateEventType.ROOM_AVATAR).getOrElse { false } + val permissions by room.permissionsAsState(RoomDetailsEditPermissions.DEFAULT){perms -> + perms.roomDetailsEditPermissions() } val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( @@ -181,11 +176,11 @@ class RoomDetailsEditPresenter( return RoomDetailsEditState( roomId = room.roomId, roomRawName = roomRawNameEdited, - canChangeName = canChangeName, + canChangeName = permissions.canEditName, roomTopic = roomTopicEdited, - canChangeTopic = canChangeTopic, + canChangeTopic = permissions.canEditTopic, roomAvatarUrl = roomAvatarUriEdited, - canChangeAvatar = canChangeAvatar, + canChangeAvatar = permissions.canEditAvatar, avatarActions = avatarActions, saveButtonEnabled = saveButtonEnabled, saveAction = saveAction.value, diff --git a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt index 31ff2da2b2..96749ba5ac 100644 --- a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt +++ b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt @@ -30,10 +30,9 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.matrix.ui.room.canBanAsState -import io.element.android.libraries.matrix.ui.room.canKickAsState import io.element.android.libraries.matrix.ui.room.userPowerLevelAsState import io.element.android.services.analytics.api.AnalyticsService import kotlinx.collections.immutable.ImmutableList @@ -56,8 +55,12 @@ class RoomMemberModerationPresenter( override fun present(): RoomMemberModerationState { val coroutineScope = rememberCoroutineScope() val syncUpdateFlow = room.syncUpdateFlow.collectAsState() - val canBan = room.canBanAsState(syncUpdateFlow.value) - val canKick = room.canKickAsState(syncUpdateFlow.value) + val permissions by room.permissionsAsState(Permissions()) { perms -> + Permissions( + canKick = perms.canOwnUserKick(), + canBan = perms.canOwnUserBan(), + ) + } val currentUserMemberPowerLevel = room.userPowerLevelAsState(syncUpdateFlow.value) val kickUserAsyncAction = @@ -80,8 +83,7 @@ class RoomMemberModerationPresenter( } moderationActions.value = computeModerationActions( member = member, - canKick = canKick.value, - canBan = canBan.value, + permissions = permissions, currentUserMemberPowerLevel = currentUserMemberPowerLevel.value, ) } @@ -134,8 +136,8 @@ class RoomMemberModerationPresenter( } return InternalRoomMemberModerationState( - canKick = canKick.value, - canBan = canBan.value, + canKick = permissions.canKick, + canBan = permissions.canBan, selectedUser = selectedUser, actions = moderationActions.value, kickUserAsyncAction = kickUserAsyncAction.value, @@ -147,8 +149,7 @@ class RoomMemberModerationPresenter( private fun computeModerationActions( member: RoomMember?, - canKick: Boolean, - canBan: Boolean, + permissions: Permissions, currentUserMemberPowerLevel: Long, ): ImmutableList { return buildList { @@ -158,11 +159,11 @@ class RoomMemberModerationPresenter( val canModerateThisUser = currentUserMemberPowerLevel > targetMemberPowerLevel // Assume the member is joined when it's unknown val membership = member?.membership ?: RoomMembershipState.JOIN - if (canKick) { + if (permissions.canKick) { val isKickEnabled = canModerateThisUser && membership.isActive() add(ModerationActionState(action = ModerationAction.KickUser, isEnabled = isKickEnabled)) } - if (canBan) { + if (permissions.canBan) { if (membership == RoomMembershipState.BAN) { add(ModerationActionState(action = ModerationAction.UnbanUser, isEnabled = canModerateThisUser)) } else { @@ -208,6 +209,11 @@ class RoomMemberModerationPresenter( ) } + private data class Permissions( + val canKick: Boolean = false, + val canBan: Boolean = false, + ) + private fun CoroutineScope.runActionAndWaitForMembershipChange( action: MutableState>, block: suspend () -> Result diff --git a/features/securityandprivacy/api/src/main/kotlin/io/element/android/features/securityandprivacy/api/SecurityAndPrivacyPermissions.kt b/features/securityandprivacy/api/src/main/kotlin/io/element/android/features/securityandprivacy/api/SecurityAndPrivacyPermissions.kt index 82ab30581e..bacd863ca6 100644 --- a/features/securityandprivacy/api/src/main/kotlin/io/element/android/features/securityandprivacy/api/SecurityAndPrivacyPermissions.kt +++ b/features/securityandprivacy/api/src/main/kotlin/io/element/android/features/securityandprivacy/api/SecurityAndPrivacyPermissions.kt @@ -8,13 +8,8 @@ package io.element.android.features.securityandprivacy.api -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.produceState -import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions.Companion.DEFAULT -import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.StateEventType -import io.element.android.libraries.matrix.api.room.powerlevels.canSendState +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions data class SecurityAndPrivacyPermissions( val canChangeRoomAccess: Boolean, @@ -37,14 +32,11 @@ data class SecurityAndPrivacyPermissions( } } -@Composable -fun BaseRoom.securityAndPrivacyPermissionsAsState(updateKey: Long): State { - return produceState(DEFAULT, key1 = updateKey) { - value = SecurityAndPrivacyPermissions( - canChangeRoomAccess = canSendState(type = StateEventType.ROOM_JOIN_RULES).getOrElse { false }, - canChangeHistoryVisibility = canSendState(type = StateEventType.ROOM_HISTORY_VISIBILITY).getOrElse { false }, - canChangeEncryption = canSendState(type = StateEventType.ROOM_ENCRYPTION).getOrElse { false }, - canChangeRoomVisibility = canSendState(type = StateEventType.ROOM_CANONICAL_ALIAS).getOrElse { false }, - ) - } +fun RoomPermissions.securityAndPrivacyPermissions(): SecurityAndPrivacyPermissions { + return SecurityAndPrivacyPermissions( + canChangeRoomAccess = canOwnUserSendState(StateEventType.ROOM_JOIN_RULES), + canChangeHistoryVisibility = canOwnUserSendState(StateEventType.ROOM_HISTORY_VISIBILITY), + canChangeEncryption = canOwnUserSendState(StateEventType.ROOM_ENCRYPTION), + canChangeRoomVisibility = canOwnUserSendState(StateEventType.ROOM_CANONICAL_ALIAS), + ) } diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt index b77ad0e7d6..af0f44be38 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt @@ -21,7 +21,8 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject -import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissionsAsState +import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions +import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator import io.element.android.features.securityandprivacy.impl.editroomaddress.matchesServer import io.element.android.libraries.architecture.AsyncAction @@ -37,6 +38,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async @@ -106,7 +108,9 @@ class SecurityAndPrivacyPresenter( ) var showEnableEncryptionConfirmation by remember(savedSettings.isEncrypted) { mutableStateOf(false) } - val permissions by room.securityAndPrivacyPermissionsAsState(syncUpdateFlow.value) + val permissions by room.permissionsAsState(SecurityAndPrivacyPermissions.DEFAULT) { perms -> + perms.securityAndPrivacyPermissions() + } fun handleEvent(event: SecurityAndPrivacyEvent) { when (event) { diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt index 9bf31e0b07..8b7eddca54 100644 --- a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt +++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt @@ -34,6 +34,8 @@ import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.powerlevels.canCall +import io.element.android.libraries.matrix.api.room.powerlevels.use import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.distinctUntilChanged @@ -56,7 +58,7 @@ class UserProfilePresenter( @Composable private fun getDmRoomId(): State { - return produceState(initialValue = null) { + return produceState(initialValue = null) { value = client.findDM(userId).getOrNull() } } @@ -66,7 +68,6 @@ class UserProfilePresenter( val isElementCallAvailable by produceState(initialValue = false, roomId) { value = sessionEnterpriseService.isElementCallAvailable() } - return produceState(initialValue = false, isElementCallAvailable, roomId) { value = when { isElementCallAvailable.not() -> false @@ -75,7 +76,7 @@ class UserProfilePresenter( roomId ?.let { client.getRoom(it) } ?.use { room -> - room.canUserJoinCall(client.sessionId).getOrNull() + room.roomPermissions().use(false){ perms -> perms.canCall()} } .orFalse() } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt index cde3a5eff7..481171ace0 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt @@ -130,57 +130,6 @@ interface BaseRoom : Closeable { */ suspend fun forget(): Result - /** - * Returns `true` if the user with the provided [userId] can invite other users to the room. - */ - suspend fun canUserInvite(userId: UserId): Result - - /** - * Returns `true` if the user with the provided [userId] can kick other users from the room. - */ - suspend fun canUserKick(userId: UserId): Result - - /** - * Returns `true` if the user with the provided [userId] can ban other users from the room. - */ - suspend fun canUserBan(userId: UserId): Result - - /** - * Returns `true` if the user with the provided [userId] can redact their own messages. - */ - suspend fun canUserRedactOwn(userId: UserId): Result - - /** - * Returns `true` if the user with the provided [userId] can redact messages from other users. - */ - suspend fun canUserRedactOther(userId: UserId): Result - - /** - * Returns `true` if the user with the provided [userId] can send state events. - */ - suspend fun canUserSendState(userId: UserId, type: StateEventType): Result - - /** - * Returns `true` if the user with the provided [userId] can send messages. - */ - suspend fun canUserSendMessage(userId: UserId, type: MessageEventType): Result - - /** - * Returns `true` if the user with the provided [userId] can trigger an `@room` notification. - */ - suspend fun canUserTriggerRoomNotification(userId: UserId): Result - - /** - * Returns `true` if the user with the provided [userId] can pin or unpin messages. - */ - suspend fun canUserPinUnpin(userId: UserId): Result - - /** - * Returns `true` if the user with the provided [userId] can join or starts calls. - */ - suspend fun canUserJoinCall(userId: UserId): Result = - canUserSendState(userId, StateEventType.CALL_MEMBER) - /** * Sets the room as favorite or not, based on the [isFavorite] parameter. */ diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPermissions.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPermissions.kt index cdc735b819..735627e10e 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPermissions.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPermissions.kt @@ -7,9 +7,18 @@ 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 +import timber.log.Timber /** * Provides information about the permissions of users in a room. @@ -120,23 +129,38 @@ interface RoomPermissions : AutoCloseable { fun canUserTriggerRoomNotification(userId: UserId): Boolean } -fun RoomPermissions.canEditRoomDetails(): Boolean { - return canOwnUserSendState(StateEventType.ROOM_NAME) || - canOwnUserSendState(StateEventType.ROOM_TOPIC) || - canOwnUserSendState(StateEventType.ROOM_AVATAR) -} - -fun RoomPermissions.canManageKnockRequests(): Boolean { - return canOwnUserInvite() || canOwnUserBan() || canOwnUserKick() -} - -fun RoomPermissions.canEditSecurityAndPrivacy(): Boolean { - return canOwnUserSendState(StateEventType.ROOM_JOIN_RULES) || - canOwnUserSendState(StateEventType.ROOM_HISTORY_VISIBILITY) || - canOwnUserSendState(StateEventType.ROOM_CANONICAL_ALIAS) || - canOwnUserSendState(StateEventType.ROOM_ENCRYPTION) -} - fun RoomPermissions.canEditRolesAndPermissions(): Boolean { return canOwnUserSendState(StateEventType.ROOM_POWER_LEVELS) } + +fun RoomPermissions.canCall(): Boolean { + return canOwnUserSendState(StateEventType.CALL_MEMBER) +} + +fun Result.use(default: T, block: (RoomPermissions) -> T): T { + return fold( + onSuccess = { perms -> + perms.use(block) + }, + onFailure = { + default + } + ) +} + +fun BaseRoom.permissionsFlow(default: T, block: (RoomPermissions) -> T): Flow { + return roomInfoFlow + .map { info -> info.roomPowerLevels } + .distinctUntilChanged() + .map { + roomPermissions().use(default, block) + } +} + +@Composable +fun BaseRoom.permissionsAsState(default: T, block: (RoomPermissions) -> T): State { + return remember(this, default, block) { + Timber.d("Computing permissionsAsState for room $roomId with default=$default") + permissionsFlow(default, block) + }.collectAsState(default) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPowerLevelsValues.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPowerLevelsValues.kt index d20b6141eb..a7eebbb99c 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPowerLevelsValues.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPowerLevelsValues.kt @@ -8,11 +8,6 @@ package io.element.android.libraries.matrix.api.room.powerlevels -import io.element.android.libraries.core.extensions.runCatchingExceptions -import io.element.android.libraries.matrix.api.room.BaseRoom -import io.element.android.libraries.matrix.api.room.MessageEventType -import io.element.android.libraries.matrix.api.room.StateEventType - data class RoomPowerLevelsValues( val ban: Long, val invite: Long, @@ -24,50 +19,3 @@ data class RoomPowerLevelsValues( val roomTopic: Long, val spaceChild: Long, ) - -/** - * Shortcut for calling [BaseRoom.canUserInvite] with our own user. - */ -suspend fun BaseRoom.canInvite(): Result = canUserInvite(sessionId) - -/** - * Shortcut for calling [BaseRoom.canUserKick] with our own user. - */ -suspend fun BaseRoom.canKick(): Result = canUserKick(sessionId) - -/** - * Shortcut for calling [BaseRoom.canUserBan] with our own user. - */ -suspend fun BaseRoom.canBan(): Result = canUserBan(sessionId) - -/** - * Shortcut for calling [BaseRoom.canUserSendState] with our own user. - */ -suspend fun BaseRoom.canSendState(type: StateEventType): Result = canUserSendState(sessionId, type) - -/** - * Shortcut for calling [BaseRoom.canUserSendMessage] with our own user. - */ -suspend fun BaseRoom.canSendMessage(type: MessageEventType): Result = canUserSendMessage(sessionId, type) - -/** - * Shortcut for calling [BaseRoom.canUserRedactOwn] with our own user. - */ -suspend fun BaseRoom.canRedactOwn(): Result = canUserRedactOwn(sessionId) - -/** - * Shortcut for calling [BaseRoom.canRedactOther] with our own user. - */ -suspend fun BaseRoom.canRedactOther(): Result = canUserRedactOther(sessionId) - -/** - * Shortcut for checking if current user can handle knock requests. - */ -suspend fun BaseRoom.canHandleKnockRequests(): Result = runCatchingExceptions { - canInvite().getOrThrow() || canBan().getOrThrow() || canKick().getOrThrow() -} - -/** - * Shortcut for calling [BaseRoom.canUserPinUnpin] with our own user. - */ -suspend fun BaseRoom.canPinUnpin(): Result = canUserPinUnpin(sessionId) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt index aee9af82b7..e5e493ee63 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt @@ -30,8 +30,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarM import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.room.BaseRoom -import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther -import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource @@ -39,8 +38,10 @@ import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetSta import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.impl.model.MediaPermissions import io.element.android.libraries.mediaviewer.impl.model.eventId import io.element.android.libraries.mediaviewer.impl.model.mediaInfo +import io.element.android.libraries.mediaviewer.impl.model.mediaPermissions import io.element.android.libraries.mediaviewer.impl.model.mediaSource import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.launch @@ -80,6 +81,10 @@ class MediaGalleryPresenter( mediaGalleryDataSource.start() } + val permissions by room.permissionsAsState(MediaPermissions.DEFAULT) { perms -> + perms.mediaPermissions() + } + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() localMediaActions.Configure() @@ -119,8 +124,8 @@ class MediaGalleryPresenter( eventId = event.mediaItem.eventId(), canDelete = when (event.mediaItem.mediaInfo().senderId) { null -> false - room.sessionId -> room.canRedactOwn().getOrElse { false } && event.mediaItem.eventId() != null - else -> room.canRedactOther().getOrElse { false } && event.mediaItem.eventId() != null + room.sessionId -> permissions.canRedactOwn && event.mediaItem.eventId() != null + else -> permissions.canRedactOther && event.mediaItem.eventId() != null }, mediaInfo = event.mediaItem.mediaInfo(), thumbnailSource = when (event.mediaItem) { @@ -202,6 +207,7 @@ class MediaGalleryPresenter( CommonStrings.error_unknown } } + } private fun GroupedMediaItems?.find(eventId: EventId?): MediaItem.Event? { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaPermissions.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaPermissions.kt new file mode 100644 index 0000000000..4a111965e9 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaPermissions.kt @@ -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(), + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt index 4709a4ec51..dc0feb70cf 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt @@ -32,8 +32,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.JoinedRoom -import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther -import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint @@ -41,6 +40,8 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.impl.R import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions +import io.element.android.libraries.mediaviewer.impl.model.MediaPermissions +import io.element.android.libraries.mediaviewer.impl.model.mediaPermissions import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CoroutineScope @@ -81,6 +82,9 @@ class MediaViewerPresenter( NoMoreItemsBackwardSnackBarDisplayer(currentIndex, data) NoMoreItemsForwardSnackBarDisplayer(currentIndex, data) + val permissions by room.permissionsAsState(MediaPermissions.DEFAULT) { perms -> + perms.mediaPermissions() + } var mediaBottomSheetState by remember { mutableStateOf(MediaBottomSheetState.Hidden) } DisposableEffect(Unit) { @@ -131,8 +135,8 @@ class MediaViewerPresenter( eventId = event.data.eventId, canDelete = when (event.data.mediaInfo.senderId) { null -> false - room.sessionId -> room.canRedactOwn().getOrElse { false } && event.data.eventId != null - else -> room.canRedactOther().getOrElse { false } && event.data.eventId != null + room.sessionId -> permissions.canRedactOwn && event.data.eventId != null + else -> permissions.canRedactOther && event.data.eventId != null }, mediaInfo = event.data.mediaInfo, thumbnailSource = event.data.thumbnailSource,