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:
David Langley 2023-07-31 18:39:20 +01:00 committed by GitHub
parent ca3e284991
commit 0b95ef09b7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 829 additions and 39 deletions

View file

@ -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,

View file

@ -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?,

View file

@ -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,

View file

@ -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,
)

View file

@ -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()
)

View file

@ -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 = {},
)

View file

@ -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 = {}
)
}
}

View file

@ -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 = {},

View file

@ -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 = {}
)
}
}

View file

@ -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 = {},
)
}

View file

@ -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
}

View file

@ -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)
})
})
}
}
}
}

View file

@ -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
)
}

View file

@ -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 = {}
)
}

View file

@ -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)
}

View file

@ -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())
}

View file

@ -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 }
}
}

View file

@ -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
)
}

View file

@ -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
)

View file

@ -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(),

View file

@ -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()

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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>
)

View file

@ -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
)

View file

@ -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()
}

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4e63f683ef130d77b55966e954a7cbc144dceb5960056bcd01d7c0c9583b3b03
size 25358

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5fd8aeb2e875a1280a70ba913dd913759af66fb4300ed69c7e109ec8e8378a09
size 25051