Merge pull request #3255 from element-hq/feature/fga/pinned_event_feature_flag

WIP Pinned events : add feature flag and pin/unpin actions
This commit is contained in:
ganfra 2024-07-31 10:09:02 +02:00 committed by GitHub
commit 48d2d3fc53
33 changed files with 755 additions and 342 deletions

View file

@ -20,10 +20,12 @@ import android.os.Build
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
@ -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.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin
import io.element.android.libraries.matrix.api.room.powerlevels.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.messages.reply.map
import io.element.android.libraries.matrix.ui.model.getAvatarData 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.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.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
@ -131,10 +134,9 @@ class MessagesPresenter @AssistedInject constructor(
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present() val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() 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 userEventPermissions by userEventPermissions(syncUpdateFlow.value)
val userHasPermissionToRedactOther by room.canRedactOtherAsState(updateKey = syncUpdateFlow.value)
val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.REACTION, updateKey = syncUpdateFlow.value)
val roomName: AsyncData<String> by remember { val roomName: AsyncData<String> by remember {
derivedStateOf { roomInfo?.name?.let { AsyncData.Success(it) } ?: AsyncData.Uninitialized } derivedStateOf { roomInfo?.name?.let { AsyncData.Success(it) } ?: AsyncData.Uninitialized }
} }
@ -211,11 +213,8 @@ class MessagesPresenter @AssistedInject constructor(
roomName = roomName, roomName = roomName,
roomAvatar = roomAvatar, roomAvatar = roomAvatar,
heroes = heroes, heroes = heroes,
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToRedactOwn = userHasPermissionToRedactOwn,
userHasPermissionToRedactOther = userHasPermissionToRedactOther,
userHasPermissionToSendReaction = userHasPermissionToSendReaction,
composerState = composerState, composerState = composerState,
userEventPermissions = userEventPermissions,
voiceMessageComposerState = voiceMessageComposerState, voiceMessageComposerState = voiceMessageComposerState,
timelineState = timelineState, timelineState = timelineState,
typingNotificationState = typingNotificationState, typingNotificationState = typingNotificationState,
@ -235,6 +234,19 @@ class MessagesPresenter @AssistedInject constructor(
) )
} }
@Composable
private fun userEventPermissions(updateKey: Long): State<UserEventPermissions> {
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 { private fun MatrixRoomInfo.avatarData(): AvatarData {
return AvatarData( return AvatarData(
id = id.value, id = id.value,
@ -268,6 +280,30 @@ class MessagesPresenter @AssistedInject constructor(
TimelineItemAction.Forward -> handleForwardAction(targetEvent) TimelineItemAction.Forward -> handleForwardAction(targetEvent)
TimelineItemAction.ReportContent -> handleReportAction(targetEvent) TimelineItemAction.ReportContent -> handleReportAction(targetEvent)
TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent, timelineState) 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))
}
} }
} }

View file

