diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 2ea112c1cc..811e087b02 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index 50e952f237..d22d54e7f3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -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?, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index bb0b61f620..7da67d468c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 4d93810e20..b68a889384 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -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, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index e939b9ff68..b47ded8b3a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -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() ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index cecc2d7a63..9f81e612ba 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -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 = {}, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt index 20658c798e..568bfefd18 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt @@ -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 = {} ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 0099c958ca..90d3e6cd8c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -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 = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt index 851389c6bd..1bd5cc2c1c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt @@ -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 = {} ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt index 6682a302d2..39281dc26e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt @@ -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, 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 = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryEvents.kt new file mode 100644 index 0000000000..fdf94f52ce --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryEvents.kt @@ -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, val selectedKey: String) : ReactionSummaryEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt new file mode 100644 index 0000000000..456ac5f548 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt @@ -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 { + @Composable + override fun present(): ReactionSummaryState { + LaunchedEffect(Unit) { + room.updateMembers() + } + + val membersState by room.membersStateFlow.collectAsState() + + val target: MutableState = 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, 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) + }) + }) + } + } + } + +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryState.kt new file mode 100644 index 0000000000..37e150320b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryState.kt @@ -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, + val selectedKey: String, + val selectedEventId: EventId + ) +} + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryStateProvider.kt new file mode 100644 index 0000000000..d6642922bb --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryStateProvider.kt @@ -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 { + 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 = {} + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt new file mode 100644 index 0000000000..f794d60c81 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt @@ -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) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt index 6bc5df1e79..683b7515b9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt @@ -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 { it.count } + .thenBy { it.senders[0].timestamp } + ) return TimelineItemReactions(aggregatedReactions.toImmutableList()) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReaction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReaction.kt index ba13896c06..59c52ed8cf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReaction.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReaction.kt @@ -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 ) { - /** * 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 } + } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt index 148f565911..dcd6bb105c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt @@ -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 { override val values: Sequence @@ -29,11 +32,27 @@ open class AggregatedReactionProvider : PreviewParameterProvider + 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 + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionSender.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionSender.kt new file mode 100644 index 0000000000..276ee0b266 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionSender.kt @@ -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 +) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt index 692d4a4e35..5fcd06a980 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt @@ -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(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt index 0dfe6fd53c..345884f7bf 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt @@ -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() diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/reactionsummary/ReactionSummaryPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/reactionsummary/ReactionSummaryPresenterTests.kt new file mode 100644 index 0000000000..0170878cb5 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/reactionsummary/ReactionSummaryPresenterTests.kt @@ -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) + } + } + +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/model/AggregatedReactionTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/model/AggregatedReactionTest.kt index 0e1ccbd003..ce107f76aa 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/model/AggregatedReactionTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/model/AggregatedReactionTest.kt @@ -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) + } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventReaction.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventReaction.kt index 8bea4b5330..a2e68d17d2 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventReaction.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventReaction.kt @@ -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 + val senders: List ) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/ReactionSender.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/ReactionSender.kt new file mode 100644 index 0000000000..60398cffd5 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/ReactionSender.kt @@ -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 +) + diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt index 359b9ecdef..21e7d51638 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt @@ -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?.map(): List { 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() } diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.reactionsummary_null_DefaultGroup_SheetContentPreview-D-12_13_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.reactionsummary_null_DefaultGroup_SheetContentPreview-D-12_13_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c63cf7083a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.reactionsummary_null_DefaultGroup_SheetContentPreview-D-12_13_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4e63f683ef130d77b55966e954a7cbc144dceb5960056bcd01d7c0c9583b3b03 +size 25358 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.reactionsummary_null_DefaultGroup_SheetContentPreview-N-12_14_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.reactionsummary_null_DefaultGroup_SheetContentPreview-N-12_14_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..495f0b09a0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.reactionsummary_null_DefaultGroup_SheetContentPreview-N-12_14_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5fd8aeb2e875a1280a70ba913dd913759af66fb4300ed69c7e109ec8e8378a09 +size 25051 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-D-12_13_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-D-13_14_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-D-12_13_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-D-13_14_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-N-12_14_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-N-13_15_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-N-12_14_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-N-13_15_null,NEXUS_5,1.0,en].png