diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvent.kt index 73ba08b29d..9be11d244a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvent.kt @@ -11,8 +11,10 @@ package io.element.android.features.messages.impl.pinned.list import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId sealed interface PinnedMessagesListEvent { data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : PinnedMessagesListEvent data class OpenThread(val threadRootId: ThreadId) : PinnedMessagesListEvent + data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : PinnedMessagesListEvent } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt index 06d97f167c..36ae68de9b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt @@ -30,6 +30,7 @@ import io.element.android.libraries.androidutils.system.copyToClipboard import io.element.android.libraries.androidutils.system.openUrlInExternalApp import io.element.android.libraries.architecture.callback import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.UserId @@ -50,6 +51,7 @@ class PinnedMessagesListNode( actionListPresenterFactory: ActionListPresenter.Factory, private val timelineItemPresenterFactories: TimelineItemPresenterFactories, private val permalinkParser: PermalinkParser, + private val emojiPickerRenderer: EmojiPickerRenderer, ) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator { interface Callback : Plugin { fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean) @@ -134,6 +136,7 @@ class PinnedMessagesListNode( toastMessage = toastMessage, ) }, + emojiPickerRenderer = emojiPickerRenderer, modifier = modifier ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt index e1ee18e920..748fa084f8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt @@ -30,6 +30,8 @@ import io.element.android.features.messages.impl.link.LinkState import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider import io.element.android.features.messages.impl.pinned.keepDisplayablePinnedEvents import io.element.android.features.messages.impl.timeline.TimelineRoomInfo +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig import io.element.android.features.messages.impl.timeline.model.TimelineItem @@ -47,6 +49,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.room.roomMembers +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analyticsproviders.api.trackers.captureInteraction @@ -71,6 +74,8 @@ class PinnedMessagesListPresenter( private val linkPresenter: Presenter, private val snackbarDispatcher: SnackbarDispatcher, @Assisted private val actionListPresenter: Presenter, + private val customReactionPresenter: Presenter, + private val reactionSummaryPresenter: Presenter, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val analyticsService: AnalyticsService, @@ -88,7 +93,7 @@ class PinnedMessagesListPresenter( private val timelineItemsFactory: TimelineItemsFactory = timelineItemsFactoryCreator.create( config = TimelineItemsFactoryConfig( computeReadReceipts = false, - computeReactions = false, + computeReactions = true, ) ) @@ -96,6 +101,9 @@ class PinnedMessagesListPresenter( override fun present(): PinnedMessagesListState { htmlConverterProvider.Update() val roomInfo by room.roomInfoFlow.collectAsState() + val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms -> + perms.userEventPermissions() + } val timelineRoomInfo by remember { derivedStateOf { TimelineRoomInfo( @@ -103,7 +111,7 @@ class PinnedMessagesListPresenter( name = roomInfo.name, // We don't need to compute those values userHasPermissionToSendMessage = false, - userHasPermissionToSendReaction = false, + userHasPermissionToSendReaction = userEventPermissions.canSendReaction, // We do not care about the call state here. roomCallState = aStandByCallState(), // don't compute this value or the pin icon will be shown @@ -119,9 +127,8 @@ class PinnedMessagesListPresenter( } val timelineProtectionState = timelineProtectionPresenter.present() val linkState = linkPresenter.present() - val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms -> - perms.userEventPermissions() - } + val customReactionState = customReactionPresenter.present() + val reactionSummaryState = reactionSummaryPresenter.present() val displayThreadSummaries by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false) @@ -138,6 +145,7 @@ class PinnedMessagesListPresenter( when (event) { is PinnedMessagesListEvent.HandleAction -> sessionCoroutineScope.handleTimelineAction(event.action, event.event) is PinnedMessagesListEvent.OpenThread -> navigator.navigateToThread(event.threadRootId) + is PinnedMessagesListEvent.ToggleReaction -> sessionCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId) } } @@ -148,10 +156,20 @@ class PinnedMessagesListPresenter( displayThreadSummaries = displayThreadSummaries, userEventPermissions = userEventPermissions, timelineItems = pinnedMessageItems, + customReactionState = customReactionState, + reactionSummaryState = reactionSummaryState, eventSink = ::handleEvent, ) } + private fun CoroutineScope.toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId) { + launch { + timelineProvider.invokeOnTimeline { + toggleReaction(emoji, eventOrTransactionId) + } + } + } + private fun CoroutineScope.handleTimelineAction( action: TimelineItemAction, targetEvent: TimelineItem.Event, @@ -234,6 +252,8 @@ class PinnedMessagesListPresenter( linkState: LinkState, userEventPermissions: UserEventPermissions, timelineItems: AsyncData>, + customReactionState: CustomReactionState, + reactionSummaryState: ReactionSummaryState, eventSink: (PinnedMessagesListEvent) -> Unit ): PinnedMessagesListState { return when (timelineItems) { @@ -252,6 +272,8 @@ class PinnedMessagesListPresenter( userEventPermissions = userEventPermissions, timelineItems = timelineItems.data, actionListState = actionListState, + customReactionState = customReactionState, + reactionSummaryState = reactionSummaryState, eventSink = eventSink ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt index bf37029eff..6c55a0d90a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt @@ -16,6 +16,8 @@ import io.element.android.features.messages.impl.UserEventPermissions import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.link.LinkState import io.element.android.features.messages.impl.timeline.TimelineRoomInfo +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.libraries.ui.strings.CommonPlurals @@ -34,6 +36,8 @@ sealed interface PinnedMessagesListState { val timelineItems: ImmutableList, val actionListState: ActionListState, val linkState: LinkState, + val customReactionState: CustomReactionState, + val reactionSummaryState: ReactionSummaryState, val displayThreadSummaries: Boolean, val eventSink: (PinnedMessagesListEvent) -> Unit, ) : PinnedMessagesListState { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt index bfa9820a44..19f19da0de 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt @@ -10,6 +10,8 @@ package io.element.android.features.messages.impl.pinned.list import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.messages.impl.UserEventPermissions +import io.element.android.features.messages.impl.aCustomReactionState +import io.element.android.features.messages.impl.aReactionSummaryState import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.link.LinkState @@ -19,6 +21,8 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemDaySepara import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent @@ -92,6 +96,8 @@ fun aLoadedPinnedMessagesListState( linkState: LinkState = aLinkState(), timelineItems: List = emptyList(), actionListState: ActionListState = anActionListState(), + customReactionState: CustomReactionState = aCustomReactionState(), + reactionSummaryState: ReactionSummaryState = aReactionSummaryState(), aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT, displayThreadSummaries: Boolean = false, eventSink: (PinnedMessagesListEvent) -> Unit = {} @@ -101,6 +107,8 @@ fun aLoadedPinnedMessagesListState( linkState = linkState, timelineItems = timelineItems.toImmutableList(), actionListState = actionListState, + customReactionState = customReactionState, + reactionSummaryState = reactionSummaryState, userEventPermissions = aUserEventPermissions, displayThreadSummaries = displayThreadSummaries, eventSink = eventSink, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index 4f69bacd28..0b76d35d36 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -33,8 +33,12 @@ import io.element.android.features.messages.impl.link.LinkEvent import io.element.android.features.messages.impl.link.LinkView import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.components.TimelineItemRow +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState @@ -50,6 +54,8 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets +import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer +import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.ui.strings.CommonStrings @@ -66,6 +72,7 @@ fun PinnedMessagesListView( onUserDataClick: (MatrixUser) -> Unit, onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, + emojiPickerRenderer: EmojiPickerRenderer, modifier: Modifier = Modifier, ) { Scaffold( @@ -89,6 +96,7 @@ fun PinnedMessagesListView( onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, onErrorDismiss = onBackClick, + emojiPickerRenderer = emojiPickerRenderer, modifier = Modifier .padding(padding) .consumeWindowInsets(padding), @@ -121,6 +129,7 @@ private fun PinnedMessagesListContent( onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, onErrorDismiss: () -> Unit, + emojiPickerRenderer: EmojiPickerRenderer, modifier: Modifier = Modifier, ) { Box(modifier.fillMaxSize()) { @@ -141,6 +150,7 @@ private fun PinnedMessagesListContent( onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, + emojiPickerRenderer = emojiPickerRenderer, ) PinnedMessagesListState.Loading -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -180,6 +190,7 @@ private fun PinnedMessagesListLoaded( onUserDataClick: (MatrixUser) -> Unit, onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, + emojiPickerRenderer: EmojiPickerRenderer, modifier: Modifier = Modifier, ) { fun onActionSelected(timelineItemAction: TimelineItemAction, event: TimelineItem.Event) { @@ -203,11 +214,25 @@ private fun PinnedMessagesListLoaded( ) } + fun onReactionClick(emoji: String, event: TimelineItem.Event) { + state.eventSink(PinnedMessagesListEvent.ToggleReaction(emoji, event.eventOrTransactionId)) + } + + fun onReactionLongClick(emoji: String, event: TimelineItem.Event) { + event.eventId?.let { eventId -> + state.reactionSummaryState.eventSink(ReactionSummaryEvent.ShowReactionSummary(eventId, event.reactionsState.reactions, emoji)) + } + } + + fun onMoreReactionsClick(event: TimelineItem.Event) { + state.customReactionState.eventSink(CustomReactionEvent.ShowCustomReactionSheet(event)) + } + ActionListView( state = state.actionListState, onSelectAction = ::onActionSelected, - onCustomReactionClick = {}, - onEmojiReactionClick = { _, _ -> }, + onCustomReactionClick = ::onMoreReactionsClick, + onEmojiReactionClick = ::onReactionClick, onVerifiedUserSendFailureClick = {} ) @@ -239,9 +264,9 @@ private fun PinnedMessagesListLoaded( onLongClick = ::onMessageLongClick, displayThreadSummaries = displayThreadSummaries, inReplyToClick = {}, - onReactionClick = { _, _ -> }, - onReactionLongClick = { _, _ -> }, - onMoreReactionsClick = {}, + onReactionClick = ::onReactionClick, + onReactionLongClick = ::onReactionLongClick, + onMoreReactionsClick = ::onMoreReactionsClick, onReadReceiptClick = {}, onSwipeToReply = {}, onJoinCallClick = {}, @@ -273,6 +298,14 @@ private fun PinnedMessagesListLoaded( state.linkState, onLinkValid = onLinkClick, ) + CustomReactionBottomSheet( + state = state.customReactionState, + onSelectEmoji = { eventOrTransactionId, emoji -> + state.eventSink(PinnedMessagesListEvent.ToggleReaction(emoji.unicode, eventOrTransactionId)) + }, + emojiPickerRenderer = emojiPickerRenderer, + ) + ReactionSummaryView(state = state.reactionSummaryState) } @Composable @@ -322,5 +355,6 @@ internal fun PinnedMessagesListViewPreview(@PreviewParameter(PinnedMessagesListS onUserDataClick = {}, onLinkClick = {}, onLinkLongClick = {}, + emojiPickerRenderer = NoOpEmojiPickerRenderer, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt index 3efa707c3d..cafb16c41f 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt @@ -10,6 +10,8 @@ package io.element.android.features.messages.impl.pinned.list import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.PinUnpinAction +import io.element.android.features.messages.impl.aCustomReactionState +import io.element.android.features.messages.impl.aReactionSummaryState import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator @@ -400,6 +402,8 @@ class PinnedMessagesListPresenterTest { snackbarDispatcher = SnackbarDispatcher(), actionListPresenter = { anActionListState() }, linkPresenter = { aLinkState() }, + customReactionPresenter = { aCustomReactionState() }, + reactionSummaryPresenter = { aReactionSummaryState() }, analyticsService = analyticsService, featureFlagService = featureFlagService, sessionCoroutineScope = this, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt index 35e60684a9..856f74e33a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt @@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.timeline.aTimelineItemList import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent +import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalledWithParam @@ -111,6 +112,7 @@ private fun AndroidComposeUiTest.setPinnedMessagesListView( onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, onLinkLongClick = onLinkLongClick, + emojiPickerRenderer = NoOpEmojiPickerRenderer, ) } }