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 b13a95d4f2..329196a958 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 @@ -20,10 +20,12 @@ 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 @@ -73,12 +75,13 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomInfo import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState 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.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.ui.messages.reply.map import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.room.canCall -import io.element.android.libraries.matrix.ui.room.canRedactOtherAsState -import io.element.android.libraries.matrix.ui.room.canRedactOwnAsState -import io.element.android.libraries.matrix.ui.room.canSendMessageAsState import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.toPersistentList @@ -131,10 +134,9 @@ class MessagesPresenter @AssistedInject constructor( val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present() val syncUpdateFlow = room.syncUpdateFlow.collectAsState() - val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value) - val userHasPermissionToRedactOwn by room.canRedactOwnAsState(updateKey = syncUpdateFlow.value) - val userHasPermissionToRedactOther by room.canRedactOtherAsState(updateKey = syncUpdateFlow.value) - val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.REACTION, updateKey = syncUpdateFlow.value) + + val userEventPermissions by userEventPermissions(syncUpdateFlow.value) + val roomName: AsyncData by remember { derivedStateOf { roomInfo?.name?.let { AsyncData.Success(it) } ?: AsyncData.Uninitialized } } @@ -211,11 +213,8 @@ class MessagesPresenter @AssistedInject constructor( roomName = roomName, roomAvatar = roomAvatar, heroes = heroes, - userHasPermissionToSendMessage = userHasPermissionToSendMessage, - userHasPermissionToRedactOwn = userHasPermissionToRedactOwn, - userHasPermissionToRedactOther = userHasPermissionToRedactOther, - userHasPermissionToSendReaction = userHasPermissionToSendReaction, composerState = composerState, + userEventPermissions = userEventPermissions, voiceMessageComposerState = voiceMessageComposerState, timelineState = timelineState, typingNotificationState = typingNotificationState, @@ -235,6 +234,19 @@ class MessagesPresenter @AssistedInject constructor( ) } + @Composable + private fun userEventPermissions(updateKey: Long): State { + return produceState(UserEventPermissions.DEFAULT, key1 = updateKey) { + value = UserEventPermissions( + canSendMessage = room.canSendMessage(type = MessageEventType.ROOM_MESSAGE).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 MatrixRoomInfo.avatarData(): AvatarData { return AvatarData( id = id.value, @@ -268,6 +280,30 @@ class MessagesPresenter @AssistedInject constructor( TimelineItemAction.Forward -> handleForwardAction(targetEvent) TimelineItemAction.ReportContent -> handleReportAction(targetEvent) TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent, timelineState) + TimelineItemAction.Pin -> handlePinAction(targetEvent) + TimelineItemAction.Unpin -> handleUnpinAction(targetEvent) + } + } + + private suspend fun handlePinAction(targetEvent: TimelineItem.Event) { + if (targetEvent.eventId == null) return + timelineController.invokeOnCurrentTimeline { + pinEvent(targetEvent.eventId) + .onFailure { + Timber.e(it, "Failed to pin event ${targetEvent.eventId}") + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error)) + } + } + } + + private suspend fun handleUnpinAction(targetEvent: TimelineItem.Event) { + if (targetEvent.eventId == null) return + timelineController.invokeOnCurrentTimeline { + unpinEvent(targetEvent.eventId) + .onFailure { + Timber.e(it, "Failed to unpin event ${targetEvent.eventId}") + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error)) + } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index e8657d70bd..99e5b50de8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -37,10 +37,7 @@ data class MessagesState( val roomName: AsyncData, val roomAvatar: AsyncData, val heroes: ImmutableList, - val userHasPermissionToSendMessage: Boolean, - val userHasPermissionToRedactOwn: Boolean, - val userHasPermissionToRedactOther: Boolean, - val userHasPermissionToSendReaction: Boolean, + val userEventPermissions: UserEventPermissions, val composerState: MessageComposerState, val voiceMessageComposerState: VoiceMessageComposerState, val timelineState: TimelineState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index 1396d3e17c..3f3a691a98 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -53,7 +53,7 @@ open class MessagesStateProvider : PreviewParameterProvider { aMessagesState(), aMessagesState(hasNetworkConnection = false), aMessagesState(composerState = aMessageComposerState(showAttachmentSourcePicker = true)), - aMessagesState(userHasPermissionToSendMessage = false), + aMessagesState(userEventPermissions = aUserEventPermissions(canSendMessage = false)), aMessagesState(showReinvitePrompt = true), aMessagesState( roomName = AsyncData.Uninitialized, @@ -93,10 +93,7 @@ open class MessagesStateProvider : PreviewParameterProvider { fun aMessagesState( roomName: AsyncData = AsyncData.Success("Room name"), roomAvatar: AsyncData = AsyncData.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)), - userHasPermissionToSendMessage: Boolean = true, - userHasPermissionToRedactOwn: Boolean = false, - userHasPermissionToRedactOther: Boolean = false, - userHasPermissionToSendReaction: Boolean = true, + userEventPermissions: UserEventPermissions = aUserEventPermissions(), composerState: MessageComposerState = aMessageComposerState( textEditorState = TextEditorState.Rich(aRichTextEditorState(initialText = "Hello", initialFocus = true)), isFullScreen = false, @@ -122,10 +119,7 @@ fun aMessagesState( roomName = roomName, roomAvatar = roomAvatar, heroes = persistentListOf(), - userHasPermissionToSendMessage = userHasPermissionToSendMessage, - userHasPermissionToRedactOwn = userHasPermissionToRedactOwn, - userHasPermissionToRedactOther = userHasPermissionToRedactOther, - userHasPermissionToSendReaction = userHasPermissionToSendReaction, + userEventPermissions = userEventPermissions, composerState = composerState, voiceMessageComposerState = voiceMessageComposerState, typingNotificationState = aTypingNotificationState(), @@ -145,6 +139,20 @@ fun aMessagesState( eventSink = eventSink, ) +fun aUserEventPermissions( + canRedactOwn: Boolean = false, + canRedactOther: Boolean = false, + canSendMessage: Boolean = true, + canSendReaction: Boolean = true, + canPinUnpin: Boolean = false, +) = UserEventPermissions( + canRedactOwn = canRedactOwn, + canRedactOther = canRedactOther, + canSendMessage = canSendMessage, + canSendReaction = canSendReaction, + canPinUnpin = canPinUnpin, +) + fun aReactionSummaryState( target: ReactionSummaryState.Summary? = null, eventSink: (ReactionSummaryEvents) -> Unit = {} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 24439c0c75..0a868fac12 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -154,10 +154,7 @@ fun MessagesView( state.actionListState.eventSink( ActionListEvents.ComputeForMessage( event = event, - canRedactOwn = state.userHasPermissionToRedactOwn, - canRedactOther = state.userHasPermissionToRedactOther, - canSendMessage = state.userHasPermissionToSendMessage, - canSendReaction = state.userHasPermissionToSendReaction, + userEventPermissions = state.userEventPermissions, ) ) } @@ -408,7 +405,7 @@ private fun MessagesViewComposerBottomSheetContents( subcomposing: Boolean, state: MessagesState, ) { - if (state.userHasPermissionToSendMessage) { + if (state.userEventPermissions.canSendMessage) { Column(modifier = Modifier.fillMaxWidth()) { MentionSuggestionsPickerView( modifier = Modifier 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 new file mode 100644 index 0000000000..77a24b8c5d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/UserEventPermissions.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl + +/** + * Represents the permissions a user has in a room. + * It's dependent of the user's power level in the room. + */ +data class UserEventPermissions( + val canRedactOwn: Boolean, + val canRedactOther: Boolean, + val canSendMessage: Boolean, + val canSendReaction: Boolean, + val canPinUnpin: Boolean, +) { + companion object { + val DEFAULT = UserEventPermissions( + canRedactOwn = false, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = false + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt index e486c1ae2b..407d18afb7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt @@ -16,15 +16,13 @@ package io.element.android.features.messages.impl.actionlist +import io.element.android.features.messages.impl.UserEventPermissions import io.element.android.features.messages.impl.timeline.model.TimelineItem sealed interface ActionListEvents { data object Clear : ActionListEvents data class ComputeForMessage( val event: TimelineItem.Event, - val canRedactOwn: Boolean, - val canRedactOther: Boolean, - val canSendMessage: Boolean, - val canSendReaction: Boolean, + val userEventPermissions: UserEventPermissions, ) : ActionListEvents } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt index d1a19b600d..124f4e911d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt @@ -23,25 +23,36 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import io.element.android.features.messages.impl.UserEventPermissions import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.canBeCopied +import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded import io.element.android.features.messages.impl.timeline.model.event.canReact import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject class ActionListPresenter @Inject constructor( private val appPreferencesStore: AppPreferencesStore, + private val featureFlagsService: FeatureFlagService, + private val room: MatrixRoom, ) : Presenter { @Composable override fun present(): ActionListState { @@ -52,17 +63,20 @@ class ActionListPresenter @Inject constructor( } val isDeveloperModeEnabled by appPreferencesStore.isDeveloperModeEnabledFlow().collectAsState(initial = false) + val isPinnedEventsEnabled by featureFlagsService.isFeatureEnabledFlow(FeatureFlags.PinnedEvents).collectAsState(initial = false) + val pinnedEventIds by remember { + room.roomInfoFlow.map { it.pinnedEventIds } + }.collectAsState(initial = persistentListOf()) fun handleEvents(event: ActionListEvents) { when (event) { ActionListEvents.Clear -> target.value = ActionListState.Target.None is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage( timelineItem = event.event, - userCanRedactOwn = event.canRedactOwn, - userCanRedactOther = event.canRedactOther, - userCanSendMessage = event.canSendMessage, - userCanSendReaction = event.canSendReaction, + usersEventPermissions = event.userEventPermissions, isDeveloperModeEnabled = isDeveloperModeEnabled, + isPinnedEventsEnabled = isPinnedEventsEnabled, + pinnedEventIds = pinnedEventIds, target = target, ) } @@ -76,136 +90,22 @@ class ActionListPresenter @Inject constructor( private fun CoroutineScope.computeForMessage( timelineItem: TimelineItem.Event, - userCanRedactOwn: Boolean, - userCanRedactOther: Boolean, - userCanSendMessage: Boolean, - userCanSendReaction: Boolean, + usersEventPermissions: UserEventPermissions, isDeveloperModeEnabled: Boolean, + isPinnedEventsEnabled: Boolean, + pinnedEventIds: ImmutableList, target: MutableState ) = launch { target.value = ActionListState.Target.Loading(timelineItem) - val canRedact = timelineItem.isMine && userCanRedactOwn || !timelineItem.isMine && userCanRedactOther - val actions = - when (timelineItem.content) { - is TimelineItemCallNotifyContent -> { - if (isDeveloperModeEnabled) { - listOf(TimelineItemAction.ViewSource) - } else { - emptyList() - } - } - is TimelineItemRedactedContent -> { - if (isDeveloperModeEnabled) { - listOf(TimelineItemAction.ViewSource) - } else { - emptyList() - } - } - is TimelineItemStateContent -> { - buildList { - add(TimelineItemAction.Copy) - if (timelineItem.isRemote) { - add(TimelineItemAction.CopyLink) - } - if (isDeveloperModeEnabled) { - add(TimelineItemAction.ViewSource) - } - } - } - is TimelineItemPollContent -> { - val canEndPoll = timelineItem.isRemote && - !timelineItem.content.isEnded && - (timelineItem.isMine || canRedact) - buildList { - if (timelineItem.isRemote) { - // Can only reply or forward messages already uploaded to the server - add(TimelineItemAction.Reply) - } - if (timelineItem.isRemote && timelineItem.isEditable) { - add(TimelineItemAction.Edit) - } - if (canEndPoll) { - add(TimelineItemAction.EndPoll) - } - if (timelineItem.content.canBeCopied()) { - add(TimelineItemAction.Copy) - } - if (timelineItem.isRemote) { - add(TimelineItemAction.CopyLink) - } - if (isDeveloperModeEnabled) { - add(TimelineItemAction.ViewSource) - } - if (!timelineItem.isMine) { - add(TimelineItemAction.ReportContent) - } - if (canRedact) { - add(TimelineItemAction.Redact) - } - } - } - is TimelineItemVoiceContent -> { - buildList { - if (timelineItem.isRemote) { - add(TimelineItemAction.Reply) - add(TimelineItemAction.Forward) - add(TimelineItemAction.CopyLink) - } - if (isDeveloperModeEnabled) { - add(TimelineItemAction.ViewSource) - } - if (!timelineItem.isMine) { - add(TimelineItemAction.ReportContent) - } - if (canRedact) { - add(TimelineItemAction.Redact) - } - } - } - is TimelineItemLegacyCallInviteContent -> { - buildList { - if (isDeveloperModeEnabled) { - add(TimelineItemAction.ViewSource) - } - } - } - else -> buildList { - if (timelineItem.isRemote) { - // Can only reply or forward messages already uploaded to the server - if (userCanSendMessage) { - if (timelineItem.isThreaded) { - add(TimelineItemAction.ReplyInThread) - } else { - add(TimelineItemAction.Reply) - } - } - // Stickers can't be forwarded (yet) so we don't show the option - // See https://github.com/element-hq/element-x-android/issues/2161 - if (!timelineItem.isSticker) { - add(TimelineItemAction.Forward) - } - } - if (timelineItem.isEditable) { - add(TimelineItemAction.Edit) - } - if (timelineItem.content.canBeCopied()) { - add(TimelineItemAction.Copy) - } - if (timelineItem.isRemote) { - add(TimelineItemAction.CopyLink) - } - if (isDeveloperModeEnabled) { - add(TimelineItemAction.ViewSource) - } - if (!timelineItem.isMine) { - add(TimelineItemAction.ReportContent) - } - if (canRedact) { - add(TimelineItemAction.Redact) - } - } - } - val displayEmojiReactions = userCanSendReaction && + + val actions = buildActions( + timelineItem = timelineItem, + usersEventPermissions = usersEventPermissions, + isDeveloperModeEnabled = isDeveloperModeEnabled, + isPinnedEventsEnabled = isPinnedEventsEnabled, + isEventPinned = pinnedEventIds.contains(timelineItem.eventId), + ) + val displayEmojiReactions = usersEventPermissions.canSendReaction && timelineItem.isRemote && timelineItem.content.canReact() if (actions.isNotEmpty() || displayEmojiReactions) { @@ -219,3 +119,71 @@ class ActionListPresenter @Inject constructor( } } } + +private fun buildActions( + timelineItem: TimelineItem.Event, + usersEventPermissions: UserEventPermissions, + isDeveloperModeEnabled: Boolean, + isPinnedEventsEnabled: Boolean, + isEventPinned: Boolean, +): List { + val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther + return buildList { + if (timelineItem.canBeRepliedTo && usersEventPermissions.canSendMessage) { + if (timelineItem.isThreaded) { + add(TimelineItemAction.ReplyInThread) + } else { + add(TimelineItemAction.Reply) + } + } + if (timelineItem.isRemote && timelineItem.content.canBeForwarded()) { + add(TimelineItemAction.Forward) + } + if (timelineItem.isEditable) { + add(TimelineItemAction.Edit) + } + if (canRedact && timelineItem.content is TimelineItemPollContent && !timelineItem.content.isEnded) { + add(TimelineItemAction.EndPoll) + } + val canPinUnpin = isPinnedEventsEnabled && usersEventPermissions.canPinUnpin && timelineItem.isRemote + if (canPinUnpin) { + if (isEventPinned) { + add(TimelineItemAction.Unpin) + } else { + add(TimelineItemAction.Pin) + } + } + if (timelineItem.content.canBeCopied()) { + add(TimelineItemAction.Copy) + } + if (timelineItem.isRemote) { + add(TimelineItemAction.CopyLink) + } + if (isDeveloperModeEnabled) { + add(TimelineItemAction.ViewSource) + } + if (!timelineItem.isMine) { + add(TimelineItemAction.ReportContent) + } + if (canRedact) { + add(TimelineItemAction.Redact) + } + }.postFilter(timelineItem.content) +} + +/** + * Post filter the actions based on the content of the event. + */ +private fun List.postFilter(content: TimelineItemEventContent): List { + return filter { action -> + when (content) { + is TimelineItemCallNotifyContent, + is TimelineItemLegacyCallInviteContent, + is TimelineItemStateContent, + is TimelineItemRedactedContent -> { + action == TimelineItemAction.ViewSource + } + else -> true + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt index f61e6197c2..a650dc88eb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt @@ -39,4 +39,8 @@ sealed class TimelineItemAction( data object ViewSource : TimelineItemAction(CommonStrings.action_view_source, CommonDrawables.ic_developer_options) data object ReportContent : TimelineItemAction(CommonStrings.action_report_content, CompoundDrawables.ic_compound_chat_problem, destructive = true) data object EndPoll : TimelineItemAction(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end) + data object Pin : TimelineItemAction(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin) + + // TODO use the Unpin compound icon when available. + data object Unpin : TimelineItemAction(CommonStrings.action_unpin, CompoundDrawables.ic_compound_pin) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index f6b58e2799..55a82fa791 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -127,6 +127,7 @@ internal fun aTimelineItemEvent( transactionId: TransactionId? = null, isMine: Boolean = false, isEditable: Boolean = false, + canBeRepliedTo: Boolean = false, senderDisplayName: String = "Sender", displayNameAmbiguous: Boolean = false, content: TimelineItemEventContent = aTimelineItemTextContent(), @@ -150,6 +151,7 @@ internal fun aTimelineItemEvent( sentTime = "12:34", isMine = isMine, isEditable = isEditable, + canBeRepliedTo = canBeRepliedTo, senderProfile = aProfileTimelineDetailsReady( displayName = senderDisplayName, displayNameAmbiguous = displayNameAmbiguous, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index a2ac0ac7b1..528217d367 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -77,7 +77,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent -import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo import io.element.android.libraries.designsystem.colors.AvatarColorsProvider import io.element.android.libraries.designsystem.components.EqualWidthColumn import io.element.android.libraries.designsystem.components.avatar.Avatar @@ -148,7 +147,7 @@ fun TimelineItemEventRow( } else { Spacer(modifier = Modifier.height(2.dp)) } - val canReply = timelineRoomInfo.userHasPermissionToSendMessage && event.content.canBeRepliedTo() + val canReply = timelineRoomInfo.userHasPermissionToSendMessage && event.canBeRepliedTo if (canReply) { val state: SwipeableActionsState = rememberSwipeableActionsState() val offset = state.offset.floatValue diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt index c5d303b3f1..3992c3304e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt @@ -76,6 +76,7 @@ class TimelineItemEventFactory @Inject constructor( content = contentFactory.create(currentTimelineItem.event), isMine = currentTimelineItem.event.isOwn, isEditable = currentTimelineItem.event.isEditable, + canBeRepliedTo = currentTimelineItem.event.canBeRepliedTo, sentTime = sentTime, groupPosition = groupPosition, reactionsState = currentTimelineItem.computeReactionsState(), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt index f77db70506..5eea9851ae 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt @@ -74,6 +74,7 @@ sealed interface TimelineItem { val sentTime: String = "", val isMine: Boolean = false, val isEditable: Boolean, + val canBeRepliedTo: Boolean, val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, val reactionsState: TimelineItemReactions, val readReceiptState: TimelineItemReadReceipts, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt index 43fb947433..5a52f476f1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt @@ -24,27 +24,27 @@ sealed interface TimelineItemEventContent { } /** - * Only text based content and states can be copied. + * Only text based content can be copied. */ fun TimelineItemEventContent.canBeCopied(): Boolean = - when (this) { - is TimelineItemTextBasedContent, - is TimelineItemStateContent, - is TimelineItemRedactedContent -> true - else -> false - } + this is TimelineItemTextBasedContent /** - * Determine if the event content can be replied to. - * Note: it should match the logic in [io.element.android.features.messages.impl.actionlist.ActionListPresenter]. + * Returns true if the event content can be forwarded. */ -fun TimelineItemEventContent.canBeRepliedTo(): Boolean = +fun TimelineItemEventContent.canBeForwarded(): Boolean = when (this) { - is TimelineItemRedactedContent, - is TimelineItemLegacyCallInviteContent, - is TimelineItemCallNotifyContent, - is TimelineItemStateContent -> false - else -> true + is TimelineItemTextBasedContent, + is TimelineItemImageContent, + is TimelineItemFileContent, + is TimelineItemAudioContent, + is TimelineItemVideoContent, + is TimelineItemLocationContent, + is TimelineItemVoiceContent -> true + // Stickers can't be forwarded (yet) so we don't show the option + // See https://github.com/element-hq/element-x-android/issues/2161 + is TimelineItemStickerContent -> false + else -> false } /** diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 091305893e..db85546e42 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -43,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.typing.TypingNotificationPresenter import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer @@ -77,6 +78,7 @@ import io.element.android.libraries.matrix.test.AN_EVENT_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_2 +import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser @@ -137,8 +139,8 @@ class MessagesPresenterTest { assertThat(initialState.roomName).isEqualTo(AsyncData.Success("")) assertThat(initialState.roomAvatar) .isEqualTo(AsyncData.Success(AvatarData(id = A_ROOM_ID.value, name = "", url = AN_AVATAR_URL, size = AvatarSize.TimelineRoom))) - assertThat(initialState.userHasPermissionToSendMessage).isTrue() - assertThat(initialState.userHasPermissionToRedactOwn).isTrue() + assertThat(initialState.userEventPermissions.canSendMessage).isTrue() + assertThat(initialState.userEventPermissions.canRedactOwn).isTrue() assertThat(initialState.hasNetworkConnection).isTrue() assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.inviteProgress).isEqualTo(AsyncData.Uninitialized) @@ -155,7 +157,8 @@ class MessagesPresenterTest { canRedactOtherResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) }, typingNoticeResult = { Result.success(Unit) }, - ) + canUserPinUnpinResult = { Result.success(true) }, + ) assertThat(room.markAsReadCalls).isEmpty() val presenter = createMessagesPresenter(matrixRoom = room) moleculeFlow(RecompositionMode.Immediate) { @@ -175,7 +178,8 @@ class MessagesPresenterTest { canRedactOwnResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) }, typingNoticeResult = { Result.success(Unit) }, - ).apply { + canUserPinUnpinResult = { Result.success(true) }, + ).apply { givenRoomInfo(aRoomInfo(hasRoomCall = true)) } val presenter = createMessagesPresenter(matrixRoom = room) @@ -203,7 +207,8 @@ class MessagesPresenterTest { canRedactOtherResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) }, typingNoticeResult = { Result.success(Unit) }, - ) + canUserPinUnpinResult = { Result.success(true) }, + ) val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -240,7 +245,8 @@ class MessagesPresenterTest { canRedactOtherResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) }, typingNoticeResult = { Result.success(Unit) }, - ) + canUserPinUnpinResult = { Result.success(true) }, + ) val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -298,7 +304,8 @@ class MessagesPresenterTest { canRedactOtherResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) }, typingNoticeResult = { Result.success(Unit) }, - ) + canUserPinUnpinResult = { Result.success(true) }, + ) val presenter = createMessagesPresenter( clipboardHelper = clipboardHelper, matrixRoom = matrixRoom, @@ -487,7 +494,8 @@ class MessagesPresenterTest { canRedactOtherResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) }, typingNoticeResult = { Result.success(Unit) }, - ) + canUserPinUnpinResult = { Result.success(true) }, + ) val redactEventLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String? -> Result.success(true) } liveTimeline.redactEventLambda = redactEventLambda @@ -561,7 +569,8 @@ class MessagesPresenterTest { canRedactOtherResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) }, typingNoticeResult = { Result.success(Unit) }, - ) + canUserPinUnpinResult = { Result.success(true) }, + ) val presenter = createMessagesPresenter(matrixRoom = room) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -596,7 +605,8 @@ class MessagesPresenterTest { canRedactOtherResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) }, typingNoticeResult = { Result.success(Unit) }, - ) + canUserPinUnpinResult = { Result.success(true) }, + ) val presenter = createMessagesPresenter(matrixRoom = room) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -620,7 +630,8 @@ class MessagesPresenterTest { canRedactOtherResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) }, typingNoticeResult = { Result.success(Unit) }, - ) + canUserPinUnpinResult = { Result.success(true) }, + ) val presenter = createMessagesPresenter(matrixRoom = room) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -644,7 +655,8 @@ class MessagesPresenterTest { canRedactOtherResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) }, typingNoticeResult = { Result.success(Unit) }, - ) + canUserPinUnpinResult = { Result.success(true) }, + ) room.givenRoomMembersState( MatrixRoomMembersState.Ready( persistentListOf( @@ -679,7 +691,8 @@ class MessagesPresenterTest { canRedactOtherResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) }, typingNoticeResult = { Result.success(Unit) }, - ) + canUserPinUnpinResult = { Result.success(true) }, + ) room.givenRoomMembersState( MatrixRoomMembersState.Error( failure = Throwable(), @@ -715,7 +728,8 @@ class MessagesPresenterTest { canRedactOtherResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) }, typingNoticeResult = { Result.success(Unit) }, - ) + canUserPinUnpinResult = { Result.success(true) }, + ) room.givenRoomMembersState(MatrixRoomMembersState.Unknown) val presenter = createMessagesPresenter(matrixRoom = room) moleculeFlow(RecompositionMode.Immediate) { @@ -741,7 +755,8 @@ class MessagesPresenterTest { canRedactOtherResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) }, typingNoticeResult = { Result.success(Unit) }, - ) + canUserPinUnpinResult = { Result.success(true) }, + ) room.givenRoomMembersState( MatrixRoomMembersState.Ready( persistentListOf( @@ -781,13 +796,14 @@ class MessagesPresenterTest { canRedactOtherResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) }, typingNoticeResult = { Result.success(Unit) }, - ) + canUserPinUnpinResult = { Result.success(true) }, + ) val presenter = createMessagesPresenter(matrixRoom = matrixRoom) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val state = awaitFirstItem() - assertThat(state.userHasPermissionToSendMessage).isTrue() + assertThat(state.userEventPermissions.canSendMessage).isTrue() } } @@ -805,15 +821,16 @@ class MessagesPresenterTest { canRedactOtherResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) }, typingNoticeResult = { Result.success(Unit) }, - ) + canUserPinUnpinResult = { Result.success(true) }, + ) val presenter = createMessagesPresenter(matrixRoom = matrixRoom) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { // Default value - assertThat(awaitItem().userHasPermissionToSendMessage).isTrue() + assertThat(awaitItem().userEventPermissions.canSendMessage).isTrue() skipItems(1) - assertThat(awaitItem().userHasPermissionToSendMessage).isFalse() + assertThat(awaitItem().userEventPermissions.canSendMessage).isFalse() cancelAndIgnoreRemainingEvents() } } @@ -826,14 +843,15 @@ class MessagesPresenterTest { canRedactOtherResult = { Result.success(false) }, canUserJoinCallResult = { Result.success(true) }, typingNoticeResult = { Result.success(Unit) }, - ) + canUserPinUnpinResult = { Result.success(true) }, + ) val presenter = createMessagesPresenter(matrixRoom = matrixRoom) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val initialState = consumeItemsUntilPredicate { it.userHasPermissionToRedactOwn }.last() - assertThat(initialState.userHasPermissionToRedactOwn).isTrue() - assertThat(initialState.userHasPermissionToRedactOther).isFalse() + val initialState = consumeItemsUntilPredicate { it.userEventPermissions.canRedactOwn }.last() + assertThat(initialState.userEventPermissions.canRedactOwn).isTrue() + assertThat(initialState.userEventPermissions.canRedactOther).isFalse() cancelAndIgnoreRemainingEvents() } } @@ -846,14 +864,15 @@ class MessagesPresenterTest { canRedactOwnResult = { Result.success(false) }, canUserJoinCallResult = { Result.success(true) }, typingNoticeResult = { Result.success(Unit) }, - ) + canUserPinUnpinResult = { Result.success(true) }, + ) val presenter = createMessagesPresenter(matrixRoom = matrixRoom) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val initialState = consumeItemsUntilPredicate { it.userHasPermissionToRedactOther }.last() - assertThat(initialState.userHasPermissionToRedactOwn).isFalse() - assertThat(initialState.userHasPermissionToRedactOther).isTrue() + val initialState = consumeItemsUntilPredicate { it.userEventPermissions.canRedactOther }.last() + assertThat(initialState.userEventPermissions.canRedactOwn).isFalse() + assertThat(initialState.userEventPermissions.canRedactOther).isTrue() cancelAndIgnoreRemainingEvents() } } @@ -878,6 +897,74 @@ class MessagesPresenterTest { } } + @Test + fun `present - handle action pin`() = runTest { + val successPinEventLambda = lambdaRecorder { _: EventId -> Result.success(true) } + val failurePinEventLambda = lambdaRecorder { _: EventId -> Result.failure(A_THROWABLE) } + val timeline = FakeTimeline() + val room = FakeMatrixRoom( + liveTimeline = timeline, + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + typingNoticeResult = { Result.success(Unit) }, + canUserPinUnpinResult = { Result.success(true) }, + ) + val presenter = createMessagesPresenter(matrixRoom = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val messageEvent = aMessageEvent( + content = aTimelineItemTextContent() + ) + val initialState = awaitFirstItem() + + timeline.pinEventLambda = successPinEventLambda + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Pin, messageEvent)) + assert(successPinEventLambda).isCalledOnce().with(value(messageEvent.eventId)) + + timeline.pinEventLambda = failurePinEventLambda + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Pin, messageEvent)) + assert(failurePinEventLambda).isCalledOnce().with(value(messageEvent.eventId)) + assertThat(awaitItem().snackbarMessage).isNotNull() + } + } + + @Test + fun `present - handle action unpin`() = runTest { + val successUnpinEventLambda = lambdaRecorder { _: EventId -> Result.success(true) } + val failureUnpinEventLambda = lambdaRecorder { _: EventId -> Result.failure(A_THROWABLE) } + val timeline = FakeTimeline() + val room = FakeMatrixRoom( + liveTimeline = timeline, + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + typingNoticeResult = { Result.success(Unit) }, + canUserPinUnpinResult = { Result.success(true) }, + ) + val presenter = createMessagesPresenter(matrixRoom = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val messageEvent = aMessageEvent( + content = aTimelineItemTextContent() + ) + val initialState = awaitFirstItem() + + timeline.unpinEventLambda = successUnpinEventLambda + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Unpin, messageEvent)) + assert(successUnpinEventLambda).isCalledOnce().with(value(messageEvent.eventId)) + + timeline.unpinEventLambda = failureUnpinEventLambda + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Unpin, messageEvent)) + assert(failureUnpinEventLambda).isCalledOnce().with(value(messageEvent.eventId)) + assertThat(awaitItem().snackbarMessage).isNotNull() + } + } + private suspend fun ReceiveTurbine.awaitFirstItem(): T { // Skip 2 item if Mentions feature is enabled, else 1 skipItems(if (FeatureFlags.Mentions.defaultValue(aBuildMeta())) 2 else 1) @@ -892,6 +979,7 @@ class MessagesPresenterTest { canRedactOtherResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) }, typingNoticeResult = { Result.success(Unit) }, + canUserPinUnpinResult = { Result.success(true) }, ).apply { givenRoomInfo(aRoomInfo(id = roomId, name = "")) }, @@ -958,11 +1046,17 @@ class MessagesPresenterTest { return timelinePresenter } } - val actionListPresenter = ActionListPresenter(appPreferencesStore = appPreferencesStore) + val featureFlagService = FakeFeatureFlagService() + val actionListPresenter = ActionListPresenter( + appPreferencesStore = appPreferencesStore, + featureFlagsService = featureFlagService, + room = matrixRoom, + ) val typingNotificationPresenter = TypingNotificationPresenter( room = matrixRoom, sessionPreferencesStore = sessionPreferencesStore, ) + val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter() val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider()) val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom) @@ -980,7 +1074,7 @@ class MessagesPresenterTest { snackbarDispatcher = SnackbarDispatcher(), navigator = navigator, clipboardHelper = clipboardHelper, - featureFlagsService = FakeFeatureFlagService(), + featureFlagsService = featureFlagService, buildMeta = aBuildMeta(), dispatchers = coroutineDispatchers, htmlConverterProvider = FakeHtmlConverterProvider(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index 9a22ced505..e876b5ec60 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onLast import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -43,7 +44,6 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState import io.element.android.features.messages.impl.timeline.aTimelineItemEvent -import io.element.android.features.messages.impl.timeline.aTimelineItemList import io.element.android.features.messages.impl.timeline.aTimelineItemReadReceipts import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo import io.element.android.features.messages.impl.timeline.aTimelineState @@ -53,7 +53,6 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents import io.element.android.features.messages.impl.timeline.model.TimelineItem -import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.ui.strings.CommonStrings @@ -169,16 +168,20 @@ class MessagesViewTest { userHasPermissionToRedactOwn: Boolean = false, userHasPermissionToRedactOther: Boolean = false, userHasPermissionToSendReaction: Boolean = false, + userCanPinEvent: Boolean = false, ) { val eventsRecorder = EventsRecorder() val state = aMessagesState( actionListState = anActionListState( eventSink = eventsRecorder ), - userHasPermissionToSendMessage = userHasPermissionToSendMessage, - userHasPermissionToRedactOwn = userHasPermissionToRedactOwn, - userHasPermissionToRedactOther = userHasPermissionToRedactOther, - userHasPermissionToSendReaction = userHasPermissionToSendReaction, + userEventPermissions = UserEventPermissions( + canSendMessage = userHasPermissionToSendMessage, + canRedactOwn = userHasPermissionToRedactOwn, + canRedactOther = userHasPermissionToRedactOther, + canSendReaction = userHasPermissionToSendReaction, + canPinUnpin = userCanPinEvent, + ), ) val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event rule.setMessagesView( @@ -189,10 +192,7 @@ class MessagesViewTest { eventsRecorder.assertSingle( ActionListEvents.ComputeForMessage( event = timelineItem, - canRedactOwn = state.userHasPermissionToRedactOwn, - canRedactOther = state.userHasPermissionToRedactOther, - canSendMessage = state.userHasPermissionToSendMessage, - canSendReaction = state.userHasPermissionToSendReaction, + userEventPermissions = state.userEventPermissions, ) ) } @@ -237,9 +237,11 @@ class MessagesViewTest { private fun swipeTest(userHasPermissionToSendMessage: Boolean) { val eventsRecorder = EventsRecorder() + val canBeRepliedEvent = aTimelineItemEvent(canBeRepliedTo = true) + val cannotBeRepliedEvent = aTimelineItemEvent(canBeRepliedTo = false) val state = aMessagesState( timelineState = aTimelineState( - timelineItems = aTimelineItemList(aTimelineItemTextContent()), + timelineItems = persistentListOf(canBeRepliedEvent, cannotBeRepliedEvent), timelineRoomInfo = aTimelineRoomInfo( userHasPermissionToSendMessage = userHasPermissionToSendMessage ), @@ -249,10 +251,12 @@ class MessagesViewTest { rule.setMessagesView( state = state, ) - rule.onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performTouchInput { swipeRight(endX = 200f) } + rule.onAllNodesWithTag(TestTags.messageBubble.value).apply { + onFirst().performTouchInput { swipeRight(endX = 200f) } + onLast().performTouchInput { swipeRight(endX = 200f) } + } if (userHasPermissionToSendMessage) { - val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event - eventsRecorder.assertSingle(MessagesEvents.HandleAction(TimelineItemAction.Reply, timelineItem)) + eventsRecorder.assertSingle(MessagesEvents.HandleAction(TimelineItemAction.Reply, canBeRepliedEvent)) } else { eventsRecorder.assertEmpty() } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt index 76c43a9c8b..e8ea7cbbc2 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt @@ -20,6 +20,7 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.aUserEventPermissions import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.fixtures.aMessageEvent import io.element.android.features.messages.impl.timeline.aTimelineItemEvent @@ -31,7 +32,13 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_MESSAGE +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.tests.testutils.WarmUpRule import kotlinx.collections.immutable.persistentListOf @@ -46,7 +53,7 @@ class ActionListPresenterTest { @Test fun `present - initial state`() = runTest { - val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -57,7 +64,7 @@ class ActionListPresenterTest { @Test fun `present - compute for message from me redacted`() = runTest { - val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -66,10 +73,13 @@ class ActionListPresenterTest { initialState.eventSink.invoke( ActionListEvents.ComputeForMessage( event = messageEvent, - canRedactOwn = false, - canRedactOther = false, - canSendMessage = true, - canSendReaction = true, + userEventPermissions = aUserEventPermissions( + canRedactOwn = false, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) ) ) // val loadingState = awaitItem() @@ -91,7 +101,7 @@ class ActionListPresenterTest { @Test fun `present - compute for message from others redacted`() = runTest { - val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -104,10 +114,13 @@ class ActionListPresenterTest { initialState.eventSink.invoke( ActionListEvents.ComputeForMessage( event = messageEvent, - canRedactOwn = false, - canRedactOther = false, - canSendMessage = true, - canSendReaction = true, + userEventPermissions = aUserEventPermissions( + canRedactOwn = false, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) ) ) // val loadingState = awaitItem() @@ -129,7 +142,7 @@ class ActionListPresenterTest { @Test fun `present - compute for others message`() = runTest { - val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -142,10 +155,13 @@ class ActionListPresenterTest { initialState.eventSink.invoke( ActionListEvents.ComputeForMessage( event = messageEvent, - canRedactOwn = false, - canRedactOther = false, - canSendMessage = true, - canSendReaction = true, + userEventPermissions = aUserEventPermissions( + canRedactOwn = false, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) ) ) // val loadingState = awaitItem() @@ -158,6 +174,7 @@ class ActionListPresenterTest { actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Forward, + TimelineItemAction.Pin, TimelineItemAction.Copy, TimelineItemAction.CopyLink, TimelineItemAction.ViewSource, @@ -172,7 +189,7 @@ class ActionListPresenterTest { @Test fun `present - compute for others message cannot sent message`() = runTest { - val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -185,10 +202,13 @@ class ActionListPresenterTest { initialState.eventSink.invoke( ActionListEvents.ComputeForMessage( event = messageEvent, - canRedactOwn = true, - canRedactOther = false, - canSendMessage = false, - canSendReaction = true + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = false, + canSendReaction = true, + canPinUnpin = true, + ) ) ) // val loadingState = awaitItem() @@ -200,6 +220,7 @@ class ActionListPresenterTest { displayEmojiReactions = true, actions = persistentListOf( TimelineItemAction.Forward, + TimelineItemAction.Pin, TimelineItemAction.Copy, TimelineItemAction.CopyLink, TimelineItemAction.ViewSource, @@ -214,7 +235,7 @@ class ActionListPresenterTest { @Test fun `present - compute for others message and can redact`() = runTest { - val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -227,10 +248,13 @@ class ActionListPresenterTest { initialState.eventSink.invoke( ActionListEvents.ComputeForMessage( event = messageEvent, - canRedactOwn = false, - canRedactOther = true, - canSendMessage = true, - canSendReaction = true, + userEventPermissions = aUserEventPermissions( + canRedactOwn = false, + canRedactOther = true, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) ) ) val successState = awaitItem() @@ -241,6 +265,7 @@ class ActionListPresenterTest { actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Forward, + TimelineItemAction.Pin, TimelineItemAction.Copy, TimelineItemAction.CopyLink, TimelineItemAction.ViewSource, @@ -256,7 +281,7 @@ class ActionListPresenterTest { @Test fun `present - compute for others message and cannot send reaction`() = runTest { - val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -269,10 +294,13 @@ class ActionListPresenterTest { initialState.eventSink.invoke( ActionListEvents.ComputeForMessage( event = messageEvent, - canRedactOwn = false, - canRedactOther = true, - canSendMessage = true, - canSendReaction = false + userEventPermissions = aUserEventPermissions( + canRedactOwn = false, + canRedactOther = true, + canSendMessage = true, + canSendReaction = false, + canPinUnpin = true, + ) ) ) val successState = awaitItem() @@ -283,6 +311,7 @@ class ActionListPresenterTest { actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Forward, + TimelineItemAction.Pin, TimelineItemAction.Copy, TimelineItemAction.CopyLink, TimelineItemAction.ViewSource, @@ -298,7 +327,7 @@ class ActionListPresenterTest { @Test fun `present - compute for my message`() = runTest { - val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -310,10 +339,13 @@ class ActionListPresenterTest { initialState.eventSink.invoke( ActionListEvents.ComputeForMessage( event = messageEvent, - canRedactOwn = true, - canRedactOther = false, - canSendMessage = true, - canSendReaction = true, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) ) ) // val loadingState = awaitItem() @@ -327,6 +359,7 @@ class ActionListPresenterTest { TimelineItemAction.Reply, TimelineItemAction.Forward, TimelineItemAction.Edit, + TimelineItemAction.Pin, TimelineItemAction.Copy, TimelineItemAction.CopyLink, TimelineItemAction.ViewSource, @@ -341,7 +374,7 @@ class ActionListPresenterTest { @Test fun `present - compute for my message cannot redact`() = runTest { - val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -353,10 +386,13 @@ class ActionListPresenterTest { initialState.eventSink.invoke( ActionListEvents.ComputeForMessage( event = messageEvent, - canRedactOwn = false, - canRedactOther = false, - canSendMessage = true, - canSendReaction = true, + userEventPermissions = aUserEventPermissions( + canRedactOwn = false, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) ) ) // val loadingState = awaitItem() @@ -370,6 +406,7 @@ class ActionListPresenterTest { TimelineItemAction.Reply, TimelineItemAction.Forward, TimelineItemAction.Edit, + TimelineItemAction.Pin, TimelineItemAction.Copy, TimelineItemAction.CopyLink, TimelineItemAction.ViewSource, @@ -383,7 +420,7 @@ class ActionListPresenterTest { @Test fun `present - compute for a media item`() = runTest { - val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -396,10 +433,13 @@ class ActionListPresenterTest { initialState.eventSink.invoke( ActionListEvents.ComputeForMessage( event = messageEvent, - canRedactOwn = true, - canRedactOther = false, - canSendMessage = true, - canSendReaction = true, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ), ) ) // val loadingState = awaitItem() @@ -412,6 +452,7 @@ class ActionListPresenterTest { actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Forward, + TimelineItemAction.Pin, TimelineItemAction.CopyLink, TimelineItemAction.ViewSource, TimelineItemAction.Redact, @@ -425,7 +466,7 @@ class ActionListPresenterTest { @Test fun `present - compute for a state item in debug build`() = runTest { - val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -437,10 +478,13 @@ class ActionListPresenterTest { initialState.eventSink.invoke( ActionListEvents.ComputeForMessage( event = stateEvent, - canRedactOwn = false, - canRedactOther = false, - canSendMessage = true, - canSendReaction = true, + userEventPermissions = aUserEventPermissions( + canRedactOwn = false, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) ) ) // val loadingState = awaitItem() @@ -451,8 +495,6 @@ class ActionListPresenterTest { event = stateEvent, displayEmojiReactions = false, actions = persistentListOf( - TimelineItemAction.Copy, - TimelineItemAction.CopyLink, TimelineItemAction.ViewSource, ) ) @@ -464,7 +506,7 @@ class ActionListPresenterTest { @Test fun `present - compute for a state item in non-debuggable build`() = runTest { - val presenter = createActionListPresenter(isDeveloperModeEnabled = false) + val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -476,33 +518,24 @@ class ActionListPresenterTest { initialState.eventSink.invoke( ActionListEvents.ComputeForMessage( event = stateEvent, - canRedactOwn = false, - canRedactOther = false, - canSendMessage = true, - canSendReaction = true, + userEventPermissions = aUserEventPermissions( + canRedactOwn = false, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) ) ) // val loadingState = awaitItem() // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) - val successState = awaitItem() - assertThat(successState.target).isEqualTo( - ActionListState.Target.Success( - event = stateEvent, - displayEmojiReactions = false, - actions = persistentListOf( - TimelineItemAction.Copy, - TimelineItemAction.CopyLink, - ) - ) - ) - initialState.eventSink.invoke(ActionListEvents.Clear) assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) } } @Test fun `present - compute message in non-debuggable build`() = runTest { - val presenter = createActionListPresenter(isDeveloperModeEnabled = false) + val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -514,10 +547,59 @@ class ActionListPresenterTest { initialState.eventSink.invoke( ActionListEvents.ComputeForMessage( event = messageEvent, - canRedactOwn = true, - canRedactOther = false, - canSendMessage = true, - canSendReaction = true, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) + ) + ) + // val loadingState = awaitItem() + // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + displayEmojiReactions = true, + actions = persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.Edit, + TimelineItemAction.Pin, + TimelineItemAction.Copy, + TimelineItemAction.CopyLink, + TimelineItemAction.Redact, + ) + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute message when user can't pin`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = false, + ) ) ) // val loadingState = awaitItem() @@ -533,6 +615,61 @@ class ActionListPresenterTest { TimelineItemAction.Edit, TimelineItemAction.Copy, TimelineItemAction.CopyLink, + TimelineItemAction.ViewSource, + TimelineItemAction.Redact, + ) + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute message when event is already pinned`() = runTest { + val room = FakeMatrixRoom().apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) + } + val presenter = createActionListPresenter( + isDeveloperModeEnabled = true, + isPinFeatureEnabled = true, + room = room + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) + ) + ) + // val loadingState = awaitItem() + // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + displayEmojiReactions = true, + actions = persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.Edit, + TimelineItemAction.Unpin, + TimelineItemAction.Copy, + TimelineItemAction.CopyLink, + TimelineItemAction.ViewSource, TimelineItemAction.Redact, ) ) @@ -544,7 +681,7 @@ class ActionListPresenterTest { @Test fun `present - compute message with no actions`() = runTest { - val presenter = createActionListPresenter(isDeveloperModeEnabled = false) + val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -561,10 +698,13 @@ class ActionListPresenterTest { initialState.eventSink.invoke( ActionListEvents.ComputeForMessage( event = messageEvent, - canRedactOwn = false, - canRedactOther = false, - canSendMessage = true, - canSendReaction = true, + userEventPermissions = aUserEventPermissions( + canRedactOwn = false, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) ) ) assertThat(awaitItem().target).isInstanceOf(ActionListState.Target.Success::class.java) @@ -572,10 +712,12 @@ class ActionListPresenterTest { initialState.eventSink.invoke( ActionListEvents.ComputeForMessage( event = redactedEvent, - canRedactOwn = false, - canRedactOther = false, - canSendMessage = true, - canSendReaction = true, + userEventPermissions = aUserEventPermissions( + canRedactOwn = false, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + ) ) ) awaitItem().run { @@ -586,7 +728,7 @@ class ActionListPresenterTest { @Test fun `present - compute not sent message`() = runTest { - val presenter = createActionListPresenter(isDeveloperModeEnabled = false) + val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -595,16 +737,20 @@ class ActionListPresenterTest { // No event id, so it's not sent yet eventId = null, isMine = true, + canBeRepliedTo = false, content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null), ) initialState.eventSink.invoke( ActionListEvents.ComputeForMessage( event = messageEvent, - canRedactOwn = true, - canRedactOther = false, - canSendMessage = true, - canSendReaction = true, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) ) ) val successState = awaitItem() @@ -624,7 +770,7 @@ class ActionListPresenterTest { @Test fun `present - compute for editable poll message`() = runTest { - val presenter = createActionListPresenter(isDeveloperModeEnabled = false) + val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -637,10 +783,13 @@ class ActionListPresenterTest { initialState.eventSink.invoke( ActionListEvents.ComputeForMessage( event = messageEvent, - canRedactOwn = true, - canRedactOther = false, - canSendMessage = true, - canSendReaction = true, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) ) ) val successState = awaitItem() @@ -652,6 +801,7 @@ class ActionListPresenterTest { TimelineItemAction.Reply, TimelineItemAction.Edit, TimelineItemAction.EndPoll, + TimelineItemAction.Pin, TimelineItemAction.CopyLink, TimelineItemAction.Redact, ) @@ -662,7 +812,7 @@ class ActionListPresenterTest { @Test fun `present - compute for non-editable poll message`() = runTest { - val presenter = createActionListPresenter(isDeveloperModeEnabled = false) + val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -675,10 +825,13 @@ class ActionListPresenterTest { initialState.eventSink.invoke( ActionListEvents.ComputeForMessage( event = messageEvent, - canRedactOwn = true, - canRedactOther = false, - canSendMessage = true, - canSendReaction = true, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true + ) ) ) val successState = awaitItem() @@ -689,6 +842,7 @@ class ActionListPresenterTest { actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.EndPoll, + TimelineItemAction.Pin, TimelineItemAction.CopyLink, TimelineItemAction.Redact, ) @@ -699,7 +853,7 @@ class ActionListPresenterTest { @Test fun `present - compute for ended poll message`() = runTest { - val presenter = createActionListPresenter(isDeveloperModeEnabled = false) + val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -712,10 +866,13 @@ class ActionListPresenterTest { initialState.eventSink.invoke( ActionListEvents.ComputeForMessage( event = messageEvent, - canRedactOwn = true, - canRedactOther = false, - canSendMessage = true, - canSendReaction = true, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) ) ) val successState = awaitItem() @@ -725,6 +882,7 @@ class ActionListPresenterTest { displayEmojiReactions = true, actions = persistentListOf( TimelineItemAction.Reply, + TimelineItemAction.Pin, TimelineItemAction.CopyLink, TimelineItemAction.Redact, ) @@ -735,22 +893,26 @@ class ActionListPresenterTest { @Test fun `present - compute for voice message`() = runTest { - val presenter = createActionListPresenter(isDeveloperModeEnabled = false) + val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() val messageEvent = aMessageEvent( isMine = true, + isEditable = false, content = aTimelineItemVoiceContent(), ) initialState.eventSink.invoke( ActionListEvents.ComputeForMessage( event = messageEvent, - canRedactOwn = true, - canRedactOther = false, - canSendMessage = true, - canSendReaction = true, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true + ) ) ) val successState = awaitItem() @@ -761,6 +923,7 @@ class ActionListPresenterTest { actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Forward, + TimelineItemAction.Pin, TimelineItemAction.CopyLink, TimelineItemAction.Redact, ) @@ -771,7 +934,7 @@ class ActionListPresenterTest { @Test fun `present - compute for call notify`() = runTest { - val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -783,10 +946,12 @@ class ActionListPresenterTest { initialState.eventSink.invoke( ActionListEvents.ComputeForMessage( event = messageEvent, - canRedactOwn = true, - canRedactOther = false, - canSendMessage = true, - canSendReaction = true, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + ) ) ) val successState = awaitItem() @@ -803,7 +968,20 @@ class ActionListPresenterTest { } } -private fun createActionListPresenter(isDeveloperModeEnabled: Boolean): ActionListPresenter { +private fun createActionListPresenter( + isDeveloperModeEnabled: Boolean, + isPinFeatureEnabled: Boolean, + room: MatrixRoom = FakeMatrixRoom(), +): ActionListPresenter { val preferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = isDeveloperModeEnabled) - return ActionListPresenter(appPreferencesStore = preferencesStore) + val featureFlagsService = FakeFeatureFlagService( + initialState = mapOf( + FeatureFlags.PinnedEvents.key to isPinFeatureEnabled, + ) + ) + return ActionListPresenter( + appPreferencesStore = preferencesStore, + featureFlagsService = featureFlagsService, + room = room + ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt index 7f2c3cfbeb..ca805d44c7 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt @@ -42,6 +42,7 @@ internal fun aMessageEvent( transactionId: TransactionId? = null, isMine: Boolean = true, isEditable: Boolean = true, + canBeRepliedTo: Boolean = true, content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = null, isEdited = false), inReplyTo: InReplyToDetails? = null, isThreaded: Boolean = false, @@ -58,6 +59,7 @@ internal fun aMessageEvent( sentTime = "", isMine = isMine, isEditable = isEditable, + canBeRepliedTo = canBeRepliedTo, reactionsState = aTimelineItemReactions(count = 0), readReceiptState = TimelineItemReadReceipts(emptyList().toImmutableList()), localSendState = sendState, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt index ce53e05090..e044ac7e2b 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt @@ -46,6 +46,7 @@ class TimelineItemGrouperTest { readReceiptState = TimelineItemReadReceipts(emptyList().toImmutableList()), localSendState = LocalEventSendState.Sent(AN_EVENT_ID), isEditable = false, + canBeRepliedTo = false, inReplyTo = null, isThreaded = false, debugInfo = aTimelineItemDebugInfo(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt index 4cabc6450a..2a54edad58 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt @@ -85,6 +85,7 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf( eventId = eventId, transactionId = null, isEditable = false, + canBeRepliedTo = false, isLocal = false, isOwn = false, isRemote = false, diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index d279ae6976..4d37576afc 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -120,6 +120,13 @@ enum class FeatureFlags( defaultValue = { it.buildType != BuildType.RELEASE }, isFinished = false, ), + PinnedEvents( + key = "feature.pinnedEvents", + title = "Pinned Events", + description = "Allow user to pin events in a room", + defaultValue = { false }, + isFinished = false, + ), SyncOnPush( key = "feature.syncOnPush", title = "Sync on push", diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index 378ada5b16..76c9f902c7 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -180,6 +180,8 @@ interface MatrixRoom : Closeable { suspend fun canUserTriggerRoomNotification(userId: UserId): Result + suspend fun canUserPinUnpin(userId: UserId): Result + suspend fun canUserJoinCall(userId: UserId): Result = canUserSendState(userId, StateEventType.CALL_MEMBER) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomInfo.kt index 70da8e7364..c7de03dac6 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomInfo.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomInfo.kt @@ -17,6 +17,7 @@ package io.element.android.libraries.matrix.api.room import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId @@ -52,4 +53,5 @@ data class MatrixRoomInfo( val hasRoomCall: Boolean, val activeRoomCallParticipants: ImmutableList, val heroes: ImmutableList, + val pinnedEventIds: ImmutableList ) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt index ef4e6f747e..2abd42f519 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt @@ -65,3 +65,8 @@ suspend fun MatrixRoom.canRedactOwn(): Result = canUserRedactOwn(sessio * Shortcut for calling [MatrixRoom.canRedactOther] with our own user. */ suspend fun MatrixRoom.canRedactOther(): Result = canUserRedactOther(sessionId) + +/** + * Shortcut for calling [MatrixRoom.canUserPinUnpin] with our own user. + */ +suspend fun MatrixRoom.canPinUnpin(): Result = canUserPinUnpin(sessionId) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt index 3d8defa252..f42ec5fbe9 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt @@ -169,4 +169,22 @@ interface Timeline : AutoCloseable { ): Result suspend fun loadReplyDetails(eventId: EventId): InReplyTo + + /** + * Adds a new pinned event by sending an updated `m.room.pinned_events` + * event containing the new event id. + * + * Returns `true` if we sent the request, `false` if the event was already + * pinned. + */ + suspend fun pinEvent(eventId: EventId): Result + + /** + * Adds a new pinned event by sending an updated `m.room.pinned_events` + * event without the event id we want to remove. + * + * Returns `true` if we sent the request, `false` if the event wasn't + * pinned + */ + suspend fun unpinEvent(eventId: EventId): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt index fa15f8f096..4a8f330c5c 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt @@ -26,6 +26,7 @@ data class EventTimelineItem( val eventId: EventId?, val transactionId: TransactionId?, val isEditable: Boolean, + val canBeRepliedTo: Boolean, val isLocal: Boolean, val isOwn: Boolean, val isRemote: Boolean, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapper.kt index 6a87b02a2b..a7461d6b0a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapper.kt @@ -16,6 +16,7 @@ package io.element.android.libraries.matrix.impl.room +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId @@ -58,7 +59,8 @@ class MatrixRoomInfoMapper { userDefinedNotificationMode = it.userDefinedNotificationMode?.map(), hasRoomCall = it.hasRoomCall, activeRoomCallParticipants = it.activeRoomCallParticipants.toImmutableList(), - heroes = it.elementHeroes().toImmutableList() + heroes = it.elementHeroes().toImmutableList(), + pinnedEventIds = it.pinnedEventIds.map(::EventId).toImmutableList(), ) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index b594bba5e4..96beb99d5a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -403,6 +403,12 @@ class RustMatrixRoom( } } + override suspend fun canUserPinUnpin(userId: UserId): Result { + return runCatching { + innerRoom.canUserPinUnpin(userId.value) + } + } + override suspend fun sendImage( file: File, thumbnailFile: File?, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index b57efc5603..87fca3d395 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -525,6 +525,18 @@ class RustTimeline( } } + override suspend fun pinEvent(eventId: EventId): Result = withContext(dispatcher) { + runCatching { + inner.pinEvent(eventId = eventId.value) + } + } + + override suspend fun unpinEvent(eventId: EventId): Result = withContext(dispatcher) { + runCatching { + inner.unpinEvent(eventId = eventId.value) + } + } + private suspend fun fetchDetailsForEvent(eventId: EventId): Result { return runCatching { inner.fetchDetailsForEvent(eventId.value) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt index dd0bdabd7f..2ae0a3e909 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt @@ -44,6 +44,7 @@ class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMap eventId = it.eventId()?.let(::EventId), transactionId = it.transactionId()?.let(::TransactionId), isEditable = it.isEditable(), + canBeRepliedTo = it.canBeRepliedTo(), isLocal = it.isLocal(), isOwn = it.isOwn(), isRemote = it.isRemote(), diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index cfd0267516..e2be5bcb66 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -125,6 +125,7 @@ class FakeMatrixRoom( private val getWidgetDriverResult: (MatrixWidgetSettings) -> Result = { lambdaError() }, private val canUserTriggerRoomNotificationResult: (UserId) -> Result = { lambdaError() }, private val canUserJoinCallResult: (UserId) -> Result = { lambdaError() }, + private val canUserPinUnpinResult: (UserId) -> Result = { lambdaError() }, private val setIsFavoriteResult: (Boolean) -> Result = { lambdaError() }, private val powerLevelsResult: () -> Result = { lambdaError() }, private val updatePowerLevelsResult: () -> Result = { lambdaError() }, @@ -289,6 +290,10 @@ class FakeMatrixRoom( return canUserJoinCallResult(userId) } + override suspend fun canUserPinUnpin(userId: UserId): Result { + return canUserPinUnpinResult(userId) + } + override suspend fun sendImage( file: File, thumbnailFile: File?, @@ -517,6 +522,7 @@ fun aRoomInfo( userPowerLevels: ImmutableMap = persistentMapOf(), activeRoomCallParticipants: List = emptyList(), heroes: List = emptyList(), + pinnedEventIds: List = emptyList(), ) = MatrixRoomInfo( id = id, name = name, @@ -542,6 +548,7 @@ fun aRoomInfo( userPowerLevels = userPowerLevels, activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(), heroes = heroes.toImmutableList(), + pinnedEventIds = pinnedEventIds.toImmutableList(), ) fun defaultRoomPowerLevels() = MatrixRoomPowerLevels( diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt index 4fa9f7a94c..ea9b353a67 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt @@ -33,6 +33,7 @@ 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.InReplyTo import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler +import io.element.android.tests.testutils.lambda.lambdaError import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -371,6 +372,16 @@ class FakeTimeline( override suspend fun loadReplyDetails(eventId: EventId) = loadReplyDetailsLambda(eventId) + var pinEventLambda: (eventId: EventId) -> Result = { lambdaError() } + override suspend fun pinEvent(eventId: EventId): Result { + return pinEventLambda(eventId) + } + + var unpinEventLambda: (eventId: EventId) -> Result = { lambdaError() } + override suspend fun unpinEvent(eventId: EventId): Result { + return unpinEventLambda(eventId) + } + var closeCounter = 0 private set diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt index d53bc7b18f..f9e9efb210 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt @@ -46,6 +46,7 @@ fun anEventTimelineItem( eventId: EventId = AN_EVENT_ID, transactionId: TransactionId? = null, isEditable: Boolean = false, + canBeRepliedTo: Boolean = false, isLocal: Boolean = false, isOwn: Boolean = false, isRemote: Boolean = false, @@ -61,6 +62,7 @@ fun anEventTimelineItem( eventId = eventId, transactionId = transactionId, isEditable = isEditable, + canBeRepliedTo = canBeRepliedTo, isLocal = isLocal, isOwn = isOwn, isRemote = isRemote, diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt index ab3c80a6e2..8ff8b1a894 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt @@ -56,6 +56,13 @@ fun MatrixRoom.canCall(updateKey: Long): State { } } +@Composable +fun MatrixRoom.canPinUnpin(updateKey: Long): State { + return produceState(initialValue = false, key1 = updateKey) { + value = canUserPinUnpin(sessionId).getOrElse { false } + } +} + @Composable fun MatrixRoom.isOwnUserAdmin(): Boolean { val roomInfo by roomInfoFlow.collectAsState(initial = null)