Merge pull request #1834 from vector-im/feature/bma/readReceipts

Render send state and read receipts
This commit is contained in:
Benoit Marty 2023-11-20 14:39:13 +01:00 committed by GitHub
commit 71ef0c3c78
118 changed files with 1159 additions and 56 deletions

View file

@ -0,0 +1,21 @@
/*
* 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.appconfig
object TimelineConfig {
const val maxReadReceiptToDisplay = 3
}

View file

@ -33,6 +33,7 @@ dependencies {
implementation(projects.anvilannotations) implementation(projects.anvilannotations)
anvil(projects.anvilcodegen) anvil(projects.anvilcodegen)
api(projects.features.messages.api) api(projects.features.messages.api)
implementation(projects.appconfig)
implementation(projects.features.call) implementation(projects.features.call)
implementation(projects.features.location.api) implementation(projects.features.location.api)
implementation(projects.features.poll.api) implementation(projects.features.poll.api)

View file

@ -42,6 +42,7 @@ import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.TimelineState import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetPresenter
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
@ -97,6 +98,7 @@ class MessagesPresenter @AssistedInject constructor(
private val customReactionPresenter: CustomReactionPresenter, private val customReactionPresenter: CustomReactionPresenter,
private val reactionSummaryPresenter: ReactionSummaryPresenter, private val reactionSummaryPresenter: ReactionSummaryPresenter,
private val retrySendMenuPresenter: RetrySendMenuPresenter, private val retrySendMenuPresenter: RetrySendMenuPresenter,
private val readReceiptBottomSheetPresenter: ReadReceiptBottomSheetPresenter,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
private val messageSummaryFormatter: MessageSummaryFormatter, private val messageSummaryFormatter: MessageSummaryFormatter,
@ -124,6 +126,7 @@ class MessagesPresenter @AssistedInject constructor(
val customReactionState = customReactionPresenter.present() val customReactionState = customReactionPresenter.present()
val reactionSummaryState = reactionSummaryPresenter.present() val reactionSummaryState = reactionSummaryPresenter.present()
val retryState = retrySendMenuPresenter.present() val retryState = retrySendMenuPresenter.present()
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value) val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
@ -201,6 +204,7 @@ class MessagesPresenter @AssistedInject constructor(
customReactionState = customReactionState, customReactionState = customReactionState,
reactionSummaryState = reactionSummaryState, reactionSummaryState = reactionSummaryState,
retrySendMenuState = retryState, retrySendMenuState = retryState,
readReceiptBottomSheetState = readReceiptBottomSheetState,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
showReinvitePrompt = showReinvitePrompt, showReinvitePrompt = showReinvitePrompt,

View file

@ -22,6 +22,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.timeline.TimelineState import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
@ -43,6 +44,7 @@ data class MessagesState(
val customReactionState: CustomReactionState, val customReactionState: CustomReactionState,
val reactionSummaryState: ReactionSummaryState, val reactionSummaryState: ReactionSummaryState,
val retrySendMenuState: RetrySendMenuState, val retrySendMenuState: RetrySendMenuState,
val readReceiptBottomSheetState: ReadReceiptBottomSheetState,
val hasNetworkConnection: Boolean, val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
val inviteProgress: Async<Unit>, val inviteProgress: Async<Unit>,

View file

@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.aTimelineState import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
@ -96,6 +97,10 @@ fun aMessagesState() = MessagesState(
selectedEvent = null, selectedEvent = null,
eventSink = {}, eventSink = {},
), ),
readReceiptBottomSheetState = ReadReceiptBottomSheetState(
selectedEvent = null,
eventSink = {},
),
actionListState = anActionListState(), actionListState = anActionListState(),
customReactionState = CustomReactionState( customReactionState = CustomReactionState(
target = CustomReactionState.Target.None, target = CustomReactionState.Target.None,

View file

@ -70,6 +70,8 @@ import io.element.android.features.messages.impl.timeline.components.customreact
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
@ -212,6 +214,9 @@ fun MessagesView(
onReactionClicked = ::onEmojiReactionClicked, onReactionClicked = ::onEmojiReactionClicked,
onReactionLongClicked = ::onEmojiReactionLongClicked, onReactionLongClicked = ::onEmojiReactionLongClicked,
onMoreReactionsClicked = ::onMoreReactionsClicked, onMoreReactionsClicked = ::onMoreReactionsClicked,
onReadReceiptClick = { event ->
state.readReceiptBottomSheetState.eventSink(ReadReceiptBottomSheetEvents.EventSelected(event))
},
onSendLocationClicked = onSendLocationClicked, onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked, onCreatePollClicked = onCreatePollClicked,
onSwipeToReply = { targetEvent -> onSwipeToReply = { targetEvent ->
@ -246,13 +251,12 @@ fun MessagesView(
) )
ReactionSummaryView(state = state.reactionSummaryState) ReactionSummaryView(state = state.reactionSummaryState)
RetrySendMessageMenu( RetrySendMessageMenu(state = state.retrySendMenuState)
state = state.retrySendMenuState ReadReceiptBottomSheet(
) state = state.readReceiptBottomSheetState,
onUserDataClicked = onUserDataClicked,
ReinviteDialog(
state = state
) )
ReinviteDialog(state = state)
// Since the textfield is now based on an Android view, this is no longer done automatically. // Since the textfield is now based on an Android view, this is no longer done automatically.
// We need to hide the keyboard automatically when navigating out of this screen. // We need to hide the keyboard automatically when navigating out of this screen.
@ -310,6 +314,7 @@ private fun MessagesViewContent(
onReactionClicked: (key: String, TimelineItem.Event) -> Unit, onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClicked: (key: String, TimelineItem.Event) -> Unit, onReactionLongClicked: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClicked: (TimelineItem.Event) -> Unit, onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onMessageLongClicked: (TimelineItem.Event) -> Unit, onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
onSendLocationClicked: () -> Unit, onSendLocationClicked: () -> Unit,
@ -381,6 +386,7 @@ private fun MessagesViewContent(
onReactionClicked = onReactionClicked, onReactionClicked = onReactionClicked,
onReactionLongClicked = onReactionLongClicked, onReactionLongClicked = onReactionLongClicked,
onMoreReactionsClicked = onMoreReactionsClicked, onMoreReactionsClicked = onMoreReactionsClicked,
onReadReceiptClick = onReadReceiptClick,
onSwipeToReply = onSwipeToReply, onSwipeToReply = onSwipeToReply,
) )
}, },
@ -406,7 +412,8 @@ private fun MessagesViewComposerBottomSheetContents(
if (state.userHasPermissionToSendMessage) { if (state.userHasPermissionToSendMessage) {
Column(modifier = modifier.fillMaxWidth()) { Column(modifier = modifier.fillMaxWidth()) {
MentionSuggestionsPickerView( MentionSuggestionsPickerView(
modifier = Modifier.heightIn(max = 230.dp) modifier = Modifier
.heightIn(max = 230.dp)
// Consume all scrolling, preventing the bottom sheet from being dragged when interacting with the list of suggestions // Consume all scrolling, preventing the bottom sheet from being dragged when interacting with the list of suggestions
.nestedScroll(object : NestedScrollConnection { .nestedScroll(object : NestedScrollConnection {
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {

View file

@ -35,11 +35,14 @@ import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
@ -64,6 +67,7 @@ class TimelinePresenter @Inject constructor(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val verificationService: SessionVerificationService, private val verificationService: SessionVerificationService,
private val encryptionService: EncryptionService, private val encryptionService: EncryptionService,
private val featureFlagService: FeatureFlagService,
private val redactedVoiceMessageManager: RedactedVoiceMessageManager, private val redactedVoiceMessageManager: RedactedVoiceMessageManager,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
@ -99,6 +103,9 @@ class TimelinePresenter @Inject constructor(
} }
} }
val readReceiptsEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.ReadReceipts).collectAsState(initial = false)
val membersState by room.membersStateFlow.collectAsState()
fun handleEvents(event: TimelineEvents) { fun handleEvents(event: TimelineEvents) {
when (event) { when (event) {
TimelineEvents.LoadMore -> localScope.paginateBackwards() TimelineEvents.LoadMore -> localScope.paginateBackwards()
@ -138,7 +145,17 @@ class TimelinePresenter @Inject constructor(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
timeline timeline
.timelineItems .timelineItems
.onEach(timelineItemsFactory::replaceWith) .onEach {
timelineItemsFactory.replaceWith(
timelineItems = it,
roomMembers = if (readReceiptsEnabled) {
membersState.roomMembers().orEmpty()
} else {
// Give an empty list to not affect performance
emptyList()
}
)
}
.onEach { timelineItems -> .onEach { timelineItems ->
if (timelineItems.isEmpty()) { if (timelineItems.isEmpty()) {
paginateBackwards() paginateBackwards()
@ -153,6 +170,7 @@ class TimelinePresenter @Inject constructor(
userHasPermissionToSendMessage = userHasPermissionToSendMessage, userHasPermissionToSendMessage = userHasPermissionToSendMessage,
paginationState = paginationState, paginationState = paginationState,
timelineItems = timelineItems, timelineItems = timelineItems,
showReadReceipts = readReceiptsEnabled,
hasNewItems = hasNewItems.value, hasNewItems = hasNewItems.value,
sessionState = sessionState, sessionState = sessionState,
eventSink = ::handleEvents eventSink = ::handleEvents

View file

@ -26,6 +26,7 @@ import kotlinx.collections.immutable.ImmutableList
@Immutable @Immutable
data class TimelineState( data class TimelineState(
val timelineItems: ImmutableList<TimelineItem>, val timelineItems: ImmutableList<TimelineItem>,
val showReadReceipts: Boolean,
val highlightedEventId: EventId?, val highlightedEventId: EventId?,
val userHasPermissionToSendMessage: Boolean, val userHasPermissionToSendMessage: Boolean,
val paginationState: MatrixTimeline.PaginationState, val paginationState: MatrixTimeline.PaginationState,

View file

@ -16,9 +16,11 @@
package io.element.android.features.messages.impl.timeline package io.element.android.features.messages.impl.timeline
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.model.anAggregatedReaction import io.element.android.features.messages.impl.timeline.model.anAggregatedReaction
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
@ -43,6 +45,7 @@ import kotlin.random.Random
fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf()) = TimelineState( fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf()) = TimelineState(
timelineItems = timelineItems, timelineItems = timelineItems,
showReadReceipts = false,
paginationState = MatrixTimeline.PaginationState( paginationState = MatrixTimeline.PaginationState(
isBackPaginating = false, isBackPaginating = false,
hasMoreToLoadBackwards = true, hasMoreToLoadBackwards = true,
@ -118,11 +121,12 @@ internal fun aTimelineItemEvent(
senderDisplayName: String = "Sender", senderDisplayName: String = "Sender",
content: TimelineItemEventContent = aTimelineItemTextContent(), content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
sendState: LocalEventSendState = LocalEventSendState.Sent(eventId), sendState: LocalEventSendState? = null,
inReplyTo: InReplyTo? = null, inReplyTo: InReplyTo? = null,
isThreaded: Boolean = false, isThreaded: Boolean = false,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(), debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(), timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
readReceiptState: TimelineItemReadReceipts = aTimelineItemReadReceipts(),
): TimelineItem.Event { ): TimelineItem.Event {
return TimelineItem.Event( return TimelineItem.Event(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
@ -132,6 +136,7 @@ internal fun aTimelineItemEvent(
senderAvatar = AvatarData("@senderId:domain", "sender", size = AvatarSize.TimelineSender), senderAvatar = AvatarData("@senderId:domain", "sender", size = AvatarSize.TimelineSender),
content = content, content = content,
reactionsState = timelineItemReactions, reactionsState = timelineItemReactions,
readReceiptState = readReceiptState,
sentTime = "12:34", sentTime = "12:34",
isMine = isMine, isMine = isMine,
senderDisplayName = senderDisplayName, senderDisplayName = senderDisplayName,
@ -173,6 +178,12 @@ internal fun aTimelineItemDebugInfo(
model, originalJson, latestEditedJson model, originalJson, latestEditedJson
) )
internal fun aTimelineItemReadReceipts(): TimelineItemReadReceipts {
return TimelineItemReadReceipts(
receipts = emptyList<ReadReceiptData>().toImmutableList(),
)
}
fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents { fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents {
val event = aTimelineItemEvent( val event = aTimelineItemEvent(
isMine = true, isMine = true,

View file

@ -92,6 +92,7 @@ fun TimelineView(
onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit, onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit,
onReactionLongClicked: (emoji: String, TimelineItem.Event) -> Unit, onReactionLongClicked: (emoji: String, TimelineItem.Event) -> Unit,
onMoreReactionsClicked: (TimelineItem.Event) -> Unit, onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun onReachedLoadMore() { fun onReachedLoadMore() {
@ -126,6 +127,9 @@ fun TimelineView(
) { timelineItem -> ) { timelineItem ->
TimelineItemRow( TimelineItemRow(
timelineItem = timelineItem, timelineItem = timelineItem,
showReadReceipts = state.showReadReceipts,
isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true
&& state.timelineItems.first().identifier() == timelineItem.identifier(),
highlightedItem = state.highlightedEventId?.value, highlightedItem = state.highlightedEventId?.value,
userHasPermissionToSendMessage = state.userHasPermissionToSendMessage, userHasPermissionToSendMessage = state.userHasPermissionToSendMessage,
onClick = onMessageClicked, onClick = onMessageClicked,
@ -135,6 +139,7 @@ fun TimelineView(
onReactionClick = onReactionClicked, onReactionClick = onReactionClicked,
onReactionLongClick = onReactionLongClicked, onReactionLongClick = onReactionLongClicked,
onMoreReactionsClick = onMoreReactionsClicked, onMoreReactionsClick = onMoreReactionsClicked,
onReadReceiptClick = onReadReceiptClick,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
sessionState = state.sessionState, sessionState = state.sessionState,
eventSink = state.eventSink, eventSink = state.eventSink,
@ -169,6 +174,8 @@ fun TimelineView(
@Composable @Composable
private fun TimelineItemRow( private fun TimelineItemRow(
timelineItem: TimelineItem, timelineItem: TimelineItem,
showReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
highlightedItem: String?, highlightedItem: String?,
userHasPermissionToSendMessage: Boolean, userHasPermissionToSendMessage: Boolean,
sessionState: SessionState, sessionState: SessionState,
@ -179,6 +186,7 @@ private fun TimelineItemRow(
onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit,
eventSink: (TimelineEvents) -> Unit, eventSink: (TimelineEvents) -> Unit,
@ -205,6 +213,8 @@ private fun TimelineItemRow(
} else { } else {
TimelineItemEventRow( TimelineItemEventRow(
event = timelineItem, event = timelineItem,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = highlightedItem == timelineItem.identifier(), isHighlighted = highlightedItem == timelineItem.identifier(),
canReply = userHasPermissionToSendMessage && timelineItem.content.canBeRepliedTo(), canReply = userHasPermissionToSendMessage && timelineItem.content.canBeRepliedTo(),
onClick = { onClick(timelineItem) }, onClick = { onClick(timelineItem) },
@ -214,6 +224,7 @@ private fun TimelineItemRow(
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick, onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
onSwipeToReply = { onSwipeToReply(timelineItem) }, onSwipeToReply = { onSwipeToReply(timelineItem) },
eventSink = eventSink, eventSink = eventSink,
@ -244,6 +255,8 @@ private fun TimelineItemRow(
timelineItem.events.forEach { subGroupEvent -> timelineItem.events.forEach { subGroupEvent ->
TimelineItemRow( TimelineItemRow(
timelineItem = subGroupEvent, timelineItem = subGroupEvent,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
highlightedItem = highlightedItem, highlightedItem = highlightedItem,
sessionState = sessionState, sessionState = sessionState,
userHasPermissionToSendMessage = false, userHasPermissionToSendMessage = false,
@ -255,6 +268,7 @@ private fun TimelineItemRow(
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick, onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
eventSink = eventSink, eventSink = eventSink,
onSwipeToReply = {}, onSwipeToReply = {},
) )
@ -362,6 +376,7 @@ internal fun TimelineViewPreview(
onReactionLongClicked = { _, _ -> }, onReactionLongClicked = { _, _ -> },
onMoreReactionsClicked = {}, onMoreReactionsClicked = {},
onSwipeToReply = {}, onSwipeToReply = {},
onReadReceiptClick = {},
) )
} }
} }

View file

@ -66,6 +66,8 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
import io.element.android.features.messages.impl.timeline.components.event.toExtraPadding import io.element.android.features.messages.impl.timeline.components.event.toExtraPadding
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
@ -114,6 +116,8 @@ import kotlin.math.roundToInt
@Composable @Composable
fun TimelineItemEventRow( fun TimelineItemEventRow(
event: TimelineItem.Event, event: TimelineItem.Event,
showReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
isHighlighted: Boolean, isHighlighted: Boolean,
canReply: Boolean, canReply: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
@ -124,6 +128,7 @@ fun TimelineItemEventRow(
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
onReactionLongClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, onReactionLongClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit, onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit,
onReadReceiptClick: (event: TimelineItem.Event) -> Unit,
onSwipeToReply: () -> Unit, onSwipeToReply: () -> Unit,
eventSink: (TimelineEvents) -> Unit, eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@ -173,6 +178,8 @@ fun TimelineItemEventRow(
state = state.draggableState, state = state.draggableState,
), ),
event = event, event = event,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = isHighlighted, isHighlighted = isHighlighted,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onClick, onClick = onClick,
@ -183,6 +190,7 @@ fun TimelineItemEventRow(
onReactionClicked = { emoji -> onReactionClick(emoji, event) }, onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) }, onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) }, onMoreReactionsClicked = { onMoreReactionsClick(event) },
onReadReceiptsClicked = { onReadReceiptClick(event) },
eventSink = eventSink, eventSink = eventSink,
) )
} }
@ -190,6 +198,8 @@ fun TimelineItemEventRow(
} else { } else {
TimelineItemEventRowContent( TimelineItemEventRowContent(
event = event, event = event,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = isHighlighted, isHighlighted = isHighlighted,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = onClick, onClick = onClick,
@ -200,6 +210,7 @@ fun TimelineItemEventRow(
onReactionClicked = { emoji -> onReactionClick(emoji, event) }, onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) }, onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) }, onMoreReactionsClicked = { onMoreReactionsClick(event) },
onReadReceiptsClicked = { onReadReceiptClick(event) },
eventSink = eventSink, eventSink = eventSink,
) )
} }
@ -232,6 +243,8 @@ private fun SwipeSensitivity(
@Composable @Composable
private fun TimelineItemEventRowContent( private fun TimelineItemEventRowContent(
event: TimelineItem.Event, event: TimelineItem.Event,
showReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
isHighlighted: Boolean, isHighlighted: Boolean,
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
onClick: () -> Unit, onClick: () -> Unit,
@ -240,6 +253,7 @@ private fun TimelineItemEventRowContent(
inReplyToClicked: () -> Unit, inReplyToClicked: () -> Unit,
onUserDataClicked: () -> Unit, onUserDataClicked: () -> Unit,
onReactionClicked: (emoji: String) -> Unit, onReactionClicked: (emoji: String) -> Unit,
onReadReceiptsClicked: () -> Unit,
onReactionLongClicked: (emoji: String) -> Unit, onReactionLongClicked: (emoji: String) -> Unit,
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit, onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
eventSink: (TimelineEvents) -> Unit, eventSink: (TimelineEvents) -> Unit,
@ -256,7 +270,12 @@ private fun TimelineItemEventRowContent(
.wrapContentHeight() .wrapContentHeight()
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
val (sender, message, reactions) = createRefs() val (
sender,
message,
reactions,
readReceipts,
) = createRefs()
// Sender // Sender
val avatarStrokeSize = 3.dp val avatarStrokeSize = 3.dp
@ -322,6 +341,25 @@ private fun TimelineItemEventRowContent(
.padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp) .padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp)
) )
} }
// Read receipts / Send state
TimelineItemReadReceiptView(
state = ReadReceiptViewState(
sendState = event.localSendState,
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts,
),
showReadReceipts = showReadReceipts,
onReadReceiptsClicked = onReadReceiptsClicked,
modifier = Modifier
.constrainAs(readReceipts) {
if (event.reactionsState.reactions.isNotEmpty()) {
top.linkTo(reactions.bottom, margin = 4.dp)
} else {
top.linkTo(message.bottom, margin = 4.dp)
}
}
)
} }
} }
@ -658,6 +696,8 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
), ),
groupPosition = TimelineItemGroupPosition.First, groupPosition = TimelineItemGroupPosition.First,
), ),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false, isHighlighted = false,
canReply = true, canReply = true,
onClick = {}, onClick = {},
@ -667,6 +707,7 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {},
onTimestampClicked = {}, onTimestampClicked = {},
onSwipeToReply = {}, onSwipeToReply = {},
eventSink = {}, eventSink = {},
@ -679,6 +720,8 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
), ),
groupPosition = TimelineItemGroupPosition.Last, groupPosition = TimelineItemGroupPosition.Last,
), ),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false, isHighlighted = false,
canReply = true, canReply = true,
onClick = {}, onClick = {},
@ -688,6 +731,7 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {},
onTimestampClicked = {}, onTimestampClicked = {},
onSwipeToReply = {}, onSwipeToReply = {},
eventSink = {}, eventSink = {},
@ -718,6 +762,8 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
inReplyTo = aInReplyToReady(replyContent), inReplyTo = aInReplyToReady(replyContent),
groupPosition = TimelineItemGroupPosition.First, groupPosition = TimelineItemGroupPosition.First,
), ),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false, isHighlighted = false,
canReply = true, canReply = true,
onClick = {}, onClick = {},
@ -727,6 +773,7 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {},
onTimestampClicked = {}, onTimestampClicked = {},
onSwipeToReply = {}, onSwipeToReply = {},
eventSink = {}, eventSink = {},
@ -741,6 +788,8 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
isThreaded = true, isThreaded = true,
groupPosition = TimelineItemGroupPosition.Last, groupPosition = TimelineItemGroupPosition.Last,
), ),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false, isHighlighted = false,
canReply = true, canReply = true,
onClick = {}, onClick = {},
@ -750,6 +799,7 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {},
onTimestampClicked = {}, onTimestampClicked = {},
onSwipeToReply = {}, onSwipeToReply = {},
eventSink = {}, eventSink = {},
@ -792,6 +842,8 @@ internal fun TimelineItemEventRowTimestampPreview(
reactionsState = aTimelineItemReactions(count = 0), reactionsState = aTimelineItemReactions(count = 0),
senderDisplayName = if (useDocument) "Document case" else "Text case", senderDisplayName = if (useDocument) "Document case" else "Text case",
), ),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false, isHighlighted = false,
canReply = true, canReply = true,
onClick = {}, onClick = {},
@ -801,6 +853,7 @@ internal fun TimelineItemEventRowTimestampPreview(
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {},
onTimestampClicked = {}, onTimestampClicked = {},
onSwipeToReply = {}, onSwipeToReply = {},
eventSink = {}, eventSink = {},
@ -824,6 +877,8 @@ internal fun TimelineItemEventRowWithManyReactionsPreview() = ElementPreview {
), ),
timelineItemReactions = aTimelineItemReactions(count = 20), timelineItemReactions = aTimelineItemReactions(count = 20),
), ),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false, isHighlighted = false,
canReply = true, canReply = true,
onClick = {}, onClick = {},
@ -833,6 +888,7 @@ internal fun TimelineItemEventRowWithManyReactionsPreview() = ElementPreview {
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {},
onSwipeToReply = {}, onSwipeToReply = {},
onTimestampClicked = {}, onTimestampClicked = {},
eventSink = {}, eventSink = {},
@ -849,6 +905,8 @@ internal fun TimelineItemEventRowLongSenderNamePreview() = ElementPreviewLight {
event = aTimelineItemEvent( event = aTimelineItemEvent(
senderDisplayName = "a long sender display name to test single line and ellipsis at the end of the line", senderDisplayName = "a long sender display name to test single line and ellipsis at the end of the line",
), ),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false, isHighlighted = false,
canReply = true, canReply = true,
onClick = {}, onClick = {},
@ -858,6 +916,7 @@ internal fun TimelineItemEventRowLongSenderNamePreview() = ElementPreviewLight {
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {},
onSwipeToReply = {}, onSwipeToReply = {},
onTimestampClicked = {}, onTimestampClicked = {},
eventSink = {}, eventSink = {},
@ -870,6 +929,8 @@ internal fun TimelineItemEventRowLongSenderNamePreview() = ElementPreviewLight {
internal fun TimelineItemEventTimestampBelowPreview() = ElementPreviewLight { internal fun TimelineItemEventTimestampBelowPreview() = ElementPreviewLight {
TimelineItemEventRow( TimelineItemEventRow(
event = aTimelineItemEvent(content = aTimelineItemPollContent()), event = aTimelineItemEvent(content = aTimelineItemPollContent()),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false, isHighlighted = false,
canReply = true, canReply = true,
onClick = {}, onClick = {},
@ -879,6 +940,7 @@ internal fun TimelineItemEventTimestampBelowPreview() = ElementPreviewLight {
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {}, onMoreReactionsClick = {},
onReadReceiptClick = {},
onSwipeToReply = {}, onSwipeToReply = {},
onTimestampClicked = {}, onTimestampClicked = {},
eventSink = {}, eventSink = {},

View file

@ -69,7 +69,7 @@ private fun TimelineItemReactionsView(
onToggleExpandClick: () -> Unit, onToggleExpandClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
// In LTR languages we want an incoming message's reactions to be LRT and outgoing to be RTL. // In LTR languages we want an incoming message's reactions to be LTR and outgoing to be RTL.
// For RTL languages it should be the opposite. // For RTL languages it should be the opposite.
val currentLayout = LocalLayoutDirection.current val currentLayout = LocalLayoutDirection.current
val reactionsLayoutDirection = if (!isOutgoing) currentLayout val reactionsLayoutDirection = if (!isOutgoing) currentLayout

View file

@ -0,0 +1,27 @@
/*
* 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.receipt
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import kotlinx.collections.immutable.ImmutableList
data class ReadReceiptViewState(
val sendState: LocalEventSendState?,
val isLastOutgoingMessage: Boolean,
val receipts: ImmutableList<ReadReceiptData>,
)

View file

@ -0,0 +1,77 @@
/*
* 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.receipt
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import kotlinx.collections.immutable.toImmutableList
class ReadReceiptViewStateProvider : PreviewParameterProvider<ReadReceiptViewState> {
override val values: Sequence<ReadReceiptViewState>
get() = sequenceOf(
aReadReceiptViewState(),
aReadReceiptViewState(sendState = LocalEventSendState.NotSentYet),
aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId"))),
aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = mutableListOf<ReadReceiptData>().apply { repeat(1) { add(aReadReceiptData(it)) } },
),
aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = mutableListOf<ReadReceiptData>().apply { repeat(2) { add(aReadReceiptData(it)) } },
),
aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = mutableListOf<ReadReceiptData>().apply { repeat(3) { add(aReadReceiptData(it)) } },
),
aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = mutableListOf<ReadReceiptData>().apply { repeat(4) { add(aReadReceiptData(it)) } },
),
aReadReceiptViewState(
sendState = LocalEventSendState.Sent(EventId("\$eventId")),
receipts = mutableListOf<ReadReceiptData>().apply { repeat(5) { add(aReadReceiptData(it)) } },
),
)
}
private fun aReadReceiptViewState(
sendState: LocalEventSendState? = null,
isLastOutgoingMessage: Boolean = true,
receipts: List<ReadReceiptData> = emptyList(),
) = ReadReceiptViewState(
sendState = sendState,
isLastOutgoingMessage = isLastOutgoingMessage,
receipts = receipts.toImmutableList(),
)
private fun aReadReceiptData(
index: Int,
avatarData: AvatarData = anAvatarData(
id = "$index",
size = AvatarSize.TimelineReadReceipt
),
formattedDate: String = "12:34",
) = ReadReceiptData(
avatarData = avatarData,
formattedDate = formattedDate,
)

View file

@ -0,0 +1,211 @@
/*
* 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.receipt
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import io.element.android.appconfig.TimelineConfig
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.getBestName
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@Composable
fun TimelineItemReadReceiptView(
state: ReadReceiptViewState,
showReadReceipts: Boolean,
onReadReceiptsClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
if (state.receipts.isNotEmpty()) {
if (showReadReceipts) {
ReadReceiptsRow(modifier = modifier) {
ReadReceiptsAvatars(
receipts = state.receipts,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.clickable { onReadReceiptsClicked() }
.padding(2.dp)
)
}
}
} else when (state.sendState) {
LocalEventSendState.NotSentYet -> {
ReadReceiptsRow(modifier) {
Icon(
modifier = Modifier.padding(2.dp),
resourceId = CommonDrawables.ic_sending,
contentDescription = null,
tint = ElementTheme.colors.iconSecondary
)
}
}
LocalEventSendState.Canceled -> Unit
is LocalEventSendState.SendingFailed -> {
// Error? The timestamp is already displayed in red
}
null,
is LocalEventSendState.Sent -> {
if (state.isLastOutgoingMessage) {
ReadReceiptsRow(modifier = modifier) {
Icon(
modifier = Modifier.padding(2.dp),
resourceId = CommonDrawables.ic_sent,
contentDescription = null,
tint = ElementTheme.colors.iconSecondary
)
}
}
}
}
}
@Composable
private fun ReadReceiptsRow(
modifier: Modifier = Modifier,
content: @Composable () -> Unit = {},
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(AvatarSize.TimelineReadReceipt.dp + 8.dp)
.padding(horizontal = 18.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.padding(horizontal = 4.dp)
) {
content()
}
}
}
@Composable
private fun ReadReceiptsAvatars(
receipts: ImmutableList<ReadReceiptData>,
modifier: Modifier = Modifier
) {
val avatarSize = AvatarSize.TimelineReadReceipt.dp
val avatarStrokeSize = 1.dp
val avatarStrokeColor = MaterialTheme.colorScheme.background
val receiptDescription = computeReceiptDescription(receipts)
Row(
modifier = modifier
.clearAndSetSemantics {
stateDescription = receiptDescription
},
horizontalArrangement = Arrangement.spacedBy(4.dp - avatarStrokeSize),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
contentAlignment = Alignment.CenterEnd,
) {
receipts
.take(TimelineConfig.maxReadReceiptToDisplay)
.reversed()
.forEachIndexed { index, readReceiptData ->
Box(
modifier = Modifier
.padding(end = (12.dp + avatarStrokeSize * 2) * index)
.size(size = avatarSize + avatarStrokeSize * 2)
.clip(CircleShape)
.background(avatarStrokeColor)
.zIndex(index.toFloat()),
contentAlignment = Alignment.Center,
) {
Avatar(
avatarData = readReceiptData.avatarData,
)
}
}
}
if (receipts.size > TimelineConfig.maxReadReceiptToDisplay) {
Text(
text = "+" + (receipts.size - TimelineConfig.maxReadReceiptToDisplay),
style = ElementTheme.typography.fontBodyXsRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
}
@Composable
private fun computeReceiptDescription(receipts: ImmutableList<ReadReceiptData>): String {
return when (receipts.size) {
0 -> "" // Cannot happen
1 -> stringResource(
id = CommonStrings.a11y_read_receipts_single,
receipts[0].avatarData.getBestName()
)
2 -> stringResource(
id = CommonStrings.a11y_read_receipts_multiple,
receipts[0].avatarData.getBestName(),
receipts[1].avatarData.getBestName(),
)
else -> pluralStringResource(
id = CommonPlurals.a11y_read_receipts_multiple_with_others,
count = receipts.size - 1,
receipts[0].avatarData.getBestName(),
receipts.size - 1
)
}
}
@PreviewsDayNight
@Composable
internal fun TimelineItemReactionsViewPreview(
@PreviewParameter(ReadReceiptViewStateProvider::class) state: ReadReceiptViewState,
) = ElementPreview {
TimelineItemReadReceiptView(
state = state,
showReadReceipts = true,
onReadReceiptsClicked = {},
)
}

View file

@ -0,0 +1,127 @@
/*
* 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.receipt.bottomsheet
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ReadReceiptBottomSheet(
state: ReadReceiptBottomSheetState,
onUserDataClicked: (UserId) -> Unit,
modifier: Modifier = Modifier,
) {
val isVisible = state.selectedEvent != null
val sheetState = rememberModalBottomSheetState()
val coroutineScope = rememberCoroutineScope()
if (isVisible) {
ModalBottomSheet(
modifier = modifier,
// modifier = modifier.navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044
// .imePadding()
sheetState = sheetState,
onDismissRequest = {
coroutineScope.launch {
sheetState.hide()
state.eventSink(ReadReceiptBottomSheetEvents.Dismiss)
}
}
) {
ReadReceiptBottomSheetContent(
state = state,
onUserDataClicked = {
coroutineScope.launch {
sheetState.hide()
state.eventSink(ReadReceiptBottomSheetEvents.Dismiss)
onUserDataClicked.invoke(it)
}
},
)
// FIXME remove after https://issuetracker.google.com/issues/275849044
Spacer(modifier = Modifier.height(32.dp))
}
}
}
@Composable
private fun ColumnScope.ReadReceiptBottomSheetContent(
state: ReadReceiptBottomSheetState,
onUserDataClicked: (UserId) -> Unit,
) {
ListItem(
headlineContent = {
Text(text = stringResource(id = CommonStrings.common_seen_by))
}
)
val receipts = state.selectedEvent?.readReceiptState?.receipts.orEmpty()
receipts.forEach {
val userId = UserId(it.avatarData.id)
MatrixUserRow(
modifier = Modifier.clickable { onUserDataClicked(userId) },
matrixUser = MatrixUser(
userId = userId,
displayName = it.avatarData.name,
avatarUrl = it.avatarData.url,
),
avatarSize = AvatarSize.ReadReceiptList,
trailingContent = {
Text(
text = it.formattedDate,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
)
}
}
@PreviewsDayNight
@Composable
internal fun ReadReceiptBottomSheetPreview(@PreviewParameter(ReadReceiptBottomSheetStateProvider::class) state: ReadReceiptBottomSheetState) = ElementPreview {
// TODO restore RetrySendMessageMenuBottomSheet once the issue with bottom sheet not being previewable is fixed
Column {
ReadReceiptBottomSheetContent(
state = state,
onUserDataClicked = {},
)
}
}

View file

@ -0,0 +1,24 @@
/*
* 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.receipt.bottomsheet
import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface ReadReceiptBottomSheetEvents {
data class EventSelected(val event: TimelineItem.Event) : ReadReceiptBottomSheetEvents
data object Dismiss : ReadReceiptBottomSheetEvents
}

View file

@ -0,0 +1,52 @@
/*
* 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.receipt.bottomsheet
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.architecture.Presenter
import javax.inject.Inject
class ReadReceiptBottomSheetPresenter @Inject constructor(
) : Presenter<ReadReceiptBottomSheetState> {
@Composable
override fun present(): ReadReceiptBottomSheetState {
var selectedEvent: TimelineItem.Event? by remember { mutableStateOf(null) }
fun handleEvent(event: ReadReceiptBottomSheetEvents) {
@Suppress("LiftReturnOrAssignment")
when (event) {
is ReadReceiptBottomSheetEvents.EventSelected -> {
selectedEvent = event.event
}
ReadReceiptBottomSheetEvents.Dismiss -> {
selectedEvent = null
}
}
}
return ReadReceiptBottomSheetState(
selectedEvent = selectedEvent,
eventSink = { handleEvent(it) },
)
}
}

View file

@ -0,0 +1,26 @@
/*
* 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.receipt.bottomsheet
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@Immutable
data class ReadReceiptBottomSheetState(
val selectedEvent: TimelineItem.Event?,
val eventSink: (ReadReceiptBottomSheetEvents) -> Unit,
)

View file

@ -0,0 +1,44 @@
/*
* 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.receipt.bottomsheet
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewStateProvider
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import kotlinx.collections.immutable.toImmutableList
class ReadReceiptBottomSheetStateProvider : PreviewParameterProvider<ReadReceiptBottomSheetState> {
// Reuse the provider ReadReceiptViewStateProvider
private val readReceiptViewStateProvider = ReadReceiptViewStateProvider()
override val values: Sequence<ReadReceiptBottomSheetState> = readReceiptViewStateProvider.values
.filter { it.sendState is LocalEventSendState.Sent }
.map { readReceiptViewState ->
ReadReceiptBottomSheetState(
selectedEvent = aTimelineItemEvent(
readReceiptState = TimelineItemReadReceipts(
receipts = readReceiptViewState.receipts.map { readReceiptData ->
readReceiptData
.copy(avatarData = readReceiptData.avatarData.copy(id = "@${readReceiptData.avatarData.id}:localhost"))
}.toImmutableList()
)
),
eventSink = {},
)
}
}

View file

@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.androidutils.diff.DiffCacheUpdater import io.element.android.libraries.androidutils.diff.DiffCacheUpdater
import io.element.android.libraries.androidutils.diff.MutableListDiffCache import io.element.android.libraries.androidutils.diff.MutableListDiffCache
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -66,19 +67,23 @@ class TimelineItemsFactory @Inject constructor(
suspend fun replaceWith( suspend fun replaceWith(
timelineItems: List<MatrixTimelineItem>, timelineItems: List<MatrixTimelineItem>,
roomMembers: List<RoomMember>,
) = withContext(dispatchers.computation) { ) = withContext(dispatchers.computation) {
lock.withLock { lock.withLock {
diffCacheUpdater.updateWith(timelineItems) diffCacheUpdater.updateWith(timelineItems)
buildAndEmitTimelineItemStates(timelineItems) buildAndEmitTimelineItemStates(timelineItems, roomMembers)
} }
} }
private suspend fun buildAndEmitTimelineItemStates(timelineItems: List<MatrixTimelineItem>) { private suspend fun buildAndEmitTimelineItemStates(
timelineItems: List<MatrixTimelineItem>,
roomMembers: List<RoomMember>,
) {
val newTimelineItemStates = ArrayList<TimelineItem>() val newTimelineItemStates = ArrayList<TimelineItem>()
for (index in diffCache.indices().reversed()) { for (index in diffCache.indices().reversed()) {
val cacheItem = diffCache.get(index) val cacheItem = diffCache.get(index)
if (cacheItem == null) { if (cacheItem == null) {
buildAndCacheItem(timelineItems, index)?.also { timelineItemState -> buildAndCacheItem(timelineItems, index, roomMembers)?.also { timelineItemState ->
newTimelineItemStates.add(timelineItemState) newTimelineItemStates.add(timelineItemState)
} }
} else { } else {
@ -91,11 +96,12 @@ class TimelineItemsFactory @Inject constructor(
private suspend fun buildAndCacheItem( private suspend fun buildAndCacheItem(
timelineItems: List<MatrixTimelineItem>, timelineItems: List<MatrixTimelineItem>,
index: Int index: Int,
roomMembers: List<RoomMember>,
): TimelineItem? { ): TimelineItem? {
val timelineItemState = val timelineItemState =
when (val currentTimelineItem = timelineItems[index]) { when (val currentTimelineItem = timelineItems[index]) {
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems) is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers)
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem) is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
MatrixTimelineItem.Other -> null MatrixTimelineItem.Other -> null
} }

View file

@ -19,13 +19,17 @@ package io.element.android.features.messages.impl.timeline.factories.event
import io.element.android.features.messages.impl.timeline.groups.canBeDisplayedInBubbleBlock import io.element.android.features.messages.impl.timeline.groups.canBeDisplayedInBubbleBlock
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
import io.element.android.features.messages.impl.timeline.model.AggregatedReactionSender import io.element.android.features.messages.impl.timeline.model.AggregatedReactionSender
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
import io.element.android.libraries.core.bool.orTrue import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -36,12 +40,14 @@ import javax.inject.Inject
class TimelineItemEventFactory @Inject constructor( class TimelineItemEventFactory @Inject constructor(
private val contentFactory: TimelineItemContentFactory, private val contentFactory: TimelineItemContentFactory,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
) { ) {
suspend fun create( suspend fun create(
currentTimelineItem: MatrixTimelineItem.Event, currentTimelineItem: MatrixTimelineItem.Event,
index: Int, index: Int,
timelineItems: List<MatrixTimelineItem>, timelineItems: List<MatrixTimelineItem>,
roomMembers: List<RoomMember>,
): TimelineItem.Event { ): TimelineItem.Event {
val currentSender = currentTimelineItem.event.sender val currentSender = currentTimelineItem.event.sender
val groupPosition = val groupPosition =
@ -84,6 +90,7 @@ class TimelineItemEventFactory @Inject constructor(
sentTime = sentTime, sentTime = sentTime,
groupPosition = groupPosition, groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState(), reactionsState = currentTimelineItem.computeReactionsState(),
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
localSendState = currentTimelineItem.event.localSendState, localSendState = currentTimelineItem.event.localSendState,
inReplyTo = currentTimelineItem.event.inReplyTo(), inReplyTo = currentTimelineItem.event.inReplyTo(),
isThreaded = currentTimelineItem.event.isThreaded(), isThreaded = currentTimelineItem.event.isThreaded(),
@ -102,7 +109,7 @@ class TimelineItemEventFactory @Inject constructor(
key = reaction.key, key = reaction.key,
currentUserId = matrixClient.sessionId, currentUserId = matrixClient.sessionId,
senders = reaction.senders senders = reaction.senders
.sortedByDescending{ it.timestamp } .sortedByDescending { it.timestamp }
.map { .map {
val date = Date(it.timestamp) val date = Date(it.timestamp)
AggregatedReactionSender( AggregatedReactionSender(
@ -124,6 +131,27 @@ class TimelineItemEventFactory @Inject constructor(
return TimelineItemReactions(aggregatedReactions.toImmutableList()) return TimelineItemReactions(aggregatedReactions.toImmutableList())
} }
private fun MatrixTimelineItem.Event.computeReadReceiptState(
roomMembers: List<RoomMember>,
): TimelineItemReadReceipts {
return TimelineItemReadReceipts(
receipts = event.receipts
.map { receipt ->
val roomMember = roomMembers.find { it.userId == receipt.userId }
ReadReceiptData(
avatarData = AvatarData(
id = receipt.userId.value,
name = roomMember?.displayName,
url = roomMember?.avatarUrl,
size = AvatarSize.TimelineReadReceipt,
),
formattedDate = lastMessageTimestampFormatter.format(receipt.timestamp)
)
}
.toImmutableList()
)
}
private fun computeGroupPosition( private fun computeGroupPosition(
currentTimelineItem: MatrixTimelineItem.Event, currentTimelineItem: MatrixTimelineItem.Event,
timelineItems: List<MatrixTimelineItem>, timelineItems: List<MatrixTimelineItem>,

View file

@ -65,6 +65,7 @@ sealed interface TimelineItem {
val isMine: Boolean = false, val isMine: Boolean = false,
val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
val reactionsState: TimelineItemReactions, val reactionsState: TimelineItemReactions,
val readReceiptState: TimelineItemReadReceipts,
val localSendState: LocalEventSendState?, val localSendState: LocalEventSendState?,
val inReplyTo: InReplyTo?, val inReplyTo: InReplyTo?,
val isThreaded: Boolean, val isThreaded: Boolean,

View file

@ -0,0 +1,29 @@
/*
* 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.designsystem.components.avatar.AvatarData
import kotlinx.collections.immutable.ImmutableList
data class TimelineItemReadReceipts(
val receipts: ImmutableList<ReadReceiptData>,
)
data class ReadReceiptData(
val avatarData: AvatarData,
val formattedDate: String,
)

View file

@ -35,6 +35,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetPresenter
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
@ -654,10 +655,12 @@ class MessagesPresenterTest {
analyticsService = analyticsService, analyticsService = analyticsService,
encryptionService = FakeEncryptionService(), encryptionService = FakeEncryptionService(),
verificationService = FakeSessionVerificationService(), verificationService = FakeSessionVerificationService(),
featureFlagService = FakeFeatureFlagService(),
redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(), redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
) )
val preferencesStore = InMemoryPreferencesStore(isRichTextEditorEnabled = true) val preferencesStore = InMemoryPreferencesStore(isRichTextEditorEnabled = true)
val actionListPresenter = ActionListPresenter(preferencesStore = preferencesStore) val actionListPresenter = ActionListPresenter(preferencesStore = preferencesStore)
val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter()
val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider()) val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom) val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom) val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom)
@ -670,6 +673,7 @@ class MessagesPresenterTest {
customReactionPresenter = customReactionPresenter, customReactionPresenter = customReactionPresenter,
reactionSummaryPresenter = reactionSummaryPresenter, reactionSummaryPresenter = reactionSummaryPresenter,
retrySendMenuPresenter = retrySendMenuPresenter, retrySendMenuPresenter = retrySendMenuPresenter,
readReceiptBottomSheetPresenter = readReceiptBottomSheetPresenter,
networkMonitor = FakeNetworkMonitor(), networkMonitor = FakeNetworkMonitor(),
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),
messageSummaryFormatter = FakeMessageSummaryFormatter(), messageSummaryFormatter = FakeMessageSummaryFormatter(),

View file

@ -17,7 +17,9 @@
package io.element.android.features.messages.fixtures package io.element.android.features.messages.fixtures
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
@ -31,6 +33,7 @@ import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_USER_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.A_USER_NAME
import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo
import kotlinx.collections.immutable.toImmutableList
internal fun aMessageEvent( internal fun aMessageEvent(
eventId: EventId? = AN_EVENT_ID, eventId: EventId? = AN_EVENT_ID,
@ -50,6 +53,7 @@ internal fun aMessageEvent(
sentTime = "", sentTime = "",
isMine = isMine, isMine = isMine,
reactionsState = aTimelineItemReactions(count = 0), reactionsState = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
localSendState = sendState, localSendState = sendState,
inReplyTo = inReplyTo, inReplyTo = inReplyTo,
debugInfo = debugInfo, debugInfo = debugInfo,

View file

@ -35,6 +35,7 @@ import io.element.android.features.messages.impl.timeline.groups.TimelineItemGro
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractorWithoutValidation import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractorWithoutValidation
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
@ -65,6 +66,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(), failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
), ),
matrixClient = matrixClient, matrixClient = matrixClient,
lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(),
), ),
virtualItemFactory = TimelineItemVirtualFactory( virtualItemFactory = TimelineItemVirtualFactory(
daySeparatorFactory = TimelineItemDaySeparatorFactory( daySeparatorFactory = TimelineItemDaySeparatorFactory(

View file

@ -29,6 +29,7 @@ import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.session.SessionState import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
import io.element.android.features.messages.voicemessages.timeline.FakeRedactedVoiceMessageManager import io.element.android.features.messages.voicemessages.timeline.FakeRedactedVoiceMessageManager
import io.element.android.features.messages.voicemessages.timeline.aRedactedMatrixTimeline import io.element.android.features.messages.voicemessages.timeline.aRedactedMatrixTimeline
@ -347,6 +348,7 @@ class TimelinePresenterTest {
analyticsService = FakeAnalyticsService(), analyticsService = FakeAnalyticsService(),
encryptionService = FakeEncryptionService(), encryptionService = FakeEncryptionService(),
verificationService = FakeSessionVerificationService(), verificationService = FakeSessionVerificationService(),
featureFlagService = FakeFeatureFlagService(),
redactedVoiceMessageManager = redactedVoiceMessageManager, redactedVoiceMessageManager = redactedVoiceMessageManager,
) )
} }
@ -363,6 +365,7 @@ class TimelinePresenterTest {
analyticsService = analyticsService, analyticsService = analyticsService,
encryptionService = FakeEncryptionService(), encryptionService = FakeEncryptionService(),
verificationService = FakeSessionVerificationService(), verificationService = FakeSessionVerificationService(),
featureFlagService = FakeFeatureFlagService(),
redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(), redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
) )
} }

View file

@ -0,0 +1,65 @@
/*
* 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.receipt.bottomsheet
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.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetPresenter
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ReadReceiptBottomSheetPresenterTests {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - handle event selected`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent()
initialState.eventSink(ReadReceiptBottomSheetEvents.EventSelected(selectedEvent))
assertThat(awaitItem().selectedEvent).isSameInstanceAs(selectedEvent)
}
}
@Test
fun `present - handle dismiss`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent()
initialState.eventSink(ReadReceiptBottomSheetEvents.EventSelected(selectedEvent))
skipItems(1)
initialState.eventSink(ReadReceiptBottomSheetEvents.Dismiss)
assertThat(awaitItem().selectedEvent).isNull()
}
}
private fun createPresenter() = ReadReceiptBottomSheetPresenter()
}

View file

@ -21,7 +21,9 @@ import io.element.android.features.messages.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.impl.timeline.groups.computeGroupIdWith import io.element.android.features.messages.impl.timeline.groups.computeGroupIdWith
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.libraries.designsystem.components.avatar.anAvatarData import io.element.android.libraries.designsystem.components.avatar.anAvatarData
@ -42,6 +44,7 @@ class TimelineItemGrouperTest {
senderDisplayName = "", senderDisplayName = "",
content = TimelineItemStateEventContent(body = "a state event"), content = TimelineItemStateEventContent(body = "a state event"),
reactionsState = aTimelineItemReactions(count = 0), reactionsState = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
localSendState = LocalEventSendState.Sent(AN_EVENT_ID), localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
inReplyTo = null, inReplyTo = null,
isThreaded = false, isThreaded = false,

View file

@ -89,6 +89,7 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
isRemote = false, isRemote = false,
localSendState = null, localSendState = null,
reactions = listOf(), reactions = listOf(),
receipts = listOf(),
sender = A_USER_ID, sender = A_USER_ID,
senderProfile = ProfileTimelineDetails.Unavailable, senderProfile = ProfileTimelineDetails.Unavailable,
timestamp = 9442, timestamp = 9442,

View file

@ -58,3 +58,7 @@ data class AvatarData(
.uppercase() .uppercase()
} }
} }
fun AvatarData.getBestName(): String {
return name?.takeIf { it.isNotEmpty() } ?: id
}

View file

@ -37,6 +37,9 @@ enum class AvatarSize(val dp: Dp) {
TimelineRoom(32.dp), TimelineRoom(32.dp),
TimelineSender(32.dp), TimelineSender(32.dp),
TimelineReadReceipt(16.dp),
ReadReceiptList(32.dp),
MessageActionSender(32.dp), MessageActionSender(32.dp),

View file

@ -0,0 +1,29 @@
<!--
~ 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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<group>
<clip-path
android:pathData="M0,0h16v16h-16z"/>
<path
android:pathData="M8.006,16C6.905,16 5.868,15.792 4.896,15.375C3.924,14.958 3.073,14.385 2.344,13.656C1.615,12.927 1.042,12.077 0.625,11.105C0.208,10.133 0,9.095 0,7.99C0,6.886 0.208,5.851 0.625,4.885C1.042,3.92 1.615,3.073 2.344,2.344C3.073,1.615 3.923,1.042 4.895,0.625C5.867,0.208 6.905,0 8.01,0C9.114,0 10.149,0.208 11.115,0.625C12.08,1.042 12.927,1.615 13.656,2.344C14.385,3.073 14.958,3.922 15.375,4.89C15.792,5.858 16,6.893 16,7.994C16,9.095 15.792,10.132 15.375,11.104C14.958,12.076 14.385,12.927 13.656,13.656C12.927,14.385 12.078,14.958 11.11,15.375C10.142,15.792 9.107,16 8.006,16ZM8,14.5C9.806,14.5 11.34,13.868 12.604,12.604C13.868,11.34 14.5,9.806 14.5,8C14.5,6.194 13.868,4.66 12.604,3.396C11.34,2.132 9.806,1.5 8,1.5C6.194,1.5 4.66,2.132 3.396,3.396C2.132,4.66 1.5,6.194 1.5,8C1.5,9.806 2.132,11.34 3.396,12.604C4.66,13.868 6.194,14.5 8,14.5Z"
android:fillColor="@android:color/white"/>
</group>
</vector>

View file

@ -0,0 +1,29 @@
<!--
~ 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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<group>
<clip-path
android:pathData="M0,0h16v16h-16z"/>
<path
android:pathData="M6.938,8.875L5.688,7.646C5.535,7.493 5.361,7.417 5.167,7.417C4.972,7.417 4.799,7.493 4.646,7.646C4.493,7.799 4.417,7.976 4.417,8.177C4.417,8.378 4.493,8.556 4.646,8.708L6.417,10.479C6.569,10.632 6.743,10.708 6.938,10.708C7.132,10.708 7.306,10.632 7.458,10.479L11.354,6.583C11.507,6.431 11.583,6.253 11.583,6.052C11.583,5.851 11.507,5.674 11.354,5.521C11.201,5.368 11.028,5.292 10.833,5.292C10.639,5.292 10.465,5.368 10.313,5.521L6.938,8.875ZM8,16C6.903,16 5.868,15.792 4.896,15.375C3.924,14.958 3.073,14.385 2.344,13.656C1.615,12.927 1.042,12.076 0.625,11.104C0.208,10.132 0,9.097 0,8C0,6.889 0.208,5.851 0.625,4.885C1.042,3.92 1.615,3.073 2.344,2.344C3.073,1.615 3.924,1.042 4.896,0.625C5.868,0.208 6.903,0 8,0C9.111,0 10.149,0.208 11.115,0.625C12.08,1.042 12.927,1.615 13.656,2.344C14.385,3.073 14.958,3.92 15.375,4.885C15.792,5.851 16,6.889 16,8C16,9.097 15.792,10.132 15.375,11.104C14.958,12.076 14.385,12.927 13.656,13.656C12.927,14.385 12.08,14.958 11.115,15.375C10.149,15.792 9.111,16 8,16ZM8,14.5C9.806,14.5 11.34,13.868 12.604,12.604C13.868,11.34 14.5,9.806 14.5,8C14.5,6.194 13.868,4.66 12.604,3.396C11.34,2.132 9.806,1.5 8,1.5C6.194,1.5 4.66,2.132 3.396,3.396C2.132,4.66 1.5,6.194 1.5,8C1.5,9.806 2.132,11.34 3.396,12.604C4.66,13.868 6.194,14.5 8,14.5Z"
android:fillColor="@android:color/white"/>
</group>
</vector>

View file

@ -66,5 +66,11 @@ enum class FeatureFlags(
title = "Chat backup", title = "Chat backup",
description = "Allow access to backup and restore chat history settings", description = "Allow access to backup and restore chat history settings",
defaultValue = false, defaultValue = false,
) ),
ReadReceipts(
key = "feature.readreceipts",
title = "Show read receipts",
description = null,
defaultValue = false,
),
} }

View file

@ -41,6 +41,7 @@ class StaticFeatureFlagProvider @Inject constructor() :
FeatureFlags.PinUnlock -> true FeatureFlags.PinUnlock -> true
FeatureFlags.Mentions -> false FeatureFlags.Mentions -> false
FeatureFlags.SecureStorage -> false FeatureFlags.SecureStorage -> false
FeatureFlags.ReadReceipts -> false
} }
} else { } else {
false false

View file

@ -30,6 +30,7 @@ data class EventTimelineItem(
val isRemote: Boolean, val isRemote: Boolean,
val localSendState: LocalEventSendState?, val localSendState: LocalEventSendState?,
val reactions: List<EventReaction>, val reactions: List<EventReaction>,
val receipts: List<Receipt>,
val sender: UserId, val sender: UserId,
val senderProfile: ProfileTimelineDetails, val senderProfile: ProfileTimelineDetails,
val timestamp: Long, val timestamp: Long,

View file

@ -0,0 +1,24 @@
/*
* 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
data class Receipt(
val userId: UserId,
val timestamp: Long,
)

View file

@ -20,18 +20,20 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import org.matrix.rustcomponents.sdk.Reaction import org.matrix.rustcomponents.sdk.Reaction
import org.matrix.rustcomponents.sdk.EventItemOrigin as RustEventItemOrigin import org.matrix.rustcomponents.sdk.EventItemOrigin as RustEventItemOrigin
import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState
import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem
import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo as RustEventTimelineItemDebugInfo import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo as RustEventTimelineItemDebugInfo
import org.matrix.rustcomponents.sdk.ProfileDetails as RustProfileDetails import org.matrix.rustcomponents.sdk.ProfileDetails as RustProfileDetails
import org.matrix.rustcomponents.sdk.Receipt as RustReceipt
class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper()) { class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper()) {
@ -45,6 +47,7 @@ class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMap
isRemote = it.isRemote(), isRemote = it.isRemote(),
localSendState = it.localSendState()?.map(), localSendState = it.localSendState()?.map(),
reactions = it.reactions().map(), reactions = it.reactions().map(),
receipts = it.readReceipts().map(),
sender = UserId(it.sender()), sender = UserId(it.sender()),
senderProfile = it.senderProfile().map(), senderProfile = it.senderProfile().map(),
timestamp = it.timestamp().toLong(), timestamp = it.timestamp().toLong(),
@ -92,6 +95,15 @@ private fun List<Reaction>?.map(): List<EventReaction> {
} ?: emptyList() } ?: emptyList()
} }
private fun Map<String, RustReceipt>.map(): List<Receipt> {
return map {
Receipt(
userId = UserId(it.key),
timestamp = it.value.timestamp?.toLong() ?: 0
)
}.sortedByDescending { it.timestamp }
}
private fun RustEventTimelineItemDebugInfo.map(): TimelineItemDebugInfo { private fun RustEventTimelineItemDebugInfo.map(): TimelineItemDebugInfo {
return TimelineItemDebugInfo( return TimelineItemDebugInfo(
model = model, model = model,

View file

@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
@ -107,6 +108,7 @@ fun anEventTimelineItem(
isRemote: Boolean = false, isRemote: Boolean = false,
localSendState: LocalEventSendState? = null, localSendState: LocalEventSendState? = null,
reactions: List<EventReaction> = emptyList(), reactions: List<EventReaction> = emptyList(),
receipts: List<Receipt> = emptyList(),
sender: UserId = A_USER_ID, sender: UserId = A_USER_ID,
senderProfile: ProfileTimelineDetails = aProfileTimelineDetails(), senderProfile: ProfileTimelineDetails = aProfileTimelineDetails(),
timestamp: Long = 0L, timestamp: Long = 0L,
@ -121,6 +123,7 @@ fun anEventTimelineItem(
isRemote = isRemote, isRemote = isRemote,
localSendState = localSendState, localSendState = localSendState,
reactions = reactions, reactions = reactions,
receipts = receipts,
sender = sender, sender = sender,
senderProfile = senderProfile, senderProfile = senderProfile,
timestamp = timestamp, timestamp = timestamp,

View file

@ -31,11 +31,13 @@ fun MatrixUserRow(
matrixUser: MatrixUser, matrixUser: MatrixUser,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
avatarSize: AvatarSize = AvatarSize.UserListItem, avatarSize: AvatarSize = AvatarSize.UserListItem,
trailingContent: @Composable (() -> Unit)? = null,
) = UserRow( ) = UserRow(
avatarData = matrixUser.getAvatarData(avatarSize), avatarData = matrixUser.getAvatarData(avatarSize),
name = matrixUser.getBestName(), name = matrixUser.getBestName(),
subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value, subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value,
modifier = modifier, modifier = modifier,
trailingContent,
) )
@PreviewsDayNight @PreviewsDayNight

View file

@ -38,6 +38,7 @@ internal fun UserRow(
name: String, name: String,
subtext: String?, subtext: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
trailingContent: @Composable (() -> Unit)? = null,
) { ) {
Row( Row(
modifier = modifier modifier = modifier
@ -49,7 +50,8 @@ internal fun UserRow(
Avatar(avatarData) Avatar(avatarData)
Column( Column(
modifier = Modifier modifier = Modifier
.padding(start = 12.dp), .padding(start = 12.dp)
.weight(1f),
) { ) {
// Name // Name
Text( Text(
@ -70,5 +72,6 @@ internal fun UserRow(
) )
} }
} }
trailingContent?.invoke()
} }
} }

View file

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

View file

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

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9779c9c980c32b3e8ede34e5f9e7beeca6e0000fc77e1fc1cf0a8c0eb1da0be7
size 17760

View file

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

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:97fd46b3b79c90a4104c2fd4e9f5667abf67d998d9f7136b9c18da920af4cb99
size 27836

View file

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

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:63620fd5bfea92c632a0a4b1d88eabd51a973e7e4281af2b662b8955cc209e0a
size 6549

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6a8a1900a56b87bb96135cbbfe92e504b486775b5b0ba02bb6626a351081ef37
size 12272

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:63e229114afc8317c1690f817e4ba4a275c581c9f107c75e88c99728c6698219
size 17755

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:175c2bed417fdfd3c80b8ce3079675decc3a27cd8df6d06c00db0015d193cb4e
size 22808

View file

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

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:21078a2cfa738bc3a2a7f36c52ea7c6ff9c83a9885f6059fe472121f08a08c05
size 33681

View file

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

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:379c809e565791ae199367bf9e7fe5d2e7767bbd5f33f51309d0caacc976dd5f
size 5356

View file

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

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:61d4e36b624371171b6ee1909318dcc2ba96c55b42a92fb880b25abbbff48645
size 5080

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:821606acb7dcd9bdcad17bff0c33268f2ab22569e846e610c4401d852e17be2e
size 5628

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3552965164107b4a6881a5834bef98dfba2494c7c2186f6a6b8315a64c0c0d6b
size 6233

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8fedc10863be4b1777614ecf777ee24b43a4d9fb48059833cd08a122e6cec221
size 6410

View file

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

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3663e96a724a0a75e693c4c516e6adedc4c20967604c6931142972fcee8fe488
size 5475

View file

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

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3663e96a724a0a75e693c4c516e6adedc4c20967604c6931142972fcee8fe488
size 5475

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:91961cd20e51f2065a48720fe3a5067d3f640a7e87d5f369eb45842ba1f256f7
size 6811

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:748a2562d8c3e9b5e4def3b8ea3c782868e56f449de1b1ddb4bc36cd84945318 oid sha256:a5953d21cc18cb10614361f7343fed9375ca851677b4a380e97e86851a082c3b
size 20642 size 17118

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9a6ce0c3b6fe3ac503ee7019dee234a9f96552e1106fe6858030a7c70c506d4f oid sha256:d8134edc79b20771f89e8b15eed3b3660e46ab6f3805130252f32d2fb6b26cb6
size 19869 size 16745

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b5af89d5988d168ee2cbd30e3ba6447f5cd1399d35a2ca04c7348e74cb623f37 oid sha256:9e6022f750c6e26d822d4ffd1172cd8c34f7afe0188cce2da9f72d3dd8e4f45a
size 22501 size 18030

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0c49437c9f943cf044ec64a0ecab2b70d1fa811a34aff4baa8ed0ed846a4f5c5 oid sha256:d5901d9bf468a0ca5fc7c3d6d308898b802b57967afe4d61c814f86e2194ee16
size 18595 size 17829

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:063cdf0bbdfc846d3040af143f79a0b6eb325beab62cba5657236d028db6bc26 oid sha256:6aa7aa8f18b1a7d3a68c65e5275a69d49138e51046d913686f111f809cb2235f
size 17267 size 17054

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8118e782f4ec7b67b6aa148f1bb31c9d30f03f72a7abfc73dc8d5094a5a7545c oid sha256:e0dd5b1c404dd33a70083e7d775bf4c89e8eb1823e72711e8aa6c4947fb49b6e
size 21791 size 19756

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:032ed67865d3ad800371e193ea922589f27cf03697e3c91c8d698957befa8d42 oid sha256:748a2562d8c3e9b5e4def3b8ea3c782868e56f449de1b1ddb4bc36cd84945318
size 14590 size 20642

Some files were not shown because too many files have changed in this diff Show more