@ -37,10 +37,7 @@ data class MessagesState(
val roomName: AsyncData<String>, val roomName: AsyncData<String>,
val roomAvatar: AsyncData<AvatarData>, val roomAvatar: AsyncData<AvatarData>,
val heroes: ImmutableList<AvatarData>, val heroes: ImmutableList<AvatarData>,
val userHasPermissionToSendMessage: Boolean, val userEventPermissions: UserEventPermissions,
val userHasPermissionToRedactOwn: Boolean,
val userHasPermissionToRedactOther: Boolean,
val userHasPermissionToSendReaction: Boolean,
val composerState: MessageComposerState, val composerState: MessageComposerState,
val voiceMessageComposerState: VoiceMessageComposerState, val voiceMessageComposerState: VoiceMessageComposerState,
val timelineState: TimelineState, val timelineState: TimelineState,

View file

@ -53,7 +53,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
aMessagesState(), aMessagesState(),
aMessagesState(hasNetworkConnection = false), aMessagesState(hasNetworkConnection = false),
aMessagesState(composerState = aMessageComposerState(showAttachmentSourcePicker = true)), aMessagesState(composerState = aMessageComposerState(showAttachmentSourcePicker = true)),
aMessagesState(userHasPermissionToSendMessage = false), aMessagesState(userEventPermissions = aUserEventPermissions(canSendMessage = false)),
aMessagesState(showReinvitePrompt = true), aMessagesState(showReinvitePrompt = true),
aMessagesState( aMessagesState(
roomName = AsyncData.Uninitialized, roomName = AsyncData.Uninitialized,
@ -93,10 +93,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
fun aMessagesState( fun aMessagesState(
roomName: AsyncData<String> = AsyncData.Success("Room name"), roomName: AsyncData<String> = AsyncData.Success("Room name"),
roomAvatar: AsyncData<AvatarData> = AsyncData.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)), roomAvatar: AsyncData<AvatarData> = AsyncData.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)),
userHasPermissionToSendMessage: Boolean = true, userEventPermissions: UserEventPermissions = aUserEventPermissions(),
userHasPermissionToRedactOwn: Boolean = false,
userHasPermissionToRedactOther: Boolean = false,
userHasPermissionToSendReaction: Boolean = true,
composerState: MessageComposerState = aMessageComposerState( composerState: MessageComposerState = aMessageComposerState(
textEditorState = TextEditorState.Rich(aRichTextEditorState(initialText = "Hello", initialFocus = true)), textEditorState = TextEditorState.Rich(aRichTextEditorState(initialText = "Hello", initialFocus = true)),
isFullScreen = false, isFullScreen = false,
@ -122,10 +119,7 @@ fun aMessagesState(
roomName = roomName, roomName = roomName,
roomAvatar = roomAvatar, roomAvatar = roomAvatar,
heroes = persistentListOf(), heroes = persistentListOf(),
userHasPermissionToSendMessage = userHasPermissionToSendMessage, userEventPermissions = userEventPermissions,
userHasPermissionToRedactOwn = userHasPermissionToRedactOwn,
userHasPermissionToRedactOther = userHasPermissionToRedactOther,
userHasPermissionToSendReaction = userHasPermissionToSendReaction,
composerState = composerState, composerState = composerState,
voiceMessageComposerState = voiceMessageComposerState, voiceMessageComposerState = voiceMessageComposerState,
typingNotificationState = aTypingNotificationState(), typingNotificationState = aTypingNotificationState(),
@ -145,6 +139,20 @@ fun aMessagesState(
eventSink = eventSink, 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( fun aReactionSummaryState(
target: ReactionSummaryState.Summary? = null, target: ReactionSummaryState.Summary? = null,
eventSink: (ReactionSummaryEvents) -> Unit = {} eventSink: (ReactionSummaryEvents) -> Unit = {}

View file

@ -154,10 +154,7 @@ fun MessagesView(
state.actionListState.eventSink( state.actionListState.eventSink(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = event, event = event,
canRedactOwn = state.userHasPermissionToRedactOwn, userEventPermissions = state.userEventPermissions,
canRedactOther = state.userHasPermissionToRedactOther,
canSendMessage = state.userHasPermissionToSendMessage,
canSendReaction = state.userHasPermissionToSendReaction,
) )
) )
} }
@ -408,7 +405,7 @@ private fun MessagesViewComposerBottomSheetContents(
subcomposing: Boolean, subcomposing: Boolean,
state: MessagesState, state: MessagesState,
) { ) {
if (state.userHasPermissionToSendMessage) { if (state.userEventPermissions.canSendMessage) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
MentionSuggestionsPickerView( MentionSuggestionsPickerView(
modifier = Modifier modifier = Modifier

View file

@ -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
)
}
}

View file

@ -16,15 +16,13 @@
package io.element.android.features.messages.impl.actionlist 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 import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface ActionListEvents { sealed interface ActionListEvents {
data object Clear : ActionListEvents data object Clear : ActionListEvents
data class ComputeForMessage( data class ComputeForMessage(
val event: TimelineItem.Event, val event: TimelineItem.Event,
val canRedactOwn: Boolean, val userEventPermissions: UserEventPermissions,
val canRedactOther: Boolean,
val canSendMessage: Boolean,
val canSendReaction: Boolean,
) : ActionListEvents ) : ActionListEvents
} }

View file

@ -23,25 +23,36 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope 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.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent 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.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent 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.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent 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.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.features.messages.impl.timeline.model.event.canReact
import io.element.android.libraries.architecture.Presenter 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 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.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class ActionListPresenter @Inject constructor( class ActionListPresenter @Inject constructor(
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val featureFlagsService: FeatureFlagService,
private val room: MatrixRoom,
) : Presenter<ActionListState> { ) : Presenter<ActionListState> {
@Composable @Composable
override fun present(): ActionListState { override fun present(): ActionListState {
@ -52,17 +63,20 @@ class ActionListPresenter @Inject constructor(
} }
val isDeveloperModeEnabled by appPreferencesStore.isDeveloperModeEnabledFlow().collectAsState(initial = false) 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) { fun handleEvents(event: ActionListEvents) {
when (event) { when (event) {
ActionListEvents.Clear -> target.value = ActionListState.Target.None ActionListEvents.Clear -> target.value = ActionListState.Target.None
is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage( is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage(
timelineItem = event.event, timelineItem = event.event,
userCanRedactOwn = event.canRedactOwn, usersEventPermissions = event.userEventPermissions,
userCanRedactOther = event.canRedactOther,
userCanSendMessage = event.canSendMessage,
userCanSendReaction = event.canSendReaction,
isDeveloperModeEnabled = isDeveloperModeEnabled, isDeveloperModeEnabled = isDeveloperModeEnabled,
isPinnedEventsEnabled = isPinnedEventsEnabled,
pinnedEventIds = pinnedEventIds,
target = target, target = target,
) )
} }
@ -76,136 +90,22 @@ class ActionListPresenter @Inject constructor(
private fun CoroutineScope.computeForMessage( private fun CoroutineScope.computeForMessage(
timelineItem: TimelineItem.Event, timelineItem: TimelineItem.Event,
userCanRedactOwn: Boolean, usersEventPermissions: UserEventPermissions,
userCanRedactOther: Boolean,
userCanSendMessage: Boolean,
userCanSendReaction: Boolean,
isDeveloperModeEnabled: Boolean, isDeveloperModeEnabled: Boolean,
isPinnedEventsEnabled: Boolean,
pinnedEventIds: ImmutableList<EventId>,
target: MutableState<ActionListState.Target> target: MutableState<ActionListState.Target>
) = launch { ) = launch {
target.value = ActionListState.Target.Loading(timelineItem) target.value = ActionListState.Target.Loading(timelineItem)
val canRedact = timelineItem.isMine && userCanRedactOwn || !timelineItem.isMine && userCanRedactOther
val actions = val actions = buildActions(
when (timelineItem.content) { timelineItem = timelineItem,
is TimelineItemCallNotifyContent -> { usersEventPermissions = usersEventPermissions,
if (isDeveloperModeEnabled) { isDeveloperModeEnabled = isDeveloperModeEnabled,
listOf(TimelineItemAction.ViewSource) isPinnedEventsEnabled = isPinnedEventsEnabled,
} else { isEventPinned = pinnedEventIds.contains(timelineItem.eventId),
emptyList() )
} val displayEmojiReactions = usersEventPermissions.canSendReaction &&
}
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<TimelineItemAction> {
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 &&
timelineItem.isRemote && timelineItem.isRemote &&
timelineItem.content.canReact() timelineItem.content.canReact()
if (actions.isNotEmpty() || displayEmojiReactions) { 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<TimelineItemAction> {
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<TimelineItemAction>.postFilter(content: TimelineItemEventContent): List<TimelineItemAction> {
return filter { action ->
when (content) {
is TimelineItemCallNotifyContent,
is TimelineItemLegacyCallInviteContent,
is TimelineItemStateContent,
is TimelineItemRedactedContent -> {
action == TimelineItemAction.ViewSource
}
else -> true
}
}
}

View file

@ -39,4 +39,8 @@ sealed class TimelineItemAction(
data object ViewSource : TimelineItemAction(CommonStrings.action_view_source, CommonDrawables.ic_developer_options) 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 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 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)
} }

View file

@ -127,6 +127,7 @@ internal fun aTimelineItemEvent(
transactionId: TransactionId? = null, transactionId: TransactionId? = null,
isMine: Boolean = false, isMine: Boolean = false,
isEditable: Boolean = false, isEditable: Boolean = false,
canBeRepliedTo: Boolean = false,
senderDisplayName: String = "Sender", senderDisplayName: String = "Sender",
displayNameAmbiguous: Boolean = false, displayNameAmbiguous: Boolean = false,
content: TimelineItemEventContent = aTimelineItemTextContent(), content: TimelineItemEventContent = aTimelineItemTextContent(),
@ -150,6 +151,7 @@ internal fun aTimelineItemEvent(
sentTime = "12:34", sentTime = "12:34",
isMine = isMine, isMine = isMine,
isEditable = isEditable, isEditable = isEditable,
canBeRepliedTo = canBeRepliedTo,
senderProfile = aProfileTimelineDetailsReady( senderProfile = aProfileTimelineDetailsReady(
displayName = senderDisplayName, displayName = senderDisplayName,
displayNameAmbiguous = displayNameAmbiguous, displayNameAmbiguous = displayNameAmbiguous,

View file

@ -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.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent 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.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.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.EqualWidthColumn import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -148,7 +147,7 @@ fun TimelineItemEventRow(
} else { } else {
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
} }
val canReply = timelineRoomInfo.userHasPermissionToSendMessage && event.content.canBeRepliedTo() val canReply = timelineRoomInfo.userHasPermissionToSendMessage && event.canBeRepliedTo
if (canReply) { if (canReply) {
val state: SwipeableActionsState = rememberSwipeableActionsState() val state: SwipeableActionsState = rememberSwipeableActionsState()
val offset = state.offset.floatValue val offset = state.offset.floatValue

View file

@ -76,6 +76,7 @@ class TimelineItemEventFactory @Inject constructor(
content = contentFactory.create(currentTimelineItem.event), content = contentFactory.create(currentTimelineItem.event),
isMine = currentTimelineItem.event.isOwn, isMine = currentTimelineItem.event.isOwn,
isEditable = currentTimelineItem.event.isEditable, isEditable = currentTimelineItem.event.isEditable,
canBeRepliedTo = currentTimelineItem.event.canBeRepliedTo,
sentTime = sentTime, sentTime = sentTime,
groupPosition = groupPosition, groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState(), reactionsState = currentTimelineItem.computeReactionsState(),

View file

@ -74,6 +74,7 @@ sealed interface TimelineItem {
val sentTime: String = "", val sentTime: String = "",
val isMine: Boolean = false, val isMine: Boolean = false,
val isEditable: Boolean, val isEditable: Boolean,
val canBeRepliedTo: Boolean,
val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
val reactionsState: TimelineItemReactions, val reactionsState: TimelineItemReactions,
val readReceiptState: TimelineItemReadReceipts, val readReceiptState: TimelineItemReadReceipts,

View file

@ -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 = fun TimelineItemEventContent.canBeCopied(): Boolean =
when (this) { this is TimelineItemTextBasedContent
is TimelineItemTextBasedContent,
is TimelineItemStateContent,
is TimelineItemRedactedContent -> true
else -> false
}
/** /**
* Determine if the event content can be replied to. * Returns true if the event content can be forwarded.
* Note: it should match the logic in [io.element.android.features.messages.impl.actionlist.ActionListPresenter].
*/ */
fun TimelineItemEventContent.canBeRepliedTo(): Boolean = fun TimelineItemEventContent.canBeForwarded(): Boolean =
when (this) { when (this) {
is TimelineItemRedactedContent, is TimelineItemTextBasedContent,
is TimelineItemLegacyCallInviteContent, is TimelineItemImageContent,
is TimelineItemCallNotifyContent, is TimelineItemFileContent,
is TimelineItemStateContent -> false is TimelineItemAudioContent,
else -> true 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
} }
/** /**

View file

@ -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.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent 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.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.typing.TypingNotificationPresenter
import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer 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_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2 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.core.aBuildMeta
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
@ -137,8 +139,8 @@ class MessagesPresenterTest {
assertThat(initialState.roomName).isEqualTo(AsyncData.Success("")) assertThat(initialState.roomName).isEqualTo(AsyncData.Success(""))
assertThat(initialState.roomAvatar) assertThat(initialState.roomAvatar)
.isEqualTo(AsyncData.Success(AvatarData(id = A_ROOM_ID.value, name = "", url = AN_AVATAR_URL, size = AvatarSize.TimelineRoom))) .isEqualTo(AsyncData.Success(AvatarData(id = A_ROOM_ID.value, name = "", url = AN_AVATAR_URL, size = AvatarSize.TimelineRoom)))
assertThat(initialState.userHasPermissionToSendMessage).isTrue() assertThat(initialState.userEventPermissions.canSendMessage).isTrue()
assertThat(initialState.userHasPermissionToRedactOwn).isTrue() assertThat(initialState.userEventPermissions.canRedactOwn).isTrue()
assertThat(initialState.hasNetworkConnection).isTrue() assertThat(initialState.hasNetworkConnection).isTrue()
assertThat(initialState.snackbarMessage).isNull() assertThat(initialState.snackbarMessage).isNull()
assertThat(initialState.inviteProgress).isEqualTo(AsyncData.Uninitialized) assertThat(initialState.inviteProgress).isEqualTo(AsyncData.Uninitialized)
@ -155,7 +157,8 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) canUserPinUnpinResult = { Result.success(true) },
)
assertThat(room.markAsReadCalls).isEmpty() assertThat(room.markAsReadCalls).isEmpty()
val presenter = createMessagesPresenter(matrixRoom = room) val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -175,7 +178,8 @@ class MessagesPresenterTest {
canRedactOwnResult = { Result.success(true) }, canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
).apply { canUserPinUnpinResult = { Result.success(true) },
).apply {
givenRoomInfo(aRoomInfo(hasRoomCall = true)) givenRoomInfo(aRoomInfo(hasRoomCall = true))
} }
val presenter = createMessagesPresenter(matrixRoom = room) val presenter = createMessagesPresenter(matrixRoom = room)
@ -203,7 +207,8 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers) val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -240,7 +245,8 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers) val presenter = createMessagesPresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -298,7 +304,8 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter( val presenter = createMessagesPresenter(
clipboardHelper = clipboardHelper, clipboardHelper = clipboardHelper,
matrixRoom = matrixRoom, matrixRoom = matrixRoom,
@ -487,7 +494,8 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) canUserPinUnpinResult = { Result.success(true) },
)
val redactEventLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String? -> Result.success(true) } val redactEventLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String? -> Result.success(true) }
liveTimeline.redactEventLambda = redactEventLambda liveTimeline.redactEventLambda = redactEventLambda
@ -561,7 +569,8 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = room) val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -596,7 +605,8 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = room) val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -620,7 +630,8 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = room) val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -644,7 +655,8 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) canUserPinUnpinResult = { Result.success(true) },
)
room.givenRoomMembersState( room.givenRoomMembersState(
MatrixRoomMembersState.Ready( MatrixRoomMembersState.Ready(
persistentListOf( persistentListOf(
@ -679,7 +691,8 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) canUserPinUnpinResult = { Result.success(true) },
)
room.givenRoomMembersState( room.givenRoomMembersState(
MatrixRoomMembersState.Error( MatrixRoomMembersState.Error(
failure = Throwable(), failure = Throwable(),
@ -715,7 +728,8 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) canUserPinUnpinResult = { Result.success(true) },
)
room.givenRoomMembersState(MatrixRoomMembersState.Unknown) room.givenRoomMembersState(MatrixRoomMembersState.Unknown)
val presenter = createMessagesPresenter(matrixRoom = room) val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -741,7 +755,8 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) canUserPinUnpinResult = { Result.success(true) },
)
room.givenRoomMembersState( room.givenRoomMembersState(
MatrixRoomMembersState.Ready( MatrixRoomMembersState.Ready(
persistentListOf( persistentListOf(
@ -781,13 +796,14 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = matrixRoom) val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val state = awaitFirstItem() val state = awaitFirstItem()
assertThat(state.userHasPermissionToSendMessage).isTrue() assertThat(state.userEventPermissions.canSendMessage).isTrue()
} }
} }
@ -805,15 +821,16 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = matrixRoom) val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
// Default value // Default value
assertThat(awaitItem().userHasPermissionToSendMessage).isTrue() assertThat(awaitItem().userEventPermissions.canSendMessage).isTrue()
skipItems(1) skipItems(1)
assertThat(awaitItem().userHasPermissionToSendMessage).isFalse() assertThat(awaitItem().userEventPermissions.canSendMessage).isFalse()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@ -826,14 +843,15 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(false) }, canRedactOtherResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = matrixRoom) val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = consumeItemsUntilPredicate { it.userHasPermissionToRedactOwn }.last() val initialState = consumeItemsUntilPredicate { it.userEventPermissions.canRedactOwn }.last()
assertThat(initialState.userHasPermissionToRedactOwn).isTrue() assertThat(initialState.userEventPermissions.canRedactOwn).isTrue()
assertThat(initialState.userHasPermissionToRedactOther).isFalse() assertThat(initialState.userEventPermissions.canRedactOther).isFalse()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@ -846,14 +864,15 @@ class MessagesPresenterTest {
canRedactOwnResult = { Result.success(false) }, canRedactOwnResult = { Result.success(false) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
) canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(matrixRoom = matrixRoom) val presenter = createMessagesPresenter(matrixRoom = matrixRoom)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = consumeItemsUntilPredicate { it.userHasPermissionToRedactOther }.last() val initialState = consumeItemsUntilPredicate { it.userEventPermissions.canRedactOther }.last()
assertThat(initialState.userHasPermissionToRedactOwn).isFalse() assertThat(initialState.userEventPermissions.canRedactOwn).isFalse()
assertThat(initialState.userHasPermissionToRedactOther).isTrue() assertThat(initialState.userEventPermissions.canRedactOther).isTrue()
cancelAndIgnoreRemainingEvents() 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<Boolean>(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<Boolean>(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 <T> ReceiveTurbine<T>.awaitFirstItem(): T { private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
// Skip 2 item if Mentions feature is enabled, else 1 // Skip 2 item if Mentions feature is enabled, else 1
skipItems(if (FeatureFlags.Mentions.defaultValue(aBuildMeta())) 2 else 1) skipItems(if (FeatureFlags.Mentions.defaultValue(aBuildMeta())) 2 else 1)
@ -892,6 +979,7 @@ class MessagesPresenterTest {
canRedactOtherResult = { Result.success(true) }, canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) }, canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) }, typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
).apply { ).apply {
givenRoomInfo(aRoomInfo(id = roomId, name = "")) givenRoomInfo(aRoomInfo(id = roomId, name = ""))
}, },
@ -958,11 +1046,17 @@ class MessagesPresenterTest {
return timelinePresenter return timelinePresenter
} }
} }
val actionListPresenter = ActionListPresenter(appPreferencesStore = appPreferencesStore) val featureFlagService = FakeFeatureFlagService()
val actionListPresenter = ActionListPresenter(
appPreferencesStore = appPreferencesStore,
featureFlagsService = featureFlagService,
room = matrixRoom,
)
val typingNotificationPresenter = TypingNotificationPresenter( val typingNotificationPresenter = TypingNotificationPresenter(
room = matrixRoom, room = matrixRoom,
sessionPreferencesStore = sessionPreferencesStore, sessionPreferencesStore = sessionPreferencesStore,
) )
val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter() val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter()
val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider()) val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom) val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
@ -980,7 +1074,7 @@ class MessagesPresenterTest {
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),
navigator = navigator, navigator = navigator,
clipboardHelper = clipboardHelper, clipboardHelper = clipboardHelper,
featureFlagsService = FakeFeatureFlagService(), featureFlagsService = featureFlagService,
buildMeta = aBuildMeta(), buildMeta = aBuildMeta(),
dispatchers = coroutineDispatchers, dispatchers = coroutineDispatchers,
htmlConverterProvider = FakeHtmlConverterProvider(), htmlConverterProvider = FakeHtmlConverterProvider(),

View file

@ -26,6 +26,7 @@ import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText 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.attachments.Attachment
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState 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.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.aTimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineState 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.aReadReceiptData
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents 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.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.matrix.api.core.UserId
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -169,16 +168,20 @@ class MessagesViewTest {
userHasPermissionToRedactOwn: Boolean = false, userHasPermissionToRedactOwn: Boolean = false,
userHasPermissionToRedactOther: Boolean = false, userHasPermissionToRedactOther: Boolean = false,
userHasPermissionToSendReaction: Boolean = false, userHasPermissionToSendReaction: Boolean = false,
userCanPinEvent: Boolean = false,
) { ) {
val eventsRecorder = EventsRecorder<ActionListEvents>() val eventsRecorder = EventsRecorder<ActionListEvents>()
val state = aMessagesState( val state = aMessagesState(
actionListState = anActionListState( actionListState = anActionListState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
userHasPermissionToSendMessage = userHasPermissionToSendMessage, userEventPermissions = UserEventPermissions(
userHasPermissionToRedactOwn = userHasPermissionToRedactOwn, canSendMessage = userHasPermissionToSendMessage,
userHasPermissionToRedactOther = userHasPermissionToRedactOther, canRedactOwn = userHasPermissionToRedactOwn,
userHasPermissionToSendReaction = userHasPermissionToSendReaction, canRedactOther = userHasPermissionToRedactOther,
canSendReaction = userHasPermissionToSendReaction,
canPinUnpin = userCanPinEvent,
),
) )
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
rule.setMessagesView( rule.setMessagesView(
@ -189,10 +192,7 @@ class MessagesViewTest {
eventsRecorder.assertSingle( eventsRecorder.assertSingle(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = timelineItem, event = timelineItem,
canRedactOwn = state.userHasPermissionToRedactOwn, userEventPermissions = state.userEventPermissions,
canRedactOther = state.userHasPermissionToRedactOther,
canSendMessage = state.userHasPermissionToSendMessage,
canSendReaction = state.userHasPermissionToSendReaction,
) )
) )
} }
@ -237,9 +237,11 @@ class MessagesViewTest {
private fun swipeTest(userHasPermissionToSendMessage: Boolean) { private fun swipeTest(userHasPermissionToSendMessage: Boolean) {
val eventsRecorder = EventsRecorder<MessagesEvents>() val eventsRecorder = EventsRecorder<MessagesEvents>()
val canBeRepliedEvent = aTimelineItemEvent(canBeRepliedTo = true)
val cannotBeRepliedEvent = aTimelineItemEvent(canBeRepliedTo = false)
val state = aMessagesState( val state = aMessagesState(
timelineState = aTimelineState( timelineState = aTimelineState(
timelineItems = aTimelineItemList(aTimelineItemTextContent()), timelineItems = persistentListOf(canBeRepliedEvent, cannotBeRepliedEvent),
timelineRoomInfo = aTimelineRoomInfo( timelineRoomInfo = aTimelineRoomInfo(
userHasPermissionToSendMessage = userHasPermissionToSendMessage userHasPermissionToSendMessage = userHasPermissionToSendMessage
), ),
@ -249,10 +251,12 @@ class MessagesViewTest {
rule.setMessagesView( rule.setMessagesView(
state = state, 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) { if (userHasPermissionToSendMessage) {
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event eventsRecorder.assertSingle(MessagesEvents.HandleAction(TimelineItemAction.Reply, canBeRepliedEvent))
eventsRecorder.assertSingle(MessagesEvents.HandleAction(TimelineItemAction.Reply, timelineItem))
} else { } else {
eventsRecorder.assertEmpty() eventsRecorder.assertEmpty()
} }

View file

@ -20,6 +20,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat 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.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.fixtures.aMessageEvent import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent 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.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList 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.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.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -46,7 +53,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -57,7 +64,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for message from me redacted`() = runTest { fun `present - compute for message from me redacted`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -66,10 +73,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = false, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = false,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
// val loadingState = awaitItem() // val loadingState = awaitItem()
@ -91,7 +101,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for message from others redacted`() = runTest { fun `present - compute for message from others redacted`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -104,10 +114,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = false, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = false,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
// val loadingState = awaitItem() // val loadingState = awaitItem()
@ -129,7 +142,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for others message`() = runTest { fun `present - compute for others message`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -142,10 +155,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = false, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = false,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
// val loadingState = awaitItem() // val loadingState = awaitItem()
@ -158,6 +174,7 @@ class ActionListPresenterTest {
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
@ -172,7 +189,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for others message cannot sent message`() = runTest { 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) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -185,10 +202,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = true, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = true,
canSendMessage = false, canRedactOther = false,
canSendReaction = true canSendMessage = false,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
// val loadingState = awaitItem() // val loadingState = awaitItem()
@ -200,6 +220,7 @@ class ActionListPresenterTest {
displayEmojiReactions = true, displayEmojiReactions = true,
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
@ -214,7 +235,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for others message and can redact`() = runTest { 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) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -227,10 +248,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = false, userEventPermissions = aUserEventPermissions(
canRedactOther = true, canRedactOwn = false,
canSendMessage = true, canRedactOther = true,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
val successState = awaitItem() val successState = awaitItem()
@ -241,6 +265,7 @@ class ActionListPresenterTest {
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
@ -256,7 +281,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for others message and cannot send reaction`() = runTest { 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) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -269,10 +294,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = false, userEventPermissions = aUserEventPermissions(
canRedactOther = true, canRedactOwn = false,
canSendMessage = true, canRedactOther = true,
canSendReaction = false canSendMessage = true,
canSendReaction = false,
canPinUnpin = true,
)
) )
) )
val successState = awaitItem() val successState = awaitItem()
@ -283,6 +311,7 @@ class ActionListPresenterTest {
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
@ -298,7 +327,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for my message`() = runTest { fun `present - compute for my message`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -310,10 +339,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = true, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = true,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
// val loadingState = awaitItem() // val loadingState = awaitItem()
@ -327,6 +359,7 @@ class ActionListPresenterTest {
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Edit, TimelineItemAction.Edit,
TimelineItemAction.Pin,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
@ -341,7 +374,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for my message cannot redact`() = runTest { fun `present - compute for my message cannot redact`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -353,10 +386,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = false, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = false,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
// val loadingState = awaitItem() // val loadingState = awaitItem()
@ -370,6 +406,7 @@ class ActionListPresenterTest {
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Edit, TimelineItemAction.Edit,
TimelineItemAction.Pin,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
@ -383,7 +420,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for a media item`() = runTest { fun `present - compute for a media item`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -396,10 +433,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = true, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = true,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
),
) )
) )
// val loadingState = awaitItem() // val loadingState = awaitItem()
@ -412,6 +452,7 @@ class ActionListPresenterTest {
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
TimelineItemAction.Redact, TimelineItemAction.Redact,
@ -425,7 +466,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for a state item in debug build`() = runTest { 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) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -437,10 +478,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = stateEvent, event = stateEvent,
canRedactOwn = false, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = false,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
// val loadingState = awaitItem() // val loadingState = awaitItem()
@ -451,8 +495,6 @@ class ActionListPresenterTest {
event = stateEvent, event = stateEvent,
displayEmojiReactions = false, displayEmojiReactions = false,
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
) )
) )
@ -464,7 +506,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for a state item in non-debuggable build`() = runTest { 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) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -476,33 +518,24 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = stateEvent, event = stateEvent,
canRedactOwn = false, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = false,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
// val loadingState = awaitItem() // val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) // 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) assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
} }
} }
@Test @Test
fun `present - compute message in non-debuggable build`() = runTest { fun `present - compute message in non-debuggable build`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = false) val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -514,10 +547,59 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = true, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = true,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, 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() // val loadingState = awaitItem()
@ -533,6 +615,61 @@ class ActionListPresenterTest {
TimelineItemAction.Edit, TimelineItemAction.Edit,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink, 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, TimelineItemAction.Redact,
) )
) )
@ -544,7 +681,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute message with no actions`() = runTest { fun `present - compute message with no actions`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = false) val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -561,10 +698,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = false, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = false,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
assertThat(awaitItem().target).isInstanceOf(ActionListState.Target.Success::class.java) assertThat(awaitItem().target).isInstanceOf(ActionListState.Target.Success::class.java)
@ -572,10 +712,12 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = redactedEvent, event = redactedEvent,
canRedactOwn = false, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = false,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
)
) )
) )
awaitItem().run { awaitItem().run {
@ -586,7 +728,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute not sent message`() = runTest { fun `present - compute not sent message`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = false) val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -595,16 +737,20 @@ class ActionListPresenterTest {
// No event id, so it's not sent yet // No event id, so it's not sent yet
eventId = null, eventId = null,
isMine = true, isMine = true,
canBeRepliedTo = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null), content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null),
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = true, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = true,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
val successState = awaitItem() val successState = awaitItem()
@ -624,7 +770,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for editable poll message`() = runTest { fun `present - compute for editable poll message`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = false) val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -637,10 +783,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = true, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = true,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
val successState = awaitItem() val successState = awaitItem()
@ -652,6 +801,7 @@ class ActionListPresenterTest {
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Edit, TimelineItemAction.Edit,
TimelineItemAction.EndPoll, TimelineItemAction.EndPoll,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) )
@ -662,7 +812,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for non-editable poll message`() = runTest { fun `present - compute for non-editable poll message`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = false) val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -675,10 +825,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = true, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = true,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true
)
) )
) )
val successState = awaitItem() val successState = awaitItem()
@ -689,6 +842,7 @@ class ActionListPresenterTest {
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.EndPoll, TimelineItemAction.EndPoll,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) )
@ -699,7 +853,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for ended poll message`() = runTest { fun `present - compute for ended poll message`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = false) val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -712,10 +866,13 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = true, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = true,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
) )
) )
val successState = awaitItem() val successState = awaitItem()
@ -725,6 +882,7 @@ class ActionListPresenterTest {
displayEmojiReactions = true, displayEmojiReactions = true,
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) )
@ -735,22 +893,26 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for voice message`() = runTest { fun `present - compute for voice message`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = false) val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
val messageEvent = aMessageEvent( val messageEvent = aMessageEvent(
isMine = true, isMine = true,
isEditable = false,
content = aTimelineItemVoiceContent(), content = aTimelineItemVoiceContent(),
) )
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = true, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = true,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
canPinUnpin = true
)
) )
) )
val successState = awaitItem() val successState = awaitItem()
@ -761,6 +923,7 @@ class ActionListPresenterTest {
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) )
@ -771,7 +934,7 @@ class ActionListPresenterTest {
@Test @Test
fun `present - compute for call notify`() = runTest { fun `present - compute for call notify`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -783,10 +946,12 @@ class ActionListPresenterTest {
initialState.eventSink.invoke( initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage( ActionListEvents.ComputeForMessage(
event = messageEvent, event = messageEvent,
canRedactOwn = true, userEventPermissions = aUserEventPermissions(
canRedactOther = false, canRedactOwn = true,
canSendMessage = true, canRedactOther = false,
canSendReaction = true, canSendMessage = true,
canSendReaction = true,
)
) )
) )
val successState = awaitItem() 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) 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
)
} }

View file

@ -42,6 +42,7 @@ internal fun aMessageEvent(
transactionId: TransactionId? = null, transactionId: TransactionId? = null,
isMine: Boolean = true, isMine: Boolean = true,
isEditable: Boolean = true, isEditable: Boolean = true,
canBeRepliedTo: Boolean = true,
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = null, isEdited = false), content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = null, isEdited = false),
inReplyTo: InReplyToDetails? = null, inReplyTo: InReplyToDetails? = null,
isThreaded: Boolean = false, isThreaded: Boolean = false,
@ -58,6 +59,7 @@ internal fun aMessageEvent(
sentTime = "", sentTime = "",
isMine = isMine, isMine = isMine,
isEditable = isEditable, isEditable = isEditable,
canBeRepliedTo = canBeRepliedTo,
reactionsState = aTimelineItemReactions(count = 0), reactionsState = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()), readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
localSendState = sendState, localSendState = sendState,

View file

@ -46,6 +46,7 @@ class TimelineItemGrouperTest {
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()), readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
localSendState = LocalEventSendState.Sent(AN_EVENT_ID), localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
isEditable = false, isEditable = false,
canBeRepliedTo = false,
inReplyTo = null, inReplyTo = null,
isThreaded = false, isThreaded = false,
debugInfo = aTimelineItemDebugInfo(), debugInfo = aTimelineItemDebugInfo(),

View file

@ -85,6 +85,7 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
eventId = eventId, eventId = eventId,
transactionId = null, transactionId = null,
isEditable = false, isEditable = false,
canBeRepliedTo = false,
isLocal = false, isLocal = false,
isOwn = false, isOwn = false,
isRemote = false, isRemote = false,

View file

@ -120,6 +120,13 @@ enum class FeatureFlags(
defaultValue = { it.buildType != BuildType.RELEASE }, defaultValue = { it.buildType != BuildType.RELEASE },
isFinished = false, isFinished = false,
), ),
PinnedEvents(
key = "feature.pinnedEvents",
title = "Pinned Events",
description = "Allow user to pin events in a room",
defaultValue = { false },
isFinished = false,
),
SyncOnPush( SyncOnPush(
key = "feature.syncOnPush", key = "feature.syncOnPush",
title = "Sync on push", title = "Sync on push",

View file

@ -180,6 +180,8 @@ interface MatrixRoom : Closeable {
suspend fun canUserTriggerRoomNotification(userId: UserId): Result<Boolean> suspend fun canUserTriggerRoomNotification(userId: UserId): Result<Boolean>
suspend fun canUserPinUnpin(userId: UserId): Result<Boolean>
suspend fun canUserJoinCall(userId: UserId): Result<Boolean> = suspend fun canUserJoinCall(userId: UserId): Result<Boolean> =
canUserSendState(userId, StateEventType.CALL_MEMBER) canUserSendState(userId, StateEventType.CALL_MEMBER)

View file

@ -17,6 +17,7 @@
package io.element.android.libraries.matrix.api.room package io.element.android.libraries.matrix.api.room
import androidx.compose.runtime.Immutable 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.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -52,4 +53,5 @@ data class MatrixRoomInfo(
val hasRoomCall: Boolean, val hasRoomCall: Boolean,
val activeRoomCallParticipants: ImmutableList<String>, val activeRoomCallParticipants: ImmutableList<String>,
val heroes: ImmutableList<MatrixUser>, val heroes: ImmutableList<MatrixUser>,
val pinnedEventIds: ImmutableList<EventId>
) )

View file

@ -65,3 +65,8 @@ suspend fun MatrixRoom.canRedactOwn(): Result<Boolean> = canUserRedactOwn(sessio
* Shortcut for calling [MatrixRoom.canRedactOther] with our own user. * Shortcut for calling [MatrixRoom.canRedactOther] with our own user.
*/ */
suspend fun MatrixRoom.canRedactOther(): Result<Boolean> = canUserRedactOther(sessionId) suspend fun MatrixRoom.canRedactOther(): Result<Boolean> = canUserRedactOther(sessionId)
/**
* Shortcut for calling [MatrixRoom.canUserPinUnpin] with our own user.
*/
suspend fun MatrixRoom.canPinUnpin(): Result<Boolean> = canUserPinUnpin(sessionId)

View file

@ -169,4 +169,22 @@ interface Timeline : AutoCloseable {
): Result<MediaUploadHandler> ): Result<MediaUploadHandler>
suspend fun loadReplyDetails(eventId: EventId): InReplyTo 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<Boolean>
/**
* 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<Boolean>
} }

View file

@ -26,6 +26,7 @@ data class EventTimelineItem(
val eventId: EventId?, val eventId: EventId?,
val transactionId: TransactionId?, val transactionId: TransactionId?,
val isEditable: Boolean, val isEditable: Boolean,
val canBeRepliedTo: Boolean,
val isLocal: Boolean, val isLocal: Boolean,
val isOwn: Boolean, val isOwn: Boolean,
val isRemote: Boolean, val isRemote: Boolean,

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.impl.room 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.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -58,7 +59,8 @@ class MatrixRoomInfoMapper {
userDefinedNotificationMode = it.userDefinedNotificationMode?.map(), userDefinedNotificationMode = it.userDefinedNotificationMode?.map(),
hasRoomCall = it.hasRoomCall, hasRoomCall = it.hasRoomCall,
activeRoomCallParticipants = it.activeRoomCallParticipants.toImmutableList(), activeRoomCallParticipants = it.activeRoomCallParticipants.toImmutableList(),
heroes = it.elementHeroes().toImmutableList() heroes = it.elementHeroes().toImmutableList(),
pinnedEventIds = it.pinnedEventIds.map(::EventId).toImmutableList(),
) )
} }
} }

View file

@ -403,6 +403,12 @@ class RustMatrixRoom(
} }
} }
override suspend fun canUserPinUnpin(userId: UserId): Result<Boolean> {
return runCatching {
innerRoom.canUserPinUnpin(userId.value)
}
}
override suspend fun sendImage( override suspend fun sendImage(
file: File, file: File,
thumbnailFile: File?, thumbnailFile: File?,

View file

@ -525,6 +525,18 @@ class RustTimeline(
} }
} }
override suspend fun pinEvent(eventId: EventId): Result<Boolean> = withContext(dispatcher) {
runCatching {
inner.pinEvent(eventId = eventId.value)
}
}
override suspend fun unpinEvent(eventId: EventId): Result<Boolean> = withContext(dispatcher) {
runCatching {
inner.unpinEvent(eventId = eventId.value)
}
}
private suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> { private suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> {
return runCatching { return runCatching {
inner.fetchDetailsForEvent(eventId.value) inner.fetchDetailsForEvent(eventId.value)

View file

@ -44,6 +44,7 @@ class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMap
eventId = it.eventId()?.let(::EventId), eventId = it.eventId()?.let(::EventId),
transactionId = it.transactionId()?.let(::TransactionId), transactionId = it.transactionId()?.let(::TransactionId),
isEditable = it.isEditable(), isEditable = it.isEditable(),
canBeRepliedTo = it.canBeRepliedTo(),
isLocal = it.isLocal(), isLocal = it.isLocal(),
isOwn = it.isOwn(), isOwn = it.isOwn(),
isRemote = it.isRemote(), isRemote = it.isRemote(),

View file

@ -125,6 +125,7 @@ class FakeMatrixRoom(
private val getWidgetDriverResult: (MatrixWidgetSettings) -> Result<MatrixWidgetDriver> = { lambdaError() }, private val getWidgetDriverResult: (MatrixWidgetSettings) -> Result<MatrixWidgetDriver> = { lambdaError() },
private val canUserTriggerRoomNotificationResult: (UserId) -> Result<Boolean> = { lambdaError() }, private val canUserTriggerRoomNotificationResult: (UserId) -> Result<Boolean> = { lambdaError() },
private val canUserJoinCallResult: (UserId) -> Result<Boolean> = { lambdaError() }, private val canUserJoinCallResult: (UserId) -> Result<Boolean> = { lambdaError() },
private val canUserPinUnpinResult: (UserId) -> Result<Boolean> = { lambdaError() },
private val setIsFavoriteResult: (Boolean) -> Result<Unit> = { lambdaError() }, private val setIsFavoriteResult: (Boolean) -> Result<Unit> = { lambdaError() },
private val powerLevelsResult: () -> Result<MatrixRoomPowerLevels> = { lambdaError() }, private val powerLevelsResult: () -> Result<MatrixRoomPowerLevels> = { lambdaError() },
private val updatePowerLevelsResult: () -> Result<Unit> = { lambdaError() }, private val updatePowerLevelsResult: () -> Result<Unit> = { lambdaError() },
@ -289,6 +290,10 @@ class FakeMatrixRoom(
return canUserJoinCallResult(userId) return canUserJoinCallResult(userId)
} }
override suspend fun canUserPinUnpin(userId: UserId): Result<Boolean> {
return canUserPinUnpinResult(userId)
}
override suspend fun sendImage( override suspend fun sendImage(
file: File, file: File,
thumbnailFile: File?, thumbnailFile: File?,
@ -517,6 +522,7 @@ fun aRoomInfo(
userPowerLevels: ImmutableMap<UserId, Long> = persistentMapOf(), userPowerLevels: ImmutableMap<UserId, Long> = persistentMapOf(),
activeRoomCallParticipants: List<String> = emptyList(), activeRoomCallParticipants: List<String> = emptyList(),
heroes: List<MatrixUser> = emptyList(), heroes: List<MatrixUser> = emptyList(),
pinnedEventIds: List<EventId> = emptyList(),
) = MatrixRoomInfo( ) = MatrixRoomInfo(
id = id, id = id,
name = name, name = name,
@ -542,6 +548,7 @@ fun aRoomInfo(
userPowerLevels = userPowerLevels, userPowerLevels = userPowerLevels,
activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(), activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(),
heroes = heroes.toImmutableList(), heroes = heroes.toImmutableList(),
pinnedEventIds = pinnedEventIds.toImmutableList(),
) )
fun defaultRoomPowerLevels() = MatrixRoomPowerLevels( fun defaultRoomPowerLevels() = MatrixRoomPowerLevels(

View file

@ -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.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler 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.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -371,6 +372,16 @@ class FakeTimeline(
override suspend fun loadReplyDetails(eventId: EventId) = loadReplyDetailsLambda(eventId) override suspend fun loadReplyDetails(eventId: EventId) = loadReplyDetailsLambda(eventId)
var pinEventLambda: (eventId: EventId) -> Result<Boolean> = { lambdaError() }
override suspend fun pinEvent(eventId: EventId): Result<Boolean> {
return pinEventLambda(eventId)
}
var unpinEventLambda: (eventId: EventId) -> Result<Boolean> = { lambdaError() }
override suspend fun unpinEvent(eventId: EventId): Result<Boolean> {
return unpinEventLambda(eventId)
}
var closeCounter = 0 var closeCounter = 0
private set private set

View file

@ -46,6 +46,7 @@ fun anEventTimelineItem(
eventId: EventId = AN_EVENT_ID, eventId: EventId = AN_EVENT_ID,
transactionId: TransactionId? = null, transactionId: TransactionId? = null,
isEditable: Boolean = false, isEditable: Boolean = false,
canBeRepliedTo: Boolean = false,
isLocal: Boolean = false, isLocal: Boolean = false,
isOwn: Boolean = false, isOwn: Boolean = false,
isRemote: Boolean = false, isRemote: Boolean = false,
@ -61,6 +62,7 @@ fun anEventTimelineItem(
eventId = eventId, eventId = eventId,
transactionId = transactionId, transactionId = transactionId,
isEditable = isEditable, isEditable = isEditable,
canBeRepliedTo = canBeRepliedTo,
isLocal = isLocal, isLocal = isLocal,
isOwn = isOwn, isOwn = isOwn,
isRemote = isRemote, isRemote = isRemote,

View file

@ -56,6 +56,13 @@ fun MatrixRoom.canCall(updateKey: Long): State<Boolean> {
} }
} }
@Composable
fun MatrixRoom.canPinUnpin(updateKey: Long): State<Boolean> {
return produceState(initialValue = false, key1 = updateKey) {
value = canUserPinUnpin(sessionId).getOrElse { false }
}
}
@Composable @Composable
fun MatrixRoom.isOwnUserAdmin(): Boolean { fun MatrixRoom.isOwnUserAdmin(): Boolean {
val roomInfo by roomInfoFlow.collectAsState(initial = null) val roomInfo by roomInfoFlow.collectAsState(initial = null)