Merge pull request #6676 from bxdxnn/misc/reactions-pinned-messages

Show reactions in the pinned messages list
This commit is contained in:
Benoit Marty
2026-08-24 09:37:51 +02:00
committed by GitHub
8 changed files with 89 additions and 10 deletions
@@ -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
}
@@ -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
)
}
@@ -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<LinkState>,
private val snackbarDispatcher: SnackbarDispatcher,
@Assisted private val actionListPresenter: Presenter<ActionListState>,
private val customReactionPresenter: Presenter<CustomReactionState>,
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
@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<ImmutableList<TimelineItem>>,
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
)
}
@@ -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<TimelineItem>,
val actionListState: ActionListState,
val linkState: LinkState,
val customReactionState: CustomReactionState,
val reactionSummaryState: ReactionSummaryState,
val displayThreadSummaries: Boolean,
val eventSink: (PinnedMessagesListEvent) -> Unit,
) : PinnedMessagesListState {
@@ -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<TimelineItem> = 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,
@@ -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,
)
}
@@ -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,
@@ -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<ComponentActivity>.setPinnedMessagesListView(
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
emojiPickerRenderer = NoOpEmojiPickerRenderer,
)
}
}