diff --git a/changelog.d/2093.bugfix b/changelog.d/2093.bugfix new file mode 100644 index 0000000000..59a6aa9b2a --- /dev/null +++ b/changelog.d/2093.bugfix @@ -0,0 +1 @@ +Disable ability to send reaction if the user does not have the permission to. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 7d421a256a..1ca834bc66 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -139,6 +139,7 @@ class MessagesPresenter @AssistedInject constructor( val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value) val userHasPermissionToRedact by room.canRedactAsState(updateKey = syncUpdateFlow.value) + val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.REACTION_SENT, updateKey = syncUpdateFlow.value) val roomName: Async by remember { derivedStateOf { roomInfo?.name?.let { Async.Success(it) } ?: Async.Uninitialized } } @@ -219,6 +220,7 @@ class MessagesPresenter @AssistedInject constructor( roomAvatar = roomAvatar, userHasPermissionToSendMessage = userHasPermissionToSendMessage, userHasPermissionToRedact = userHasPermissionToRedact, + userHasPermissionToSendReaction = userHasPermissionToSendReaction, composerState = composerState, voiceMessageComposerState = voiceMessageComposerState, timelineState = timelineState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index 7d342ab107..c7ef5f320f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -37,6 +37,7 @@ data class MessagesState( val roomAvatar: Async, val userHasPermissionToSendMessage: Boolean, val userHasPermissionToRedact: Boolean, + val userHasPermissionToSendReaction: Boolean, val composerState: MessageComposerState, val voiceMessageComposerState: VoiceMessageComposerState, val timelineState: TimelineState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index 00c15fb410..2984e4e087 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -87,6 +87,7 @@ fun aMessagesState() = MessagesState( roomAvatar = Async.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)), userHasPermissionToSendMessage = true, userHasPermissionToRedact = false, + userHasPermissionToSendReaction = true, composerState = aMessageComposerState().copy( richTextEditorState = RichTextEditorState("Hello", initialFocus = true), isFullScreen = false, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index dd728aa274..d6556c9a8c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -159,6 +159,7 @@ fun MessagesView( event = event, canRedact = state.userHasPermissionToRedact, canSendMessage = state.userHasPermissionToSendMessage, + canSendReaction = state.userHasPermissionToSendReaction, ) ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt index 7c8fad6c7c..6339716ccf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt @@ -24,5 +24,6 @@ sealed interface ActionListEvents { val event: TimelineItem.Event, val canRedact: Boolean, val canSendMessage: Boolean, + val canSendReaction: Boolean, ) : ActionListEvents } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt index d936ea9af8..9547d57ff7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt @@ -19,7 +19,6 @@ package io.element.android.features.messages.impl.actionlist import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -53,13 +52,6 @@ class ActionListPresenter @Inject constructor( val isDeveloperModeEnabled by preferencesStore.isDeveloperModeEnabledFlow().collectAsState(initial = false) - val displayEmojiReactions by remember { - derivedStateOf { - val event = (target.value as? ActionListState.Target.Success)?.event - event?.isRemote == true && event.content.canReact() - } - } - fun handleEvents(event: ActionListEvents) { when (event) { ActionListEvents.Clear -> target.value = ActionListState.Target.None @@ -67,6 +59,7 @@ class ActionListPresenter @Inject constructor( timelineItem = event.event, userCanRedact = event.canRedact, userCanSendMessage = event.canSendMessage, + userCanSendReaction = event.canSendReaction, isDeveloperModeEnabled = isDeveloperModeEnabled, target = target, ) @@ -75,7 +68,6 @@ class ActionListPresenter @Inject constructor( return ActionListState( target = target.value, - displayEmojiReactions = displayEmojiReactions, eventSink = { handleEvents(it) } ) } @@ -84,6 +76,7 @@ class ActionListPresenter @Inject constructor( timelineItem: TimelineItem.Event, userCanRedact: Boolean, userCanSendMessage: Boolean, + userCanSendReaction: Boolean, isDeveloperModeEnabled: Boolean, target: MutableState ) = launch { @@ -178,8 +171,15 @@ class ActionListPresenter @Inject constructor( } } } - if (actions.isNotEmpty()) { - target.value = ActionListState.Target.Success(timelineItem, actions.toImmutableList()) + val displayEmojiReactions = userCanSendReaction && + timelineItem.isRemote && + timelineItem.content.canReact() + if (actions.isNotEmpty() || displayEmojiReactions) { + target.value = ActionListState.Target.Success( + event = timelineItem, + displayEmojiReactions = displayEmojiReactions, + actions = actions.toImmutableList() + ) } else { target.value = ActionListState.Target.None } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListState.kt index a8fbf81486..5566f949d0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListState.kt @@ -24,7 +24,6 @@ import kotlinx.collections.immutable.ImmutableList @Immutable data class ActionListState( val target: Target, - val displayEmojiReactions: Boolean, val eventSink: (ActionListEvents) -> Unit, ) { sealed interface Target { @@ -32,6 +31,7 @@ data class ActionListState( data class Loading(val event: TimelineItem.Event) : Target data class Success( val event: TimelineItem.Event, + val displayEmojiReactions: Boolean, val actions: ImmutableList, ) : Target } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt index 3ea99aaede..e33f54de3e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt @@ -42,6 +42,7 @@ open class ActionListStateProvider : PreviewParameterProvider { event = aTimelineItemEvent().copy( reactionsState = reactionsState ), + displayEmojiReactions = true, actions = aTimelineItemActionList(), ) ), @@ -50,6 +51,7 @@ open class ActionListStateProvider : PreviewParameterProvider { event = aTimelineItemEvent(content = aTimelineItemImageContent()).copy( reactionsState = reactionsState ), + displayEmojiReactions = true, actions = aTimelineItemActionList(), ) ), @@ -58,6 +60,7 @@ open class ActionListStateProvider : PreviewParameterProvider { event = aTimelineItemEvent(content = aTimelineItemVideoContent()).copy( reactionsState = reactionsState ), + displayEmojiReactions = true, actions = aTimelineItemActionList(), ) ), @@ -66,6 +69,7 @@ open class ActionListStateProvider : PreviewParameterProvider { event = aTimelineItemEvent(content = aTimelineItemFileContent()).copy( reactionsState = reactionsState ), + displayEmojiReactions = true, actions = aTimelineItemActionList(), ) ), @@ -74,6 +78,7 @@ open class ActionListStateProvider : PreviewParameterProvider { event = aTimelineItemEvent(content = aTimelineItemAudioContent()).copy( reactionsState = reactionsState ), + displayEmojiReactions = true, actions = aTimelineItemActionList(), ) ), @@ -82,6 +87,7 @@ open class ActionListStateProvider : PreviewParameterProvider { event = aTimelineItemEvent(content = aTimelineItemVoiceContent()).copy( reactionsState = reactionsState ), + displayEmojiReactions = true, actions = aTimelineItemActionList(), ) ), @@ -90,6 +96,7 @@ open class ActionListStateProvider : PreviewParameterProvider { event = aTimelineItemEvent(content = aTimelineItemLocationContent()).copy( reactionsState = reactionsState ), + displayEmojiReactions = true, actions = aTimelineItemActionList(), ) ), @@ -98,18 +105,18 @@ open class ActionListStateProvider : PreviewParameterProvider { event = aTimelineItemEvent(content = aTimelineItemLocationContent()).copy( reactionsState = reactionsState ), + displayEmojiReactions = false, actions = aTimelineItemActionList(), ), - displayEmojiReactions = false, ), anActionListState().copy( target = ActionListState.Target.Success( event = aTimelineItemEvent(content = aTimelineItemPollContent()).copy( reactionsState = reactionsState ), + displayEmojiReactions = false, actions = aTimelineItemPollActionList(), ), - displayEmojiReactions = false, ), ) } @@ -117,7 +124,6 @@ open class ActionListStateProvider : PreviewParameterProvider { fun anActionListState() = ActionListState( target = ActionListState.Target.None, - displayEmojiReactions = true, eventSink = {} ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index c7b72ce527..93736933fd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -179,7 +179,7 @@ private fun SheetContent( HorizontalDivider() } } - if (state.displayEmojiReactions) { + if (target.displayEmojiReactions) { item { EmojiReactionsRow( highlightedEmojis = target.event.reactionsState.highlightedKeys, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 8613f2404e..37d2d7d2a6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -98,6 +98,7 @@ class TimelinePresenter @AssistedInject constructor( val paginationState by timeline.paginationState.collectAsState() val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value) + val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.REACTION_SENT, updateKey = syncUpdateFlow.value) val prevMostRecentItemId = rememberSaveable { mutableStateOf(null) } val newItemState = remember { mutableStateOf(NewEventState.None) } @@ -175,12 +176,18 @@ class TimelinePresenter @AssistedInject constructor( .launchIn(this) } + val timelineRoomInfo by remember { + derivedStateOf { + TimelineRoomInfo( + isDirect = room.isDirect, + userHasPermissionToSendMessage = userHasPermissionToSendMessage, + userHasPermissionToSendReaction = userHasPermissionToSendReaction, + ) + } + } return TimelineState( - timelineRoomInfo = TimelineRoomInfo( - isDirect = room.isDirect - ), + timelineRoomInfo = timelineRoomInfo, highlightedEventId = highlightedEventId.value, - userHasPermissionToSendMessage = userHasPermissionToSendMessage, paginationState = paginationState, timelineItems = timelineItems, showReadReceipts = readReceiptsEnabled, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt index d334aebf8c..abf7e626a3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt @@ -30,7 +30,6 @@ data class TimelineState( val timelineRoomInfo: TimelineRoomInfo, val showReadReceipts: Boolean, val highlightedEventId: EventId?, - val userHasPermissionToSendMessage: Boolean, val paginationState: MatrixTimeline.PaginationState, val newEventState: NewEventState, val sessionState: SessionState, @@ -40,4 +39,6 @@ data class TimelineState( @Immutable data class TimelineRoomInfo( val isDirect: Boolean, + val userHasPermissionToSendMessage: Boolean, + val userHasPermissionToSendReaction: Boolean, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index 63d8f0ed01..80d98b070d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -55,7 +55,6 @@ fun aTimelineState(timelineItems: ImmutableList = persistentListOf beginningOfRoomReached = false, ), highlightedEventId = null, - userHasPermissionToSendMessage = true, newEventState = NewEventState.None, sessionState = aSessionState( isSessionVerified = true, @@ -218,4 +217,6 @@ internal fun aTimelineRoomInfo( isDirect: Boolean = false, ) = TimelineRoomInfo( isDirect = isDirect, + userHasPermissionToSendMessage = true, + userHasPermissionToSendReaction = true, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index bc285b0bc5..137d18d8bc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -123,7 +123,6 @@ fun TimelineView( isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true && state.timelineItems.first().identifier() == timelineItem.identifier(), highlightedItem = state.highlightedEventId?.value, - userHasPermissionToSendMessage = state.userHasPermissionToSendMessage, onClick = onMessageClicked, onLongClick = onMessageLongClicked, onUserDataClick = onUserDataClicked, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt index a2a0ae5f4e..6c65ecf828 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt @@ -35,7 +35,6 @@ internal fun ATimelineItemEventRow( showReadReceipts = showReadReceipts, isLastOutgoingMessage = isLastOutgoingMessage, isHighlighted = isHighlighted, - canReply = true, onClick = {}, onLongClick = {}, onUserDataClick = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index d22cfedae7..19fc7dff0d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -81,6 +81,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo import io.element.android.features.messages.impl.timeline.model.metadata import io.element.android.libraries.androidutils.system.openUrlInExternalApp import io.element.android.libraries.designsystem.colors.AvatarColorsProvider @@ -112,7 +113,6 @@ fun TimelineItemEventRow( showReadReceipts: Boolean, isLastOutgoingMessage: Boolean, isHighlighted: Boolean, - canReply: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, onUserDataClick: (UserId) -> Unit, @@ -151,6 +151,7 @@ fun TimelineItemEventRow( } else { Spacer(modifier = Modifier.height(2.dp)) } + val canReply = timelineRoomInfo.userHasPermissionToSendMessage && event.content.canBeRepliedTo() if (canReply) { val state: SwipeableActionsState = rememberSwipeableActionsState() val offset = state.offset.floatValue @@ -335,6 +336,7 @@ private fun TimelineItemEventRowContent( if (event.reactionsState.reactions.isNotEmpty()) { TimelineItemReactionsView( reactionsState = event.reactionsState, + userCanSendReaction = timelineRoomInfo.userHasPermissionToSendReaction, isOutgoing = event.isMine, onReactionClicked = onReactionClicked, onReactionLongClicked = onReactionLongClicked, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt index d52795c052..76462e35a9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt @@ -131,7 +131,6 @@ private fun TimelineItemGroupedEventsRowContent( isLastOutgoingMessage = isLastOutgoingMessage, highlightedItem = highlightedItem, sessionState = sessionState, - userHasPermissionToSendMessage = false, onClick = onClick, onLongClick = onLongClick, inReplyToClick = inReplyToClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt index d30a60c7a4..60fda07d35 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt @@ -26,8 +26,8 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import io.element.android.features.messages.impl.R -import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.utils.CommonDrawables /** @@ -46,7 +46,7 @@ import io.element.android.libraries.designsystem.utils.CommonDrawables @Composable fun TimelineItemReactionsLayout( expandButton: @Composable () -> Unit, - addMoreButton: @Composable () -> Unit, + addMoreButton: (@Composable () -> Unit)?, modifier: Modifier = Modifier, itemSpacing: Dp = 0.dp, rowSpacing: Dp = 0.dp, @@ -82,21 +82,21 @@ fun TimelineItemReactionsLayout( // Used to render the collapsed state, this takes the rows inputted and adds the extra button to the last row, // removing only as many trailing reactions as needed to make space for it. - fun replaceTrailingItemsWithButtons(rowsIn: List>, expandButton: Placeable, addMoreButton: Placeable): List> { + fun replaceTrailingItemsWithButtons(rowsIn: List>, expandButton: Placeable, addMoreButton: Placeable?): List> { val rows = rowsIn.toMutableList() val lastRow = rows.last() - val buttonsWidth = expandButton.width + itemSpacing.toPx().toInt() + addMoreButton.width + val buttonsWidth = expandButton.width + itemSpacing.toPx().toInt() + (addMoreButton?.width ?: 0) var rowX = 0 lastRow.forEachIndexed { i, placeable -> val horizontalSpacing = if (i == 0) 0 else itemSpacing.toPx().toInt() rowX += placeable.width + horizontalSpacing if (rowX > constraints.maxWidth - (buttonsWidth + horizontalSpacing)) { - val lastRowWithButton = lastRow.take(i) + listOf(expandButton, addMoreButton) + val lastRowWithButton = lastRow.take(i) + listOfNotNull(expandButton, addMoreButton) rows[rows.size - 1] = lastRowWithButton return rows } } - val lastRowWithButton = lastRow + listOf(expandButton, addMoreButton) + val lastRowWithButton = lastRow + listOfNotNull(expandButton, addMoreButton) rows[rows.size - 1] = lastRowWithButton return rows } @@ -155,16 +155,15 @@ fun TimelineItemReactionsLayout( val newConstrains = constraints.copy(minHeight = maxHeight) reactionsPlaceables = subcompose(2, reactions).map { it.measure(newConstrains) } expandPlaceable = subcompose(3, expandButton).first().measure(newConstrains) - val addMorePlaceable = subcompose(4, addMoreButton).first().measure(newConstrains) - + val addMorePlaceable = addMoreButton?.let { subcompose(4, addMoreButton).first().measure(newConstrains) } // Calculate the layout of the rows with the reactions button and add more button - val reactionsAndAddMore = calculateRows(reactionsPlaceables + listOf(addMorePlaceable)) + val reactionsAndAddMore = calculateRows(reactionsPlaceables + listOfNotNull(addMorePlaceable)) // If we have extended beyond the defined number of rows we are showing the expand/collapse ui if (rowsBeforeCollapsible?.let { reactionsAndAddMore.size > it } == true) { if (expanded) { // Show all subviews with the add more button at the end - var reactionsAndButtons = calculateRows(reactionsPlaceables + listOf(expandPlaceable, addMorePlaceable)) + var reactionsAndButtons = calculateRows(reactionsPlaceables + listOfNotNull(expandPlaceable, addMorePlaceable)) reactionsAndButtons = ensureCollapseAndAddMoreButtonsAreOnTheSameRow(reactionsAndButtons) layoutRows(reactionsAndButtons) } else { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt index 764b4cdea4..0f5026f0a2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt @@ -31,8 +31,8 @@ import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.timeline.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.model.AggregatedReaction import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions -import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.utils.CommonDrawables import kotlinx.collections.immutable.ImmutableList @@ -40,6 +40,7 @@ import kotlinx.collections.immutable.ImmutableList fun TimelineItemReactionsView( reactionsState: TimelineItemReactions, isOutgoing: Boolean, + userCanSendReaction: Boolean, onReactionClicked: (emoji: String) -> Unit, onReactionLongClicked: (emoji: String) -> Unit, onMoreReactionsClicked: () -> Unit, @@ -49,6 +50,7 @@ fun TimelineItemReactionsView( TimelineItemReactionsView( modifier = modifier, reactions = reactionsState.reactions, + userCanSendReaction = userCanSendReaction, expanded = expanded, isOutgoing = isOutgoing, onReactionClick = onReactionClicked, @@ -61,6 +63,7 @@ fun TimelineItemReactionsView( @Composable private fun TimelineItemReactionsView( reactions: ImmutableList, + userCanSendReaction: Boolean, isOutgoing: Boolean, expanded: Boolean, onReactionClick: (emoji: String) -> Unit, @@ -93,19 +96,26 @@ private fun TimelineItemReactionsView( onLongClick = {} ) }, - addMoreButton = { - MessagesReactionButton( - content = MessagesReactionsButtonContent.Icon(CommonDrawables.ic_add_reaction), - onClick = onMoreReactionsClick, - onLongClick = {} - ) - }, + addMoreButton = if (userCanSendReaction) { + { + MessagesReactionButton( + content = MessagesReactionsButtonContent.Icon(CommonDrawables.ic_add_reaction), + onClick = onMoreReactionsClick, + onLongClick = {} + ) + } + } else null, reactions = { reactions.forEach { reaction -> CompositionLocalProvider(LocalLayoutDirection provides currentLayout) { MessagesReactionButton( content = MessagesReactionsButtonContent.Reaction(reaction = reaction), - onClick = { onReactionClick(reaction.key) }, + onClick = { + // Always allow user to redact their own reactions + if (reaction.isHighlighted || userCanSendReaction) { + onReactionClick(reaction.key) + } + }, onLongClick = { onReactionLongClick(reaction.key) } ) } @@ -157,6 +167,7 @@ private fun ContentToPreview( reactionsState = TimelineItemReactions( reactions ), + userCanSendReaction = true, isOutgoing = isOutgoing, onReactionClicked = {}, onReactionLongClicked = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index 92dbbe46b6..b7ab65e69e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -18,11 +18,10 @@ package io.element.android.features.messages.impl.timeline.components import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.TimelineEvents +import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent -import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo import io.element.android.features.messages.impl.timeline.session.SessionState import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId @@ -34,7 +33,6 @@ internal fun TimelineItemRow( showReadReceipts: Boolean, isLastOutgoingMessage: Boolean, highlightedItem: String?, - userHasPermissionToSendMessage: Boolean, sessionState: SessionState, onUserDataClick: (UserId) -> Unit, onClick: (TimelineItem.Event) -> Unit, @@ -77,7 +75,6 @@ internal fun TimelineItemRow( showReadReceipts = showReadReceipts, isLastOutgoingMessage = isLastOutgoingMessage, isHighlighted = highlightedItem == timelineItem.identifier(), - canReply = userHasPermissionToSendMessage && timelineItem.content.canBeRepliedTo(), onClick = { onClick(timelineItem) }, onLongClick = { onLongClick(timelineItem) }, onUserDataClick = onUserDataClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt index feb3246e54..f459e27d88 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt @@ -47,6 +47,7 @@ fun TimelineItemEventContent.canBeRepliedTo(): Boolean = /** * Return true if user can react (i.e. send a reaction) on the event content. + * This does not take into account the power level of the user. */ fun TimelineItemEventContent.canReact(): Boolean = when (this) { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt index 3c8dcf6e85..14ae8f1b59 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt @@ -62,14 +62,15 @@ class ActionListPresenterTest { }.test { val initialState = awaitItem() val messageEvent = aMessageEvent(isMine = true, content = TimelineItemRedactedContent) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) // val loadingState = awaitItem() // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) val successState = awaitItem() assertThat(successState.target).isEqualTo( ActionListState.Target.Success( - messageEvent, - persistentListOf( + event = messageEvent, + displayEmojiReactions = false, + actions = persistentListOf( TimelineItemAction.ViewSource, ) ) @@ -87,14 +88,15 @@ class ActionListPresenterTest { }.test { val initialState = awaitItem() val messageEvent = aMessageEvent(isMine = false, content = TimelineItemRedactedContent) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) // val loadingState = awaitItem() // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) val successState = awaitItem() assertThat(successState.target).isEqualTo( ActionListState.Target.Success( - messageEvent, - persistentListOf( + event = messageEvent, + displayEmojiReactions = false, + actions = persistentListOf( TimelineItemAction.ViewSource, ) ) @@ -115,14 +117,15 @@ class ActionListPresenterTest { isMine = false, content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) ) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) // val loadingState = awaitItem() // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) val successState = awaitItem() assertThat(successState.target).isEqualTo( ActionListState.Target.Success( - messageEvent, - persistentListOf( + event = messageEvent, + displayEmojiReactions = true, + actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Forward, TimelineItemAction.Copy, @@ -147,14 +150,15 @@ class ActionListPresenterTest { isMine = false, content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) ) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = false)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = false, canSendReaction = true)) // val loadingState = awaitItem() // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) val successState = awaitItem() assertThat(successState.target).isEqualTo( ActionListState.Target.Success( - messageEvent, - persistentListOf( + event = messageEvent, + displayEmojiReactions = true, + actions = persistentListOf( TimelineItemAction.Forward, TimelineItemAction.Copy, TimelineItemAction.ViewSource, @@ -178,12 +182,45 @@ class ActionListPresenterTest { isMine = false, content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) ) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = true, canSendMessage = true)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = true, canSendMessage = true, canSendReaction = true)) val successState = awaitItem() assertThat(successState.target).isEqualTo( ActionListState.Target.Success( - messageEvent, - persistentListOf( + event = messageEvent, + displayEmojiReactions = true, + actions = persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.Copy, + TimelineItemAction.ViewSource, + TimelineItemAction.ReportContent, + TimelineItemAction.Redact, + ) + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for others message and cannot send reaction`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = false, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) + ) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = true, canSendMessage = true, canSendReaction = false)) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + displayEmojiReactions = false, + actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Forward, TimelineItemAction.Copy, @@ -209,14 +246,15 @@ class ActionListPresenterTest { isMine = true, content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) ) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) // val loadingState = awaitItem() // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) val successState = awaitItem() assertThat(successState.target).isEqualTo( ActionListState.Target.Success( - messageEvent, - persistentListOf( + event = messageEvent, + displayEmojiReactions = true, + actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Forward, TimelineItemAction.Edit, @@ -242,14 +280,15 @@ class ActionListPresenterTest { isMine = true, content = aTimelineItemImageContent(), ) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) // val loadingState = awaitItem() // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) val successState = awaitItem() assertThat(successState.target).isEqualTo( ActionListState.Target.Success( - messageEvent, - persistentListOf( + event = messageEvent, + displayEmojiReactions = true, + actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Forward, TimelineItemAction.ViewSource, @@ -273,14 +312,15 @@ class ActionListPresenterTest { isMine = true, content = aTimelineItemStateEventContent(), ) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent, canRedact = false, canSendMessage = true)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) // val loadingState = awaitItem() // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) val successState = awaitItem() assertThat(successState.target).isEqualTo( ActionListState.Target.Success( - stateEvent, - persistentListOf( + event = stateEvent, + displayEmojiReactions = false, + actions = persistentListOf( TimelineItemAction.Copy, TimelineItemAction.ViewSource, ) @@ -302,14 +342,15 @@ class ActionListPresenterTest { isMine = true, content = aTimelineItemStateEventContent(), ) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent, canRedact = false, canSendMessage = true)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) // val loadingState = awaitItem() // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) val successState = awaitItem() assertThat(successState.target).isEqualTo( ActionListState.Target.Success( - stateEvent, - persistentListOf( + event = stateEvent, + displayEmojiReactions = false, + actions = persistentListOf( TimelineItemAction.Copy, ) ) @@ -330,14 +371,15 @@ class ActionListPresenterTest { isMine = true, content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null) ) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) // val loadingState = awaitItem() // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) val successState = awaitItem() assertThat(successState.target).isEqualTo( ActionListState.Target.Success( - messageEvent, - persistentListOf( + event = messageEvent, + displayEmojiReactions = true, + actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Forward, TimelineItemAction.Edit, @@ -367,13 +409,12 @@ class ActionListPresenterTest { content = TimelineItemRedactedContent, ) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) assertThat(awaitItem().target).isInstanceOf(ActionListState.Target.Success::class.java) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(redactedEvent, canRedact = false, canSendMessage = true)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(redactedEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) awaitItem().run { assertThat(target).isEqualTo(ActionListState.Target.None) - assertThat(displayEmojiReactions).isFalse() } } } @@ -391,19 +432,19 @@ class ActionListPresenterTest { content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null), ) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) val successState = awaitItem() assertThat(successState.target).isEqualTo( ActionListState.Target.Success( - messageEvent, - persistentListOf( + event = messageEvent, + displayEmojiReactions = false, + actions = persistentListOf( TimelineItemAction.Edit, TimelineItemAction.Copy, TimelineItemAction.Redact, ) ) ) - assertThat(successState.displayEmojiReactions).isFalse() } } @@ -419,12 +460,13 @@ class ActionListPresenterTest { isEditable = true, content = aTimelineItemPollContent(answerItems = aPollAnswerItemList(hasVotes = false)), ) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) val successState = awaitItem() assertThat(successState.target).isEqualTo( ActionListState.Target.Success( - messageEvent, - persistentListOf( + event = messageEvent, + displayEmojiReactions = true, + actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Edit, TimelineItemAction.EndPoll, @@ -432,9 +474,9 @@ class ActionListPresenterTest { ) ) ) - assertThat(successState.displayEmojiReactions).isTrue() } } + @Test fun `present - compute for non-editable poll message`() = runTest { val presenter = createActionListPresenter(isDeveloperModeEnabled = false) @@ -447,19 +489,19 @@ class ActionListPresenterTest { isEditable = false, content = aTimelineItemPollContent(answerItems = aPollAnswerItemList(hasVotes = true)), ) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) val successState = awaitItem() assertThat(successState.target).isEqualTo( ActionListState.Target.Success( - messageEvent, - persistentListOf( + event = messageEvent, + displayEmojiReactions = true, + actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.EndPoll, TimelineItemAction.Redact, ) ) ) - assertThat(successState.displayEmojiReactions).isTrue() } } @@ -475,18 +517,18 @@ class ActionListPresenterTest { isEditable = false, content = aTimelineItemPollContent(isEnded = true), ) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) val successState = awaitItem() assertThat(successState.target).isEqualTo( ActionListState.Target.Success( - messageEvent, - persistentListOf( + event = messageEvent, + displayEmojiReactions = true, + actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Redact, ) ) ) - assertThat(successState.displayEmojiReactions).isTrue() } } @@ -501,19 +543,19 @@ class ActionListPresenterTest { isMine = true, content = aTimelineItemVoiceContent(), ) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true, canSendReaction = true)) val successState = awaitItem() assertThat(successState.target).isEqualTo( ActionListState.Target.Success( - messageEvent, - persistentListOf( + event = messageEvent, + displayEmojiReactions = true, + actions = persistentListOf( TimelineItemAction.Reply, TimelineItemAction.Forward, TimelineItemAction.Redact, ) ) ) - assertThat(successState.displayEmojiReactions).isTrue() } } }