Reaction summary view and sorting reactions by count and then timestamp (#942)
* Sort reactions by count and then timestamp - Sort reactions within an aggregation by timestamp descending. This puts the most recent at the top, useful in cases like the reaction summary view or getting the most recent reaction. - Sort aggregated reactions by count and then timestamp ascending, using the most recent reaction in the aggregation. This appends new aggregations on the end of the reaction layout. * Add reaction summary view * fix warnings * Fix test unit tests and add sorting tests - Fix broken build in test code - Add a test for reaction sorting * Remove default closure, move logic to presenter and add tests * Update screenshots * Fix imports * Revert Screenshots I didn't update * Fix imports remove screetshots * Update screenshots * Update screenshots * Address comments. * Update screenshots * Remove unnecessary snapshotFlow * Fix code quality checks --------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
ca3e284991
commit
0b95ef09b7
30 changed files with 829 additions and 39 deletions
|
|
@ -39,6 +39,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
|||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter
|
||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||
|
|
@ -84,6 +85,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
private val timelinePresenter: TimelinePresenter,
|
||||
private val actionListPresenter: ActionListPresenter,
|
||||
private val customReactionPresenter: CustomReactionPresenter,
|
||||
private val reactionSummaryPresenter: ReactionSummaryPresenter,
|
||||
private val retrySendMenuPresenter: RetrySendMenuPresenter,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
|
|
@ -105,6 +107,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
val timelineState = timelinePresenter.present()
|
||||
val actionListState = actionListPresenter.present()
|
||||
val customReactionState = customReactionPresenter.present()
|
||||
val reactionSummaryState = reactionSummaryPresenter.present()
|
||||
val retryState = retrySendMenuPresenter.present()
|
||||
|
||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||
|
|
@ -166,6 +169,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
timelineState = timelineState,
|
||||
actionListState = actionListState,
|
||||
customReactionState = customReactionState,
|
||||
reactionSummaryState = reactionSummaryState,
|
||||
retrySendMenuState = retryState,
|
||||
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
|
||||
snackbarMessage = snackbarMessage,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListState
|
|||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||
import io.element.android.features.messages.impl.timeline.TimelineState
|
||||
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.components.retrysendmenu.RetrySendMenuState
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
|
|
@ -38,6 +39,7 @@ data class MessagesState(
|
|||
val timelineState: TimelineState,
|
||||
val actionListState: ActionListState,
|
||||
val customReactionState: CustomReactionState,
|
||||
val reactionSummaryState: ReactionSummaryState,
|
||||
val retrySendMenuState: RetrySendMenuState,
|
||||
val hasNetworkConnection: Boolean,
|
||||
val snackbarMessage: SnackbarMessage?,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import io.element.android.features.messages.impl.messagecomposer.aMessageCompose
|
|||
import io.element.android.features.messages.impl.timeline.aTimelineItemList
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineState
|
||||
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.components.retrysendmenu.RetrySendMenuState
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.libraries.architecture.Async
|
||||
|
|
@ -68,6 +69,10 @@ fun aMessagesState() = MessagesState(
|
|||
selectedEventId = null,
|
||||
eventSink = {},
|
||||
),
|
||||
reactionSummaryState = ReactionSummaryState(
|
||||
target = null,
|
||||
eventSink = {},
|
||||
),
|
||||
hasNetworkConnection = true,
|
||||
snackbarMessage = null,
|
||||
inviteProgress = Async.Uninitialized,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
|||
import io.element.android.features.messages.impl.timeline.TimelineView
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
|
||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents
|
||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
|
|
@ -127,6 +129,11 @@ fun MessagesView(
|
|||
state.eventSink(MessagesEvents.ToggleReaction(emoji, event.eventId))
|
||||
}
|
||||
|
||||
fun onEmojiReactionLongClicked(emoji: String, event: TimelineItem.Event) {
|
||||
if (event.eventId == null) return
|
||||
state.reactionSummaryState.eventSink(ReactionSummaryEvents.ShowReactionSummary(event.eventId, event.reactionsState.reactions, emoji))
|
||||
}
|
||||
|
||||
fun onMoreReactionsClicked(event: TimelineItem.Event) {
|
||||
state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event.eventId))
|
||||
}
|
||||
|
|
@ -160,6 +167,7 @@ fun MessagesView(
|
|||
}
|
||||
},
|
||||
onReactionClicked = ::onEmojiReactionClicked,
|
||||
onReactionLongClicked = ::onEmojiReactionLongClicked,
|
||||
onMoreReactionsClicked = ::onMoreReactionsClicked,
|
||||
onSendLocationClicked = onSendLocationClicked,
|
||||
onSwipeToReply = { targetEvent ->
|
||||
|
|
@ -194,6 +202,7 @@ fun MessagesView(
|
|||
}
|
||||
)
|
||||
|
||||
ReactionSummaryView(state = state.reactionSummaryState)
|
||||
RetrySendMessageMenu(
|
||||
state = state.retrySendMenuState
|
||||
)
|
||||
|
|
@ -246,6 +255,7 @@ private fun MessagesViewContent(
|
|||
onMessageClicked: (TimelineItem.Event) -> Unit,
|
||||
onUserDataClicked: (UserId) -> Unit,
|
||||
onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
|
||||
onReactionLongClicked: (key: String, TimelineItem.Event) -> Unit,
|
||||
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
|
||||
onMessageLongClicked: (TimelineItem.Event) -> Unit,
|
||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||
|
|
@ -269,6 +279,7 @@ private fun MessagesViewContent(
|
|||
onUserDataClicked = onUserDataClicked,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onReactionClicked = onReactionClicked,
|
||||
onReactionLongClicked = onReactionLongClicked,
|
||||
onMoreReactionsClicked = onMoreReactionsClicked,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline
|
||||
|
||||
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
|
||||
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.TimelineItemReactions
|
||||
import io.element.android.features.messages.impl.timeline.model.anAggregatedReaction
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
|
|
@ -141,7 +141,11 @@ fun aTimelineItemReactions(
|
|||
reactions = buildList {
|
||||
repeat(count) { index ->
|
||||
val key = emojis[index % emojis.size]
|
||||
add(AggregatedReaction(key = key, count = 1 + index, isHighlighted = isHighlighted))
|
||||
add(anAggregatedReaction(
|
||||
key = key,
|
||||
count = index + 1,
|
||||
isHighlighted = isHighlighted
|
||||
))
|
||||
}
|
||||
}.toPersistentList()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ fun TimelineView(
|
|||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||
onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit,
|
||||
onReactionLongClicked: (emoji: String, TimelineItem.Event) -> Unit,
|
||||
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -121,6 +122,7 @@ fun TimelineView(
|
|||
onUserDataClick = onUserDataClicked,
|
||||
inReplyToClick = ::inReplyToClicked,
|
||||
onReactionClick = onReactionClicked,
|
||||
onReactionLongClick = onReactionLongClicked,
|
||||
onMoreReactionsClick = onMoreReactionsClicked,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
|
|
@ -155,6 +157,7 @@ fun TimelineItemRow(
|
|||
onLongClick: (TimelineItem.Event) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
||||
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
|
||||
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
|
||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||
|
|
@ -186,6 +189,7 @@ fun TimelineItemRow(
|
|||
onUserDataClick = onUserDataClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onSwipeToReply = { onSwipeToReply(timelineItem) },
|
||||
|
|
@ -224,6 +228,7 @@ fun TimelineItemRow(
|
|||
onUserDataClick = onUserDataClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onSwipeToReply = {},
|
||||
)
|
||||
|
|
@ -321,6 +326,7 @@ internal fun TimelineViewPreview(
|
|||
onUserDataClicked = {},
|
||||
onMessageLongClicked = {},
|
||||
onReactionClicked = { _, _ -> },
|
||||
onReactionLongClicked = { _, _ -> },
|
||||
onMoreReactionsClicked = {},
|
||||
onSwipeToReply = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,9 +17,10 @@
|
|||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
|
|
@ -54,8 +55,10 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
|||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun MessagesReactionButton(
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
content: MessagesReactionsButtonContent,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -82,7 +85,10 @@ fun MessagesReactionButton(
|
|||
.padding(vertical = 2.dp, horizontal = 2.dp)
|
||||
// Clip click indicator inside the outer border
|
||||
.clip(RoundedCornerShape(corner = CornerSize(12.dp)))
|
||||
.clickable(onClick = onClick)
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
// Inner border, to highlight when selected
|
||||
.border(BorderStroke(1.dp, borderColor), RoundedCornerShape(corner = CornerSize(12.dp)))
|
||||
.background(buttonColor, RoundedCornerShape(corner = CornerSize(12.dp)))
|
||||
|
|
@ -162,7 +168,8 @@ private fun ReactionContent(
|
|||
internal fun MessagesReactionButtonPreview(@PreviewParameter(AggregatedReactionProvider::class) reaction: AggregatedReaction) = ElementPreview {
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Reaction(reaction),
|
||||
onClick = {}
|
||||
onClick = {},
|
||||
onLongClick = {}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -172,11 +179,13 @@ internal fun MessagesReactionExtraButtonsPreview() = ElementPreview {
|
|||
Row {
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction),
|
||||
onClick = {}
|
||||
onClick = {},
|
||||
onLongClick = {}
|
||||
)
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Text("12 more"),
|
||||
onClick = {}
|
||||
onClick = {},
|
||||
onLongClick = {}
|
||||
)
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Reaction(
|
||||
|
|
@ -184,7 +193,8 @@ internal fun MessagesReactionExtraButtonsPreview() = ElementPreview {
|
|||
key = "A very long reaction with many characters that should be truncated"
|
||||
)
|
||||
),
|
||||
onClick = {}
|
||||
onClick = {},
|
||||
onLongClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ fun TimelineItemEventRow(
|
|||
inReplyToClick: (EventId) -> Unit,
|
||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
|
||||
onReactionLongClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
|
||||
onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit,
|
||||
onSwipeToReply: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
|
|
@ -169,6 +170,7 @@ fun TimelineItemEventRow(
|
|||
inReplyToClicked = ::inReplyToClicked,
|
||||
onUserDataClicked = ::onUserDataClicked,
|
||||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
|
||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||
)
|
||||
}
|
||||
|
|
@ -184,6 +186,7 @@ fun TimelineItemEventRow(
|
|||
inReplyToClicked = ::inReplyToClicked,
|
||||
onUserDataClicked = ::onUserDataClicked,
|
||||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
|
||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||
)
|
||||
}
|
||||
|
|
@ -224,6 +227,7 @@ private fun TimelineItemEventRowContent(
|
|||
inReplyToClicked: () -> Unit,
|
||||
onUserDataClicked: () -> Unit,
|
||||
onReactionClicked: (emoji: String) -> Unit,
|
||||
onReactionLongClicked: (emoji: String) -> Unit,
|
||||
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -292,6 +296,7 @@ private fun TimelineItemEventRowContent(
|
|||
reactionsState = event.reactionsState,
|
||||
isOutgoing = event.isMine,
|
||||
onReactionClicked = onReactionClicked,
|
||||
onReactionLongClicked = onReactionLongClicked,
|
||||
onMoreReactionsClicked = { onMoreReactionsClicked(event) },
|
||||
modifier = Modifier
|
||||
.constrainAs(reactions) {
|
||||
|
|
@ -588,6 +593,7 @@ private fun ContentToPreview() {
|
|||
onUserDataClick = {},
|
||||
inReplyToClick = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
|
|
@ -607,6 +613,7 @@ private fun ContentToPreview() {
|
|||
onUserDataClick = {},
|
||||
inReplyToClick = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
|
|
@ -653,6 +660,7 @@ private fun ContentToPreviewWithReply() {
|
|||
onUserDataClick = {},
|
||||
inReplyToClick = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
|
|
@ -673,6 +681,7 @@ private fun ContentToPreviewWithReply() {
|
|||
onUserDataClick = {},
|
||||
inReplyToClick = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
|
|
@ -729,6 +738,7 @@ private fun ContentTimestampToPreview(event: TimelineItem.Event) {
|
|||
onUserDataClick = {},
|
||||
inReplyToClick = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
|
|
@ -768,6 +778,7 @@ private fun ContentWithManyReactionsToPreview() {
|
|||
onUserDataClick = {},
|
||||
inReplyToClick = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onSwipeToReply = {},
|
||||
onTimestampClicked = {},
|
||||
|
|
|
|||
|
|
@ -185,13 +185,15 @@ internal fun TimelineItemReactionsLayoutPreview() = ElementPreview {
|
|||
content = MessagesReactionsButtonContent.Text(
|
||||
text = stringResource(id = R.string.screen_room_timeline_less_reactions)
|
||||
),
|
||||
onClick = { },
|
||||
onClick = {},
|
||||
onLongClick = {}
|
||||
)
|
||||
},
|
||||
addMoreButton = {
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction),
|
||||
onClick = {}
|
||||
onClick = {},
|
||||
onLongClick = {}
|
||||
)
|
||||
},
|
||||
reactions = {
|
||||
|
|
@ -200,7 +202,8 @@ internal fun TimelineItemReactionsLayoutPreview() = ElementPreview {
|
|||
content = MessagesReactionsButtonContent.Reaction(
|
||||
it
|
||||
),
|
||||
onClick = {}
|
||||
onClick = {},
|
||||
onLongClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ fun TimelineItemReactions(
|
|||
reactionsState: TimelineItemReactions,
|
||||
isOutgoing: Boolean,
|
||||
onReactionClicked: (emoji: String) -> Unit,
|
||||
onReactionLongClicked: (emoji: String) -> Unit,
|
||||
onMoreReactionsClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -61,6 +62,7 @@ fun TimelineItemReactions(
|
|||
reactions = reactionsState.reactions,
|
||||
expanded = expanded,
|
||||
onReactionClick = onReactionClicked,
|
||||
onReactionLongClick = onReactionLongClicked,
|
||||
onMoreReactionsClick = onMoreReactionsClicked,
|
||||
onToggleExpandClick = { expanded = !expanded },
|
||||
)
|
||||
|
|
@ -72,6 +74,7 @@ private fun TimelineItemReactionsView(
|
|||
reactions: ImmutableList<AggregatedReaction>,
|
||||
expanded: Boolean,
|
||||
onReactionClick: (emoji: String) -> Unit,
|
||||
onReactionLongClick: (emoji: String) -> Unit,
|
||||
onMoreReactionsClick: () -> Unit,
|
||||
onToggleExpandClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
|
|
@ -86,19 +89,22 @@ private fun TimelineItemReactionsView(
|
|||
text = stringResource(id = if (expanded) R.string.screen_room_reactions_show_less else R.string.screen_room_reactions_show_more)
|
||||
),
|
||||
onClick = onToggleExpandClick,
|
||||
onLongClick = {}
|
||||
)
|
||||
},
|
||||
addMoreButton = {
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction),
|
||||
onClick = onMoreReactionsClick
|
||||
onClick = onMoreReactionsClick,
|
||||
onLongClick = {}
|
||||
)
|
||||
},
|
||||
reactions = {
|
||||
reactions.forEach { reaction ->
|
||||
MessagesReactionButton(
|
||||
content = MessagesReactionsButtonContent.Reaction(reaction = reaction),
|
||||
onClick = { onReactionClick(reaction.key) }
|
||||
onClick = { onReactionClick(reaction.key) },
|
||||
onLongClick = { onReactionLongClick(reaction.key) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -148,6 +154,7 @@ private fun ContentToPreview(
|
|||
),
|
||||
isOutgoing = isOutgoing,
|
||||
onReactionClicked = {},
|
||||
onReactionLongClicked = {},
|
||||
onMoreReactionsClicked = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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
|
||||
*
|
||||
* http://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.timeline.components.reactionsummary
|
||||
|
||||
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
||||
sealed interface ReactionSummaryEvents {
|
||||
object Clear : ReactionSummaryEvents
|
||||
data class ShowReactionSummary(val eventId: EventId, val reactions: List<AggregatedReaction>, val selectedKey: String) : ReactionSummaryEvents
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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
|
||||
*
|
||||
* http://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.timeline.components.reactionsummary
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import javax.inject.Inject
|
||||
|
||||
class ReactionSummaryPresenter @Inject constructor(
|
||||
private val room: MatrixRoom,
|
||||
) : Presenter<ReactionSummaryState> {
|
||||
@Composable
|
||||
override fun present(): ReactionSummaryState {
|
||||
LaunchedEffect(Unit) {
|
||||
room.updateMembers()
|
||||
}
|
||||
|
||||
val membersState by room.membersStateFlow.collectAsState()
|
||||
|
||||
val target: MutableState<ReactionSummaryState.Summary?> = remember {
|
||||
mutableStateOf(null)
|
||||
}
|
||||
val targetWithAvatars = populateSenderAvatars(members = membersState.roomMembers().orEmpty().toImmutableList(), summary = target.value)
|
||||
|
||||
fun handleEvents(event: ReactionSummaryEvents) {
|
||||
when (event) {
|
||||
is ReactionSummaryEvents.ShowReactionSummary -> target.value = ReactionSummaryState.Summary(
|
||||
reactions = event.reactions,
|
||||
selectedKey = event.selectedKey,
|
||||
selectedEventId = event.eventId
|
||||
)
|
||||
ReactionSummaryEvents.Clear -> target.value = null
|
||||
}
|
||||
}
|
||||
return ReactionSummaryState(
|
||||
target = targetWithAvatars.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun populateSenderAvatars(members: ImmutableList<RoomMember>, summary: ReactionSummaryState.Summary?) = remember(summary) {
|
||||
derivedStateOf {
|
||||
summary?.let { summary ->
|
||||
summary.copy(reactions = summary.reactions.map { reaction ->
|
||||
reaction.copy(senders = reaction.senders.map { sender ->
|
||||
val member = members.firstOrNull { it.userId == sender.senderId }
|
||||
val user = MatrixUser(
|
||||
userId = sender.senderId,
|
||||
displayName = member?.displayName,
|
||||
avatarUrl = member?.avatarUrl
|
||||
)
|
||||
sender.copy(user = user)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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
|
||||
*
|
||||
* http://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.timeline.components.reactionsummary
|
||||
|
||||
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
||||
data class ReactionSummaryState(
|
||||
val target: Summary?,
|
||||
val eventSink: (ReactionSummaryEvents) -> Unit
|
||||
){
|
||||
data class Summary(
|
||||
val reactions: List<AggregatedReaction>,
|
||||
val selectedKey: String,
|
||||
val selectedEventId: EventId
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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
|
||||
*
|
||||
* http://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.timeline.components.reactionsummary
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
||||
open class ReactionSummaryStateProvider : PreviewParameterProvider<ReactionSummaryState> {
|
||||
override val values = sequenceOf(anActionListState())
|
||||
}
|
||||
|
||||
fun anActionListState(): ReactionSummaryState {
|
||||
val reactions = aTimelineItemReactions(8, true).reactions
|
||||
return ReactionSummaryState(
|
||||
target = ReactionSummaryState.Summary(
|
||||
reactions = reactions,
|
||||
selectedKey = reactions[0].key,
|
||||
selectedEventId = EventId("$1234"),
|
||||
),
|
||||
eventSink = {}
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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
|
||||
*
|
||||
* http://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.timeline.components.reactionsummary
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ReactionSummaryView(
|
||||
state: ReactionSummaryState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
|
||||
fun onDismiss() {
|
||||
state.eventSink(ReactionSummaryEvents.Clear)
|
||||
}
|
||||
|
||||
if (state.target != null) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = ::onDismiss,
|
||||
sheetState = sheetState,
|
||||
modifier = modifier
|
||||
) {
|
||||
SheetContent(summary = state.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun SheetContent(
|
||||
summary: ReactionSummaryState.Summary,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val animationScope = rememberCoroutineScope()
|
||||
var selectedReactionKey: String by rememberSaveable { mutableStateOf(summary.selectedKey) }
|
||||
val selectedReactionIndex: Int by remember {
|
||||
derivedStateOf {
|
||||
summary.reactions.indexOfFirst { it.key == selectedReactionKey }
|
||||
}
|
||||
}
|
||||
val pagerState = rememberPagerState(initialPage = selectedReactionIndex)
|
||||
val reactionListState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(pagerState.currentPage) {
|
||||
selectedReactionKey = summary.reactions[pagerState.currentPage].key
|
||||
val visibleInfo = reactionListState.layoutInfo.visibleItemsInfo
|
||||
if (selectedReactionIndex <= visibleInfo.first().index || selectedReactionIndex >= visibleInfo.last().index) {
|
||||
reactionListState.animateScrollToItem(selectedReactionIndex)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
LazyRow(state = reactionListState,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(start = 12.dp, end = 12.dp, bottom = 12.dp)
|
||||
) {
|
||||
items(summary.reactions) { reaction ->
|
||||
AggregatedReactionButton(
|
||||
reaction = reaction,
|
||||
isHighlighted = selectedReactionKey == reaction.key,
|
||||
onClick = {
|
||||
selectedReactionKey = reaction.key
|
||||
animationScope.launch {
|
||||
pagerState.animateScrollToPage(selectedReactionIndex)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalPager(state = pagerState, pageCount = summary.reactions.size) { page ->
|
||||
LazyColumn(modifier = Modifier.fillMaxHeight()) {
|
||||
items(summary.reactions[page].senders) { sender ->
|
||||
|
||||
val user = sender.user ?: MatrixUser(userId = sender.senderId)
|
||||
|
||||
SenderRow(
|
||||
avatarData = user.getAvatarData(AvatarSize.UserListItem),
|
||||
name = user.displayName ?: user.userId.value,
|
||||
userId = user.userId.value,
|
||||
sentTime = sender.sentTime
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AggregatedReactionButton(
|
||||
reaction: AggregatedReaction,
|
||||
isHighlighted: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
||||
val buttonColor = if (isHighlighted) {
|
||||
ElementTheme.colors.bgActionPrimaryRest
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
|
||||
val textColor = if (isHighlighted) {
|
||||
MaterialTheme.colorScheme.inversePrimary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.clickable(onClick = onClick)
|
||||
.background(buttonColor, RoundedCornerShape(corner = CornerSize(percent = 50)))
|
||||
.padding(vertical = 8.dp, horizontal = 12.dp),
|
||||
color = buttonColor
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = reaction.displayKey,
|
||||
style = ElementTheme.typography.fontBodyMdRegular.copy(
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 25.sp
|
||||
),
|
||||
)
|
||||
if (reaction.count > 1) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = reaction.count.toString(),
|
||||
color = textColor,
|
||||
style = ElementTheme.typography.fontBodyMdRegular.copy(
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 25.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SenderRow(
|
||||
avatarData: AvatarData,
|
||||
name: String,
|
||||
userId: String,
|
||||
sentTime: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 56.dp)
|
||||
.padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Avatar(avatarData)
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(end = 4.dp)
|
||||
.weight(1f),
|
||||
text = name,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
)
|
||||
Text(
|
||||
text = sentTime,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = userId,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
internal fun SheetContentPreview(
|
||||
@PreviewParameter(ReactionSummaryStateProvider::class) state: ReactionSummaryState
|
||||
) = ElementPreview {
|
||||
SheetContent(summary = state.target as ReactionSummaryState.Summary)
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.factories.event
|
|||
|
||||
import io.element.android.features.messages.impl.timeline.groups.canBeDisplayedInBubbleBlock
|
||||
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
|
||||
import io.element.android.features.messages.impl.timeline.model.AggregatedReactionSender
|
||||
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.TimelineItemReactions
|
||||
|
|
@ -90,14 +91,34 @@ class TimelineItemEventFactory @Inject constructor(
|
|||
}
|
||||
|
||||
private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions {
|
||||
val aggregatedReactions = event.reactions.map {
|
||||
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
|
||||
var aggregatedReactions = event.reactions.map { reaction ->
|
||||
// Sort reactions within an aggregation by timestamp descending.
|
||||
// This puts the most recent at the top, useful in cases like the
|
||||
// reaction summary view or getting the most recent reaction.
|
||||
AggregatedReaction(
|
||||
key = it.key,
|
||||
count = it.count.toInt(),
|
||||
isHighlighted = it.senderIds.contains(matrixClient.sessionId),
|
||||
key = reaction.key,
|
||||
currentUserId = matrixClient.sessionId,
|
||||
senders = reaction.senders
|
||||
.sortedByDescending{ it.timestamp }
|
||||
.map {
|
||||
val date = Date(it.timestamp)
|
||||
AggregatedReactionSender(
|
||||
senderId = it.senderId,
|
||||
timestamp = date,
|
||||
sentTime = timeFormatter.format(date),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
aggregatedReactions.sortedByDescending { it.count }
|
||||
// Sort aggregated reactions by count and then timestamp ascending, using
|
||||
// the most recent reaction in the aggregation(hence index 0).
|
||||
// This appends new aggregations on the end of the reaction layout.
|
||||
aggregatedReactions = aggregatedReactions
|
||||
.sortedWith(
|
||||
compareByDescending<AggregatedReaction> { it.count }
|
||||
.thenBy { it.senders[0].timestamp }
|
||||
)
|
||||
return TimelineItemReactions(aggregatedReactions.toImmutableList())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package io.element.android.features.messages.impl.timeline.model
|
||||
|
||||
import io.element.android.libraries.core.extensions.ellipsize
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
/**
|
||||
* Length at which we ellipsize a reaction key for display
|
||||
|
|
@ -27,16 +28,15 @@ import io.element.android.libraries.core.extensions.ellipsize
|
|||
private const val MAX_DISPLAY_CHARS = 16
|
||||
|
||||
/**
|
||||
* @property currentUserId the ID of the currently logged in user
|
||||
* @property key the full reaction key (e.g. "👍", "YES!")
|
||||
* @property count the number of users who reacted with this key
|
||||
* @property isHighlighted true if the reaction has (also) been sent by the current user.
|
||||
* @property senders the list of users who sent the reactions
|
||||
*/
|
||||
data class AggregatedReaction(
|
||||
val currentUserId: UserId,
|
||||
val key: String,
|
||||
val count: Int,
|
||||
val isHighlighted: Boolean = false
|
||||
val senders: List<AggregatedReactionSender>
|
||||
) {
|
||||
|
||||
/**
|
||||
* The key to be displayed on screen.
|
||||
*
|
||||
|
|
@ -45,4 +45,18 @@ data class AggregatedReaction(
|
|||
val displayKey: String by lazy {
|
||||
key.ellipsize(MAX_DISPLAY_CHARS)
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of users who reacted with this key.
|
||||
*/
|
||||
val count: Int by lazy {
|
||||
senders.count()
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the reaction has (also) been sent by the current user.
|
||||
*/
|
||||
val isHighlighted: Boolean by lazy {
|
||||
senders.any { it.senderId.value == currentUserId.value }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@
|
|||
package io.element.android.features.messages.impl.timeline.model
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
open class AggregatedReactionProvider : PreviewParameterProvider<AggregatedReaction> {
|
||||
override val values: Sequence<AggregatedReaction>
|
||||
|
|
@ -29,11 +32,27 @@ open class AggregatedReactionProvider : PreviewParameterProvider<AggregatedReact
|
|||
}
|
||||
|
||||
fun anAggregatedReaction(
|
||||
userId: UserId = UserId("@alice:server.org"),
|
||||
key: String = "👍",
|
||||
count: Int = 1,
|
||||
isHighlighted: Boolean = false,
|
||||
) = AggregatedReaction(
|
||||
key = key,
|
||||
count = count,
|
||||
isHighlighted = isHighlighted,
|
||||
)
|
||||
): AggregatedReaction {
|
||||
val senders = buildList {
|
||||
repeat(count) { index ->
|
||||
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
|
||||
val date = Date(1_689_061_264L)
|
||||
add(
|
||||
AggregatedReactionSender(
|
||||
senderId = if (isHighlighted && index == 0) userId else UserId("@user$index:server.org"),
|
||||
timestamp = date,
|
||||
sentTime = timeFormatter.format(date),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return AggregatedReaction(
|
||||
currentUserId = userId,
|
||||
key = key,
|
||||
senders = senders
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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
|
||||
*
|
||||
* http://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.timeline.model
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import java.util.Date
|
||||
|
||||
data class AggregatedReactionSender(
|
||||
val senderId: UserId,
|
||||
val timestamp: Date,
|
||||
val sentTime: String,
|
||||
val user: MatrixUser? = null
|
||||
)
|
||||
|
|
@ -35,6 +35,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
|||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter
|
||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||
|
|
@ -584,6 +585,7 @@ class MessagesPresenterTest {
|
|||
val buildMeta = aBuildMeta()
|
||||
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)
|
||||
val customReactionPresenter = CustomReactionPresenter()
|
||||
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
|
||||
val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom)
|
||||
return MessagesPresenter(
|
||||
room = matrixRoom,
|
||||
|
|
@ -591,6 +593,7 @@ class MessagesPresenterTest {
|
|||
timelinePresenter = timelinePresenter,
|
||||
actionListPresenter = actionListPresenter,
|
||||
customReactionPresenter = customReactionPresenter,
|
||||
reactionSummaryPresenter = reactionSummaryPresenter,
|
||||
retrySendMenuPresenter = retrySendMenuPresenter,
|
||||
networkMonitor = FakeNetworkMonitor(),
|
||||
snackbarDispatcher = SnackbarDispatcher(),
|
||||
|
|
|
|||
|
|
@ -24,8 +24,12 @@ import io.element.android.features.messages.fixtures.aTimelineItemsFactory
|
|||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
|
|
@ -37,6 +41,7 @@ import io.element.android.tests.testutils.testCoroutineDispatchers
|
|||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.util.Date
|
||||
|
||||
class TimelinePresenterTest {
|
||||
@Test
|
||||
|
|
@ -188,6 +193,61 @@ class TimelinePresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - reaction ordering`() = runTest {
|
||||
val timeline = FakeMatrixTimeline()
|
||||
val presenter = createTimelinePresenter(timeline)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.hasNewItems).isFalse()
|
||||
assertThat(initialState.timelineItems.size).isEqualTo(0)
|
||||
val now = Date().time
|
||||
val minuteInMilis = 60 * 1000
|
||||
// Use index as a convenient value for timestamp
|
||||
val (alice, bob, charlie) = aMatrixUserList().take(3).mapIndexed { i, user ->
|
||||
ReactionSender(senderId = user.userId, timestamp = now + i * minuteInMilis)
|
||||
}
|
||||
val oneReaction = listOf(
|
||||
EventReaction(
|
||||
key = "❤️",
|
||||
senders = listOf(alice, charlie)
|
||||
),
|
||||
EventReaction(
|
||||
key = "👍",
|
||||
senders = listOf(alice, bob)
|
||||
),
|
||||
EventReaction(
|
||||
key = "🐶",
|
||||
senders = listOf(charlie)
|
||||
),
|
||||
)
|
||||
timeline.updateTimelineItems {
|
||||
listOf(MatrixTimelineItem.Event(0, anEventTimelineItem(reactions = oneReaction)))
|
||||
}
|
||||
skipItems(1)
|
||||
val item = awaitItem().timelineItems.first()
|
||||
assertThat(item).isInstanceOf(TimelineItem.Event::class.java)
|
||||
val event = item as TimelineItem.Event
|
||||
val reactions = event.reactionsState.reactions
|
||||
assertThat(reactions.size).isEqualTo(3)
|
||||
|
||||
// Aggregated reactions are sorted by count first and then timestamp ascending(new ones tagged on the end)
|
||||
assertThat(reactions[0].count).isEqualTo(2)
|
||||
assertThat(reactions[0].key).isEqualTo("👍")
|
||||
assertThat(reactions[0].senders[0].senderId).isEqualTo(bob.senderId)
|
||||
|
||||
assertThat(reactions[1].count).isEqualTo(2)
|
||||
assertThat(reactions[1].key).isEqualTo("❤️")
|
||||
assertThat(reactions[1].senders[0].senderId).isEqualTo(charlie.senderId)
|
||||
|
||||
assertThat(reactions[2].count).isEqualTo(1)
|
||||
assertThat(reactions[2].key).isEqualTo("🐶")
|
||||
assertThat(reactions[2].senders[0].senderId).isEqualTo(charlie.senderId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createTimelinePresenter(
|
||||
timeline: MatrixTimeline = FakeMatrixTimeline(),
|
||||
timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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
|
||||
*
|
||||
* http://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.timeline.components.reactionsummary
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter
|
||||
import io.element.android.features.messages.impl.timeline.model.anAggregatedReaction
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class ReactionSummaryPresenterTests {
|
||||
private val aggregatedReaction = anAggregatedReaction(userId = A_USER_ID, key = "👍", isHighlighted = true)
|
||||
private val roomMember = aRoomMember(userId = A_USER_ID, avatarUrl = AN_AVATAR_URL, displayName = A_USER_NAME)
|
||||
private val summaryEvent = ReactionSummaryEvents.ShowReactionSummary(AN_EVENT_ID, listOf(aggregatedReaction), aggregatedReaction.key)
|
||||
private val room = FakeMatrixRoom().apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember)))
|
||||
}
|
||||
private val presenter = ReactionSummaryPresenter(room)
|
||||
|
||||
@Test
|
||||
fun `present - handle showing and hiding the reaction summary`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.target).isEqualTo(null)
|
||||
|
||||
initialState.eventSink(summaryEvent)
|
||||
assertThat(awaitItem().target).isNotNull()
|
||||
|
||||
initialState.eventSink(ReactionSummaryEvents.Clear)
|
||||
assertThat(awaitItem().target).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle reaction summary content and avatars populated`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.target).isEqualTo(null)
|
||||
|
||||
initialState.eventSink(summaryEvent)
|
||||
val reactions = awaitItem().target?.reactions
|
||||
assertThat(reactions?.count()).isEqualTo(1)
|
||||
assertThat(reactions?.first()?.key).isEqualTo("👍")
|
||||
assertThat(reactions?.first()?.senders?.first()?.senderId).isEqualTo(A_USER_ID)
|
||||
assertThat(reactions?.first()?.senders?.first()?.user?.userId).isEqualTo(A_USER_ID)
|
||||
assertThat(reactions?.first()?.senders?.first()?.user?.avatarUrl).isEqualTo(AN_AVATAR_URL)
|
||||
assertThat(reactions?.first()?.senders?.first()?.user?.displayName).isEqualTo(A_USER_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -16,19 +16,30 @@
|
|||
|
||||
package io.element.android.features.messages.timeline.model
|
||||
|
||||
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
|
||||
import io.element.android.features.messages.impl.timeline.model.anAggregatedReaction
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class AggregatedReactionTest {
|
||||
@Test
|
||||
fun `reaction display key is shortened`() {
|
||||
val reaction = AggregatedReaction(
|
||||
key = "1234567890123456790",
|
||||
count = 1,
|
||||
isHighlighted = false
|
||||
val reaction = anAggregatedReaction(
|
||||
key = "1234567890123456790",
|
||||
count = 1
|
||||
)
|
||||
|
||||
assertEquals("1234567890123456…", reaction.displayKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reaction count and isHighlighted are computed correctly`() {
|
||||
val reaction = anAggregatedReaction(
|
||||
key = "1234567890123456790",
|
||||
count = 3,
|
||||
isHighlighted = true
|
||||
)
|
||||
|
||||
assertEquals(3, reaction.count)
|
||||
assertEquals(true, reaction.isHighlighted)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,10 +16,7 @@
|
|||
|
||||
package io.element.android.libraries.matrix.api.timeline.item.event
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
data class EventReaction(
|
||||
val key: String,
|
||||
val count: Long,
|
||||
val senderIds: List<UserId>
|
||||
val senders: List<ReactionSender>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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
|
||||
*
|
||||
* http://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.libraries.matrix.api.timeline.item.event
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
/**
|
||||
* The sender of a reaction.
|
||||
*
|
||||
* @property senderId the ID of the user who sent the reaction
|
||||
* @property timestamp the timestamp the reaction was received on the origin homeserver
|
||||
*/
|
||||
data class ReactionSender(
|
||||
val senderId: UserId,
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
|
|
@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
|
|||
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
||||
import org.matrix.rustcomponents.sdk.Reaction
|
||||
import org.matrix.rustcomponents.sdk.EventItemOrigin as RustEventItemOrigin
|
||||
import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState
|
||||
|
|
@ -81,8 +82,12 @@ private fun List<Reaction>?.map(): List<EventReaction> {
|
|||
return this?.map {
|
||||
EventReaction(
|
||||
key = it.key,
|
||||
count = it.count.toLong(),
|
||||
senderIds = it.senders.map { sender -> UserId(sender.senderId) }
|
||||
senders = it.senders.map { sender ->
|
||||
ReactionSender(
|
||||
senderId = UserId(sender.senderId),
|
||||
timestamp = sender.timestamp.toLong()
|
||||
)
|
||||
}
|
||||
)
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4e63f683ef130d77b55966e954a7cbc144dceb5960056bcd01d7c0c9583b3b03
|
||||
size 25358
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5fd8aeb2e875a1280a70ba913dd913759af66fb4300ed69c7e109ec8e8378a09
|
||||
size 25051
|
||||
Loading…
Add table
Add a link
Reference in a new issue