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
ffdf005507
commit
8fd71b4126
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.TimelineEvents
|
||||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
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.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.components.retrysendmenu.RetrySendMenuPresenter
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
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 timelinePresenter: TimelinePresenter,
|
||||||
private val actionListPresenter: ActionListPresenter,
|
private val actionListPresenter: ActionListPresenter,
|
||||||
private val customReactionPresenter: CustomReactionPresenter,
|
private val customReactionPresenter: CustomReactionPresenter,
|
||||||
|
private val reactionSummaryPresenter: ReactionSummaryPresenter,
|
||||||
private val retrySendMenuPresenter: RetrySendMenuPresenter,
|
private val retrySendMenuPresenter: RetrySendMenuPresenter,
|
||||||
private val networkMonitor: NetworkMonitor,
|
private val networkMonitor: NetworkMonitor,
|
||||||
private val snackbarDispatcher: SnackbarDispatcher,
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
|
|
@ -105,6 +107,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
val timelineState = timelinePresenter.present()
|
val timelineState = timelinePresenter.present()
|
||||||
val actionListState = actionListPresenter.present()
|
val actionListState = actionListPresenter.present()
|
||||||
val customReactionState = customReactionPresenter.present()
|
val customReactionState = customReactionPresenter.present()
|
||||||
|
val reactionSummaryState = reactionSummaryPresenter.present()
|
||||||
val retryState = retrySendMenuPresenter.present()
|
val retryState = retrySendMenuPresenter.present()
|
||||||
|
|
||||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||||
|
|
@ -166,6 +169,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
timelineState = timelineState,
|
timelineState = timelineState,
|
||||||
actionListState = actionListState,
|
actionListState = actionListState,
|
||||||
customReactionState = customReactionState,
|
customReactionState = customReactionState,
|
||||||
|
reactionSummaryState = reactionSummaryState,
|
||||||
retrySendMenuState = retryState,
|
retrySendMenuState = retryState,
|
||||||
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
|
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
|
||||||
snackbarMessage = snackbarMessage,
|
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.messagecomposer.MessageComposerState
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineState
|
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.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.components.retrysendmenu.RetrySendMenuState
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
|
@ -38,6 +39,7 @@ data class MessagesState(
|
||||||
val timelineState: TimelineState,
|
val timelineState: TimelineState,
|
||||||
val actionListState: ActionListState,
|
val actionListState: ActionListState,
|
||||||
val customReactionState: CustomReactionState,
|
val customReactionState: CustomReactionState,
|
||||||
|
val reactionSummaryState: ReactionSummaryState,
|
||||||
val retrySendMenuState: RetrySendMenuState,
|
val retrySendMenuState: RetrySendMenuState,
|
||||||
val hasNetworkConnection: Boolean,
|
val hasNetworkConnection: Boolean,
|
||||||
val snackbarMessage: SnackbarMessage?,
|
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.aTimelineItemList
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineState
|
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.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.components.retrysendmenu.RetrySendMenuState
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
|
|
@ -68,6 +69,10 @@ fun aMessagesState() = MessagesState(
|
||||||
selectedEventId = null,
|
selectedEventId = null,
|
||||||
eventSink = {},
|
eventSink = {},
|
||||||
),
|
),
|
||||||
|
reactionSummaryState = ReactionSummaryState(
|
||||||
|
target = null,
|
||||||
|
eventSink = {},
|
||||||
|
),
|
||||||
hasNetworkConnection = true,
|
hasNetworkConnection = true,
|
||||||
snackbarMessage = null,
|
snackbarMessage = null,
|
||||||
inviteProgress = Async.Uninitialized,
|
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.TimelineView
|
||||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
|
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.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.RetrySendMenuEvents
|
||||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu
|
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
|
@ -127,6 +129,11 @@ fun MessagesView(
|
||||||
state.eventSink(MessagesEvents.ToggleReaction(emoji, event.eventId))
|
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) {
|
fun onMoreReactionsClicked(event: TimelineItem.Event) {
|
||||||
state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event.eventId))
|
state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event.eventId))
|
||||||
}
|
}
|
||||||
|
|
@ -160,6 +167,7 @@ fun MessagesView(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onReactionClicked = ::onEmojiReactionClicked,
|
onReactionClicked = ::onEmojiReactionClicked,
|
||||||
|
onReactionLongClicked = ::onEmojiReactionLongClicked,
|
||||||
onMoreReactionsClicked = ::onMoreReactionsClicked,
|
onMoreReactionsClicked = ::onMoreReactionsClicked,
|
||||||
onSendLocationClicked = onSendLocationClicked,
|
onSendLocationClicked = onSendLocationClicked,
|
||||||
onSwipeToReply = { targetEvent ->
|
onSwipeToReply = { targetEvent ->
|
||||||
|
|
@ -194,6 +202,7 @@ fun MessagesView(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ReactionSummaryView(state = state.reactionSummaryState)
|
||||||
RetrySendMessageMenu(
|
RetrySendMessageMenu(
|
||||||
state = state.retrySendMenuState
|
state = state.retrySendMenuState
|
||||||
)
|
)
|
||||||
|
|
@ -246,6 +255,7 @@ private fun MessagesViewContent(
|
||||||
onMessageClicked: (TimelineItem.Event) -> Unit,
|
onMessageClicked: (TimelineItem.Event) -> Unit,
|
||||||
onUserDataClicked: (UserId) -> Unit,
|
onUserDataClicked: (UserId) -> Unit,
|
||||||
onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
|
onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
|
||||||
|
onReactionLongClicked: (key: String, TimelineItem.Event) -> Unit,
|
||||||
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
|
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
|
||||||
onMessageLongClicked: (TimelineItem.Event) -> Unit,
|
onMessageLongClicked: (TimelineItem.Event) -> Unit,
|
||||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||||
|
|
@ -269,6 +279,7 @@ private fun MessagesViewContent(
|
||||||
onUserDataClicked = onUserDataClicked,
|
onUserDataClicked = onUserDataClicked,
|
||||||
onTimestampClicked = onTimestampClicked,
|
onTimestampClicked = onTimestampClicked,
|
||||||
onReactionClicked = onReactionClicked,
|
onReactionClicked = onReactionClicked,
|
||||||
|
onReactionLongClicked = onReactionLongClicked,
|
||||||
onMoreReactionsClicked = onMoreReactionsClicked,
|
onMoreReactionsClicked = onMoreReactionsClicked,
|
||||||
onSwipeToReply = onSwipeToReply,
|
onSwipeToReply = onSwipeToReply,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,10 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline
|
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.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
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.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.TimelineItemEventContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||||
|
|
@ -141,7 +141,11 @@ fun aTimelineItemReactions(
|
||||||
reactions = buildList {
|
reactions = buildList {
|
||||||
repeat(count) { index ->
|
repeat(count) { index ->
|
||||||
val key = emojis[index % emojis.size]
|
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()
|
}.toPersistentList()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ fun TimelineView(
|
||||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||||
onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit,
|
onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit,
|
||||||
|
onReactionLongClicked: (emoji: String, TimelineItem.Event) -> Unit,
|
||||||
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
|
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -121,6 +122,7 @@ fun TimelineView(
|
||||||
onUserDataClick = onUserDataClicked,
|
onUserDataClick = onUserDataClicked,
|
||||||
inReplyToClick = ::inReplyToClicked,
|
inReplyToClick = ::inReplyToClicked,
|
||||||
onReactionClick = onReactionClicked,
|
onReactionClick = onReactionClicked,
|
||||||
|
onReactionLongClick = onReactionLongClicked,
|
||||||
onMoreReactionsClick = onMoreReactionsClicked,
|
onMoreReactionsClick = onMoreReactionsClicked,
|
||||||
onTimestampClicked = onTimestampClicked,
|
onTimestampClicked = onTimestampClicked,
|
||||||
onSwipeToReply = onSwipeToReply,
|
onSwipeToReply = onSwipeToReply,
|
||||||
|
|
@ -155,6 +157,7 @@ fun TimelineItemRow(
|
||||||
onLongClick: (TimelineItem.Event) -> Unit,
|
onLongClick: (TimelineItem.Event) -> Unit,
|
||||||
inReplyToClick: (EventId) -> Unit,
|
inReplyToClick: (EventId) -> Unit,
|
||||||
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
||||||
|
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
|
||||||
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
|
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
|
||||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||||
|
|
@ -186,6 +189,7 @@ fun TimelineItemRow(
|
||||||
onUserDataClick = onUserDataClick,
|
onUserDataClick = onUserDataClick,
|
||||||
inReplyToClick = inReplyToClick,
|
inReplyToClick = inReplyToClick,
|
||||||
onReactionClick = onReactionClick,
|
onReactionClick = onReactionClick,
|
||||||
|
onReactionLongClick = onReactionLongClick,
|
||||||
onMoreReactionsClick = onMoreReactionsClick,
|
onMoreReactionsClick = onMoreReactionsClick,
|
||||||
onTimestampClicked = onTimestampClicked,
|
onTimestampClicked = onTimestampClicked,
|
||||||
onSwipeToReply = { onSwipeToReply(timelineItem) },
|
onSwipeToReply = { onSwipeToReply(timelineItem) },
|
||||||
|
|
@ -224,6 +228,7 @@ fun TimelineItemRow(
|
||||||
onUserDataClick = onUserDataClick,
|
onUserDataClick = onUserDataClick,
|
||||||
onTimestampClicked = onTimestampClicked,
|
onTimestampClicked = onTimestampClicked,
|
||||||
onReactionClick = onReactionClick,
|
onReactionClick = onReactionClick,
|
||||||
|
onReactionLongClick = onReactionLongClick,
|
||||||
onMoreReactionsClick = onMoreReactionsClick,
|
onMoreReactionsClick = onMoreReactionsClick,
|
||||||
onSwipeToReply = {},
|
onSwipeToReply = {},
|
||||||
)
|
)
|
||||||
|
|
@ -321,6 +326,7 @@ internal fun TimelineViewPreview(
|
||||||
onUserDataClicked = {},
|
onUserDataClicked = {},
|
||||||
onMessageLongClicked = {},
|
onMessageLongClicked = {},
|
||||||
onReactionClicked = { _, _ -> },
|
onReactionClicked = { _, _ -> },
|
||||||
|
onReactionLongClicked = { _, _ -> },
|
||||||
onMoreReactionsClicked = {},
|
onMoreReactionsClicked = {},
|
||||||
onSwipeToReply = {},
|
onSwipeToReply = {},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,10 @@
|
||||||
package io.element.android.features.messages.impl.timeline.components
|
package io.element.android.features.messages.impl.timeline.components
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.height
|
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
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
fun MessagesReactionButton(
|
fun MessagesReactionButton(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
content: MessagesReactionsButtonContent,
|
content: MessagesReactionsButtonContent,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -82,7 +85,10 @@ fun MessagesReactionButton(
|
||||||
.padding(vertical = 2.dp, horizontal = 2.dp)
|
.padding(vertical = 2.dp, horizontal = 2.dp)
|
||||||
// Clip click indicator inside the outer border
|
// Clip click indicator inside the outer border
|
||||||
.clip(RoundedCornerShape(corner = CornerSize(12.dp)))
|
.clip(RoundedCornerShape(corner = CornerSize(12.dp)))
|
||||||
.clickable(onClick = onClick)
|
.combinedClickable(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick
|
||||||
|
)
|
||||||
// Inner border, to highlight when selected
|
// Inner border, to highlight when selected
|
||||||
.border(BorderStroke(1.dp, borderColor), RoundedCornerShape(corner = CornerSize(12.dp)))
|
.border(BorderStroke(1.dp, borderColor), RoundedCornerShape(corner = CornerSize(12.dp)))
|
||||||
.background(buttonColor, 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 {
|
internal fun MessagesReactionButtonPreview(@PreviewParameter(AggregatedReactionProvider::class) reaction: AggregatedReaction) = ElementPreview {
|
||||||
MessagesReactionButton(
|
MessagesReactionButton(
|
||||||
content = MessagesReactionsButtonContent.Reaction(reaction),
|
content = MessagesReactionsButtonContent.Reaction(reaction),
|
||||||
onClick = {}
|
onClick = {},
|
||||||
|
onLongClick = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -172,11 +179,13 @@ internal fun MessagesReactionExtraButtonsPreview() = ElementPreview {
|
||||||
Row {
|
Row {
|
||||||
MessagesReactionButton(
|
MessagesReactionButton(
|
||||||
content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction),
|
content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction),
|
||||||
onClick = {}
|
onClick = {},
|
||||||
|
onLongClick = {}
|
||||||
)
|
)
|
||||||
MessagesReactionButton(
|
MessagesReactionButton(
|
||||||
content = MessagesReactionsButtonContent.Text("12 more"),
|
content = MessagesReactionsButtonContent.Text("12 more"),
|
||||||
onClick = {}
|
onClick = {},
|
||||||
|
onLongClick = {}
|
||||||
)
|
)
|
||||||
MessagesReactionButton(
|
MessagesReactionButton(
|
||||||
content = MessagesReactionsButtonContent.Reaction(
|
content = MessagesReactionsButtonContent.Reaction(
|
||||||
|
|
@ -184,7 +193,8 @@ internal fun MessagesReactionExtraButtonsPreview() = ElementPreview {
|
||||||
key = "A very long reaction with many characters that should be truncated"
|
key = "A very long reaction with many characters that should be truncated"
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
onClick = {}
|
onClick = {},
|
||||||
|
onLongClick = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ fun TimelineItemEventRow(
|
||||||
inReplyToClick: (EventId) -> Unit,
|
inReplyToClick: (EventId) -> Unit,
|
||||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||||
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
|
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
|
||||||
|
onReactionLongClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
|
||||||
onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit,
|
onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit,
|
||||||
onSwipeToReply: () -> Unit,
|
onSwipeToReply: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
|
|
@ -169,6 +170,7 @@ fun TimelineItemEventRow(
|
||||||
inReplyToClicked = ::inReplyToClicked,
|
inReplyToClicked = ::inReplyToClicked,
|
||||||
onUserDataClicked = ::onUserDataClicked,
|
onUserDataClicked = ::onUserDataClicked,
|
||||||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||||
|
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
|
||||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -184,6 +186,7 @@ fun TimelineItemEventRow(
|
||||||
inReplyToClicked = ::inReplyToClicked,
|
inReplyToClicked = ::inReplyToClicked,
|
||||||
onUserDataClicked = ::onUserDataClicked,
|
onUserDataClicked = ::onUserDataClicked,
|
||||||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||||
|
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
|
||||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -224,6 +227,7 @@ private fun TimelineItemEventRowContent(
|
||||||
inReplyToClicked: () -> Unit,
|
inReplyToClicked: () -> Unit,
|
||||||
onUserDataClicked: () -> Unit,
|
onUserDataClicked: () -> Unit,
|
||||||
onReactionClicked: (emoji: String) -> Unit,
|
onReactionClicked: (emoji: String) -> Unit,
|
||||||
|
onReactionLongClicked: (emoji: String) -> Unit,
|
||||||
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
|
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -292,6 +296,7 @@ private fun TimelineItemEventRowContent(
|
||||||
reactionsState = event.reactionsState,
|
reactionsState = event.reactionsState,
|
||||||
isOutgoing = event.isMine,
|
isOutgoing = event.isMine,
|
||||||
onReactionClicked = onReactionClicked,
|
onReactionClicked = onReactionClicked,
|
||||||
|
onReactionLongClicked = onReactionLongClicked,
|
||||||
onMoreReactionsClicked = { onMoreReactionsClicked(event) },
|
onMoreReactionsClicked = { onMoreReactionsClicked(event) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.constrainAs(reactions) {
|
.constrainAs(reactions) {
|
||||||
|
|
@ -588,6 +593,7 @@ private fun ContentToPreview() {
|
||||||
onUserDataClick = {},
|
onUserDataClick = {},
|
||||||
inReplyToClick = {},
|
inReplyToClick = {},
|
||||||
onReactionClick = { _, _ -> },
|
onReactionClick = { _, _ -> },
|
||||||
|
onReactionLongClick = { _, _ -> },
|
||||||
onMoreReactionsClick = {},
|
onMoreReactionsClick = {},
|
||||||
onTimestampClicked = {},
|
onTimestampClicked = {},
|
||||||
onSwipeToReply = {},
|
onSwipeToReply = {},
|
||||||
|
|
@ -607,6 +613,7 @@ private fun ContentToPreview() {
|
||||||
onUserDataClick = {},
|
onUserDataClick = {},
|
||||||
inReplyToClick = {},
|
inReplyToClick = {},
|
||||||
onReactionClick = { _, _ -> },
|
onReactionClick = { _, _ -> },
|
||||||
|
onReactionLongClick = { _, _ -> },
|
||||||
onMoreReactionsClick = {},
|
onMoreReactionsClick = {},
|
||||||
onTimestampClicked = {},
|
onTimestampClicked = {},
|
||||||
onSwipeToReply = {},
|
onSwipeToReply = {},
|
||||||
|
|
@ -653,6 +660,7 @@ private fun ContentToPreviewWithReply() {
|
||||||
onUserDataClick = {},
|
onUserDataClick = {},
|
||||||
inReplyToClick = {},
|
inReplyToClick = {},
|
||||||
onReactionClick = { _, _ -> },
|
onReactionClick = { _, _ -> },
|
||||||
|
onReactionLongClick = { _, _ -> },
|
||||||
onMoreReactionsClick = {},
|
onMoreReactionsClick = {},
|
||||||
onTimestampClicked = {},
|
onTimestampClicked = {},
|
||||||
onSwipeToReply = {},
|
onSwipeToReply = {},
|
||||||
|
|
@ -673,6 +681,7 @@ private fun ContentToPreviewWithReply() {
|
||||||
onUserDataClick = {},
|
onUserDataClick = {},
|
||||||
inReplyToClick = {},
|
inReplyToClick = {},
|
||||||
onReactionClick = { _, _ -> },
|
onReactionClick = { _, _ -> },
|
||||||
|
onReactionLongClick = { _, _ -> },
|
||||||
onMoreReactionsClick = {},
|
onMoreReactionsClick = {},
|
||||||
onTimestampClicked = {},
|
onTimestampClicked = {},
|
||||||
onSwipeToReply = {},
|
onSwipeToReply = {},
|
||||||
|
|
@ -729,6 +738,7 @@ private fun ContentTimestampToPreview(event: TimelineItem.Event) {
|
||||||
onUserDataClick = {},
|
onUserDataClick = {},
|
||||||
inReplyToClick = {},
|
inReplyToClick = {},
|
||||||
onReactionClick = { _, _ -> },
|
onReactionClick = { _, _ -> },
|
||||||
|
onReactionLongClick = { _, _ -> },
|
||||||
onMoreReactionsClick = {},
|
onMoreReactionsClick = {},
|
||||||
onTimestampClicked = {},
|
onTimestampClicked = {},
|
||||||
onSwipeToReply = {},
|
onSwipeToReply = {},
|
||||||
|
|
@ -768,6 +778,7 @@ private fun ContentWithManyReactionsToPreview() {
|
||||||
onUserDataClick = {},
|
onUserDataClick = {},
|
||||||
inReplyToClick = {},
|
inReplyToClick = {},
|
||||||
onReactionClick = { _, _ -> },
|
onReactionClick = { _, _ -> },
|
||||||
|
onReactionLongClick = { _, _ -> },
|
||||||
onMoreReactionsClick = {},
|
onMoreReactionsClick = {},
|
||||||
onSwipeToReply = {},
|
onSwipeToReply = {},
|
||||||
onTimestampClicked = {},
|
onTimestampClicked = {},
|
||||||
|
|
|
||||||
|
|
@ -185,13 +185,15 @@ internal fun TimelineItemReactionsLayoutPreview() = ElementPreview {
|
||||||
content = MessagesReactionsButtonContent.Text(
|
content = MessagesReactionsButtonContent.Text(
|
||||||
text = stringResource(id = R.string.screen_room_timeline_less_reactions)
|
text = stringResource(id = R.string.screen_room_timeline_less_reactions)
|
||||||
),
|
),
|
||||||
onClick = { },
|
onClick = {},
|
||||||
|
onLongClick = {}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
addMoreButton = {
|
addMoreButton = {
|
||||||
MessagesReactionButton(
|
MessagesReactionButton(
|
||||||
content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction),
|
content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction),
|
||||||
onClick = {}
|
onClick = {},
|
||||||
|
onLongClick = {}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
reactions = {
|
reactions = {
|
||||||
|
|
@ -200,7 +202,8 @@ internal fun TimelineItemReactionsLayoutPreview() = ElementPreview {
|
||||||
content = MessagesReactionsButtonContent.Reaction(
|
content = MessagesReactionsButtonContent.Reaction(
|
||||||
it
|
it
|
||||||
),
|
),
|
||||||
onClick = {}
|
onClick = {},
|
||||||
|
onLongClick = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ fun TimelineItemReactions(
|
||||||
reactionsState: TimelineItemReactions,
|
reactionsState: TimelineItemReactions,
|
||||||
isOutgoing: Boolean,
|
isOutgoing: Boolean,
|
||||||
onReactionClicked: (emoji: String) -> Unit,
|
onReactionClicked: (emoji: String) -> Unit,
|
||||||
|
onReactionLongClicked: (emoji: String) -> Unit,
|
||||||
onMoreReactionsClicked: () -> Unit,
|
onMoreReactionsClicked: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -61,6 +62,7 @@ fun TimelineItemReactions(
|
||||||
reactions = reactionsState.reactions,
|
reactions = reactionsState.reactions,
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
onReactionClick = onReactionClicked,
|
onReactionClick = onReactionClicked,
|
||||||
|
onReactionLongClick = onReactionLongClicked,
|
||||||
onMoreReactionsClick = onMoreReactionsClicked,
|
onMoreReactionsClick = onMoreReactionsClicked,
|
||||||
onToggleExpandClick = { expanded = !expanded },
|
onToggleExpandClick = { expanded = !expanded },
|
||||||
)
|
)
|
||||||
|
|
@ -72,6 +74,7 @@ private fun TimelineItemReactionsView(
|
||||||
reactions: ImmutableList<AggregatedReaction>,
|
reactions: ImmutableList<AggregatedReaction>,
|
||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
onReactionClick: (emoji: String) -> Unit,
|
onReactionClick: (emoji: String) -> Unit,
|
||||||
|
onReactionLongClick: (emoji: String) -> Unit,
|
||||||
onMoreReactionsClick: () -> Unit,
|
onMoreReactionsClick: () -> Unit,
|
||||||
onToggleExpandClick: () -> Unit,
|
onToggleExpandClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
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)
|
text = stringResource(id = if (expanded) R.string.screen_room_reactions_show_less else R.string.screen_room_reactions_show_more)
|
||||||
),
|
),
|
||||||
onClick = onToggleExpandClick,
|
onClick = onToggleExpandClick,
|
||||||
|
onLongClick = {}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
addMoreButton = {
|
addMoreButton = {
|
||||||
MessagesReactionButton(
|
MessagesReactionButton(
|
||||||
content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction),
|
content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction),
|
||||||
onClick = onMoreReactionsClick
|
onClick = onMoreReactionsClick,
|
||||||
|
onLongClick = {}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
reactions = {
|
reactions = {
|
||||||
reactions.forEach { reaction ->
|
reactions.forEach { reaction ->
|
||||||
MessagesReactionButton(
|
MessagesReactionButton(
|
||||||
content = MessagesReactionsButtonContent.Reaction(reaction = reaction),
|
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,
|
isOutgoing = isOutgoing,
|
||||||
onReactionClicked = {},
|
onReactionClicked = {},
|
||||||
|
onReactionLongClicked = {},
|
||||||
onMoreReactionsClicked = {},
|
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.groups.canBeDisplayedInBubbleBlock
|
||||||
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
|
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.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
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.TimelineItemReactions
|
||||||
|
|
@ -90,14 +91,34 @@ class TimelineItemEventFactory @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions {
|
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(
|
AggregatedReaction(
|
||||||
key = it.key,
|
key = reaction.key,
|
||||||
count = it.count.toInt(),
|
currentUserId = matrixClient.sessionId,
|
||||||
isHighlighted = it.senderIds.contains(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())
|
return TimelineItemReactions(aggregatedReactions.toImmutableList())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.features.messages.impl.timeline.model
|
package io.element.android.features.messages.impl.timeline.model
|
||||||
|
|
||||||
import io.element.android.libraries.core.extensions.ellipsize
|
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
|
* 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
|
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 key the full reaction key (e.g. "👍", "YES!")
|
||||||
* @property count the number of users who reacted with this key
|
* @property senders the list of users who sent the reactions
|
||||||
* @property isHighlighted true if the reaction has (also) been sent by the current user.
|
|
||||||
*/
|
*/
|
||||||
data class AggregatedReaction(
|
data class AggregatedReaction(
|
||||||
|
val currentUserId: UserId,
|
||||||
val key: String,
|
val key: String,
|
||||||
val count: Int,
|
val senders: List<AggregatedReactionSender>
|
||||||
val isHighlighted: Boolean = false
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The key to be displayed on screen.
|
* The key to be displayed on screen.
|
||||||
*
|
*
|
||||||
|
|
@ -45,4 +45,18 @@ data class AggregatedReaction(
|
||||||
val displayKey: String by lazy {
|
val displayKey: String by lazy {
|
||||||
key.ellipsize(MAX_DISPLAY_CHARS)
|
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
|
package io.element.android.features.messages.impl.timeline.model
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
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> {
|
open class AggregatedReactionProvider : PreviewParameterProvider<AggregatedReaction> {
|
||||||
override val values: Sequence<AggregatedReaction>
|
override val values: Sequence<AggregatedReaction>
|
||||||
|
|
@ -29,11 +32,27 @@ open class AggregatedReactionProvider : PreviewParameterProvider<AggregatedReact
|
||||||
}
|
}
|
||||||
|
|
||||||
fun anAggregatedReaction(
|
fun anAggregatedReaction(
|
||||||
|
userId: UserId = UserId("@alice:server.org"),
|
||||||
key: String = "👍",
|
key: String = "👍",
|
||||||
count: Int = 1,
|
count: Int = 1,
|
||||||
isHighlighted: Boolean = false,
|
isHighlighted: Boolean = false,
|
||||||
) = AggregatedReaction(
|
): AggregatedReaction {
|
||||||
key = key,
|
val senders = buildList {
|
||||||
count = count,
|
repeat(count) { index ->
|
||||||
isHighlighted = isHighlighted,
|
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.messagecomposer.MessageComposerPresenter
|
||||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
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.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.components.retrysendmenu.RetrySendMenuPresenter
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||||
|
|
@ -584,6 +585,7 @@ class MessagesPresenterTest {
|
||||||
val buildMeta = aBuildMeta()
|
val buildMeta = aBuildMeta()
|
||||||
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)
|
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)
|
||||||
val customReactionPresenter = CustomReactionPresenter()
|
val customReactionPresenter = CustomReactionPresenter()
|
||||||
|
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
|
||||||
val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom)
|
val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom)
|
||||||
return MessagesPresenter(
|
return MessagesPresenter(
|
||||||
room = matrixRoom,
|
room = matrixRoom,
|
||||||
|
|
@ -591,6 +593,7 @@ class MessagesPresenterTest {
|
||||||
timelinePresenter = timelinePresenter,
|
timelinePresenter = timelinePresenter,
|
||||||
actionListPresenter = actionListPresenter,
|
actionListPresenter = actionListPresenter,
|
||||||
customReactionPresenter = customReactionPresenter,
|
customReactionPresenter = customReactionPresenter,
|
||||||
|
reactionSummaryPresenter = reactionSummaryPresenter,
|
||||||
retrySendMenuPresenter = retrySendMenuPresenter,
|
retrySendMenuPresenter = retrySendMenuPresenter,
|
||||||
networkMonitor = FakeNetworkMonitor(),
|
networkMonitor = FakeNetworkMonitor(),
|
||||||
snackbarDispatcher = SnackbarDispatcher(),
|
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.TimelineEvents
|
||||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
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.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.MatrixTimeline
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
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.api.timeline.item.virtual.VirtualTimelineItem
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
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.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
class TimelinePresenterTest {
|
class TimelinePresenterTest {
|
||||||
@Test
|
@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(
|
private fun TestScope.createTimelinePresenter(
|
||||||
timeline: MatrixTimeline = FakeMatrixTimeline(),
|
timeline: MatrixTimeline = FakeMatrixTimeline(),
|
||||||
timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory()
|
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
|
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.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class AggregatedReactionTest {
|
class AggregatedReactionTest {
|
||||||
@Test
|
@Test
|
||||||
fun `reaction display key is shortened`() {
|
fun `reaction display key is shortened`() {
|
||||||
val reaction = AggregatedReaction(
|
val reaction = anAggregatedReaction(
|
||||||
key = "1234567890123456790",
|
key = "1234567890123456790",
|
||||||
count = 1,
|
count = 1
|
||||||
isHighlighted = false
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals("1234567890123456…", reaction.displayKey)
|
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
|
package io.element.android.libraries.matrix.api.timeline.item.event
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
|
||||||
|
|
||||||
data class EventReaction(
|
data class EventReaction(
|
||||||
val key: String,
|
val key: String,
|
||||||
val count: Long,
|
val senders: List<ReactionSender>
|
||||||
val senderIds: List<UserId>
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.EventTimelineItem
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
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.ProfileTimelineDetails
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
||||||
import org.matrix.rustcomponents.sdk.Reaction
|
import org.matrix.rustcomponents.sdk.Reaction
|
||||||
import org.matrix.rustcomponents.sdk.EventItemOrigin as RustEventItemOrigin
|
import org.matrix.rustcomponents.sdk.EventItemOrigin as RustEventItemOrigin
|
||||||
import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState
|
import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState
|
||||||
|
|
@ -81,8 +82,12 @@ private fun List<Reaction>?.map(): List<EventReaction> {
|
||||||
return this?.map {
|
return this?.map {
|
||||||
EventReaction(
|
EventReaction(
|
||||||
key = it.key,
|
key = it.key,
|
||||||
count = it.count.toLong(),
|
senders = it.senders.map { sender ->
|
||||||
senderIds = it.senders.map { sender -> UserId(sender.senderId) }
|
ReactionSender(
|
||||||
|
senderId = UserId(sender.senderId),
|
||||||
|
timestamp = sender.timestamp.toLong()
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
} ?: emptyList()
|
} ?: 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