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 004804a7c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
118 changed files with 1159 additions and 56 deletions

View file

@ -33,6 +33,7 @@ dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
api(projects.features.messages.api)
implementation(projects.appconfig)
implementation(projects.features.call)
implementation(projects.features.location.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.components.customreaction.CustomReactionPresenter
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.model.TimelineItem
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 reactionSummaryPresenter: ReactionSummaryPresenter,
private val retrySendMenuPresenter: RetrySendMenuPresenter,
private val readReceiptBottomSheetPresenter: ReadReceiptBottomSheetPresenter,
private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher,
private val messageSummaryFormatter: MessageSummaryFormatter,
@ -124,6 +126,7 @@ class MessagesPresenter @AssistedInject constructor(
val customReactionState = customReactionPresenter.present()
val reactionSummaryState = reactionSummaryPresenter.present()
val retryState = retrySendMenuPresenter.present()
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
@ -201,6 +204,7 @@ class MessagesPresenter @AssistedInject constructor(
customReactionState = customReactionState,
reactionSummaryState = reactionSummaryState,
retrySendMenuState = retryState,
readReceiptBottomSheetState = readReceiptBottomSheetState,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
snackbarMessage = snackbarMessage,
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.components.customreaction.CustomReactionState
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.voicemessages.composer.VoiceMessageComposerState
import io.element.android.libraries.architecture.Async
@ -43,6 +44,7 @@ data class MessagesState(
val customReactionState: CustomReactionState,
val reactionSummaryState: ReactionSummaryState,
val retrySendMenuState: RetrySendMenuState,
val readReceiptBottomSheetState: ReadReceiptBottomSheetState,
val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?,
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.components.customreaction.CustomReactionState
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.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
@ -96,6 +97,10 @@ fun aMessagesState() = MessagesState(
selectedEvent = null,
eventSink = {},
),
readReceiptBottomSheetState = ReadReceiptBottomSheetState(
selectedEvent = null,
eventSink = {},
),
actionListState = anActionListState(),
customReactionState = CustomReactionState(
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.reactionsummary.ReactionSummaryEvents
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.RetrySendMessageMenu
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@ -212,6 +214,9 @@ fun MessagesView(
onReactionClicked = ::onEmojiReactionClicked,
onReactionLongClicked = ::onEmojiReactionLongClicked,
onMoreReactionsClicked = ::onMoreReactionsClicked,
onReadReceiptClick = { event ->
state.readReceiptBottomSheetState.eventSink(ReadReceiptBottomSheetEvents.EventSelected(event))
},
onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked,
onSwipeToReply = { targetEvent ->
@ -246,13 +251,12 @@ fun MessagesView(
)
ReactionSummaryView(state = state.reactionSummaryState)
RetrySendMessageMenu(
state = state.retrySendMenuState
)
ReinviteDialog(
state = state
RetrySendMessageMenu(state = state.retrySendMenuState)
ReadReceiptBottomSheet(
state = state.readReceiptBottomSheetState,
onUserDataClicked = onUserDataClicked,
)
ReinviteDialog(state = state)
// 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.
@ -310,6 +314,7 @@ private fun MessagesViewContent(
onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClicked: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onSendLocationClicked: () -> Unit,
@ -381,6 +386,7 @@ private fun MessagesViewContent(
onReactionClicked = onReactionClicked,
onReactionLongClicked = onReactionLongClicked,
onMoreReactionsClicked = onMoreReactionsClicked,
onReadReceiptClick = onReadReceiptClick,
onSwipeToReply = onSwipeToReply,
)
},
@ -406,7 +412,8 @@ private fun MessagesViewComposerBottomSheetContents(
if (state.userHasPermissionToSendMessage) {
Column(modifier = modifier.fillMaxWidth()) {
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
.nestedScroll(object : NestedScrollConnection {
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.libraries.architecture.Presenter
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.encryption.BackupState
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.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.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
@ -64,6 +67,7 @@ class TimelinePresenter @Inject constructor(
private val analyticsService: AnalyticsService,
private val verificationService: SessionVerificationService,
private val encryptionService: EncryptionService,
private val featureFlagService: FeatureFlagService,
private val redactedVoiceMessageManager: RedactedVoiceMessageManager,
) : 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) {
when (event) {
TimelineEvents.LoadMore -> localScope.paginateBackwards()
@ -138,7 +145,17 @@ class TimelinePresenter @Inject constructor(
LaunchedEffect(Unit) {
timeline
.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 ->
if (timelineItems.isEmpty()) {
paginateBackwards()
@ -153,6 +170,7 @@ class TimelinePresenter @Inject constructor(
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
paginationState = paginationState,
timelineItems = timelineItems,
showReadReceipts = readReceiptsEnabled,
hasNewItems = hasNewItems.value,
sessionState = sessionState,
eventSink = ::handleEvents

View file

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

View file

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

View file

@ -92,6 +92,7 @@ fun TimelineView(
onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit,
onReactionLongClicked: (emoji: String, TimelineItem.Event) -> Unit,
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier,
) {
fun onReachedLoadMore() {
@ -126,6 +127,9 @@ fun TimelineView(
) { timelineItem ->
TimelineItemRow(
timelineItem = timelineItem,
showReadReceipts = state.showReadReceipts,
isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true
&& state.timelineItems.first().identifier() == timelineItem.identifier(),
highlightedItem = state.highlightedEventId?.value,
userHasPermissionToSendMessage = state.userHasPermissionToSendMessage,
onClick = onMessageClicked,
@ -135,6 +139,7 @@ fun TimelineView(
onReactionClick = onReactionClicked,
onReactionLongClick = onReactionLongClicked,
onMoreReactionsClick = onMoreReactionsClicked,
onReadReceiptClick = onReadReceiptClick,
onTimestampClicked = onTimestampClicked,
sessionState = state.sessionState,
eventSink = state.eventSink,
@ -169,6 +174,8 @@ fun TimelineView(
@Composable
private fun TimelineItemRow(
timelineItem: TimelineItem,
showReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
highlightedItem: String?,
userHasPermissionToSendMessage: Boolean,
sessionState: SessionState,
@ -179,6 +186,7 @@ private fun TimelineItemRow(
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
onReadReceiptClick: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onSwipeToReply: (TimelineItem.Event) -> Unit,
eventSink: (TimelineEvents) -> Unit,
@ -205,6 +213,8 @@ private fun TimelineItemRow(
} else {
TimelineItemEventRow(
event = timelineItem,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = highlightedItem == timelineItem.identifier(),
canReply = userHasPermissionToSendMessage && timelineItem.content.canBeRepliedTo(),
onClick = { onClick(timelineItem) },
@ -214,6 +224,7 @@ private fun TimelineItemRow(
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
onTimestampClicked = onTimestampClicked,
onSwipeToReply = { onSwipeToReply(timelineItem) },
eventSink = eventSink,
@ -244,6 +255,8 @@ private fun TimelineItemRow(
timelineItem.events.forEach { subGroupEvent ->
TimelineItemRow(
timelineItem = subGroupEvent,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
highlightedItem = highlightedItem,
sessionState = sessionState,
userHasPermissionToSendMessage = false,
@ -255,6 +268,7 @@ private fun TimelineItemRow(
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
eventSink = eventSink,
onSwipeToReply = {},
)
@ -362,6 +376,7 @@ internal fun TimelineViewPreview(
onReactionLongClicked = { _, _ -> },
onMoreReactionsClicked = {},
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.components.event.TimelineItemEventContentView
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.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
@ -114,6 +116,8 @@ import kotlin.math.roundToInt
@Composable
fun TimelineItemEventRow(
event: TimelineItem.Event,
showReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
isHighlighted: Boolean,
canReply: Boolean,
onClick: () -> Unit,
@ -124,6 +128,7 @@ fun TimelineItemEventRow(
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
onReactionLongClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit,
onReadReceiptClick: (event: TimelineItem.Event) -> Unit,
onSwipeToReply: () -> Unit,
eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier
@ -173,6 +178,8 @@ fun TimelineItemEventRow(
state = state.draggableState,
),
event = event,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = isHighlighted,
interactionSource = interactionSource,
onClick = onClick,
@ -183,6 +190,7 @@ fun TimelineItemEventRow(
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) },
onReadReceiptsClicked = { onReadReceiptClick(event) },
eventSink = eventSink,
)
}
@ -190,6 +198,8 @@ fun TimelineItemEventRow(
} else {
TimelineItemEventRowContent(
event = event,
showReadReceipts = showReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = isHighlighted,
interactionSource = interactionSource,
onClick = onClick,
@ -200,6 +210,7 @@ fun TimelineItemEventRow(
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) },
onReadReceiptsClicked = { onReadReceiptClick(event) },
eventSink = eventSink,
)
}
@ -232,6 +243,8 @@ private fun SwipeSensitivity(
@Composable
private fun TimelineItemEventRowContent(
event: TimelineItem.Event,
showReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
isHighlighted: Boolean,
interactionSource: MutableInteractionSource,
onClick: () -> Unit,
@ -240,6 +253,7 @@ private fun TimelineItemEventRowContent(
inReplyToClicked: () -> Unit,
onUserDataClicked: () -> Unit,
onReactionClicked: (emoji: String) -> Unit,
onReadReceiptsClicked: () -> Unit,
onReactionLongClicked: (emoji: String) -> Unit,
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
eventSink: (TimelineEvents) -> Unit,
@ -256,7 +270,12 @@ private fun TimelineItemEventRowContent(
.wrapContentHeight()
.fillMaxWidth(),
) {
val (sender, message, reactions) = createRefs()
val (
sender,
message,
reactions,
readReceipts,
) = createRefs()
// Sender
val avatarStrokeSize = 3.dp
@ -322,6 +341,25 @@ private fun TimelineItemEventRowContent(
.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,
),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false,
canReply = true,
onClick = {},
@ -667,6 +707,7 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onTimestampClicked = {},
onSwipeToReply = {},
eventSink = {},
@ -679,6 +720,8 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
),
groupPosition = TimelineItemGroupPosition.Last,
),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false,
canReply = true,
onClick = {},
@ -688,6 +731,7 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onTimestampClicked = {},
onSwipeToReply = {},
eventSink = {},
@ -718,6 +762,8 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
inReplyTo = aInReplyToReady(replyContent),
groupPosition = TimelineItemGroupPosition.First,
),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false,
canReply = true,
onClick = {},
@ -727,6 +773,7 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onTimestampClicked = {},
onSwipeToReply = {},
eventSink = {},
@ -741,6 +788,8 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
isThreaded = true,
groupPosition = TimelineItemGroupPosition.Last,
),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false,
canReply = true,
onClick = {},
@ -750,6 +799,7 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onTimestampClicked = {},
onSwipeToReply = {},
eventSink = {},
@ -792,6 +842,8 @@ internal fun TimelineItemEventRowTimestampPreview(
reactionsState = aTimelineItemReactions(count = 0),
senderDisplayName = if (useDocument) "Document case" else "Text case",
),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false,
canReply = true,
onClick = {},
@ -801,6 +853,7 @@ internal fun TimelineItemEventRowTimestampPreview(
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onTimestampClicked = {},
onSwipeToReply = {},
eventSink = {},
@ -824,6 +877,8 @@ internal fun TimelineItemEventRowWithManyReactionsPreview() = ElementPreview {
),
timelineItemReactions = aTimelineItemReactions(count = 20),
),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false,
canReply = true,
onClick = {},
@ -833,6 +888,7 @@ internal fun TimelineItemEventRowWithManyReactionsPreview() = ElementPreview {
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onSwipeToReply = {},
onTimestampClicked = {},
eventSink = {},
@ -849,6 +905,8 @@ internal fun TimelineItemEventRowLongSenderNamePreview() = ElementPreviewLight {
event = aTimelineItemEvent(
senderDisplayName = "a long sender display name to test single line and ellipsis at the end of the line",
),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false,
canReply = true,
onClick = {},
@ -858,6 +916,7 @@ internal fun TimelineItemEventRowLongSenderNamePreview() = ElementPreviewLight {
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onSwipeToReply = {},
onTimestampClicked = {},
eventSink = {},
@ -870,6 +929,8 @@ internal fun TimelineItemEventRowLongSenderNamePreview() = ElementPreviewLight {
internal fun TimelineItemEventTimestampBelowPreview() = ElementPreviewLight {
TimelineItemEventRow(
event = aTimelineItemEvent(content = aTimelineItemPollContent()),
showReadReceipts = false,
isLastOutgoingMessage = false,
isHighlighted = false,
canReply = true,
onClick = {},
@ -879,6 +940,7 @@ internal fun TimelineItemEventTimestampBelowPreview() = ElementPreviewLight {
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onSwipeToReply = {},
onTimestampClicked = {},
eventSink = {},

View file

@ -69,7 +69,7 @@ private fun TimelineItemReactionsView(
onToggleExpandClick: () -> Unit,
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.
val currentLayout = LocalLayoutDirection.current
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.MutableListDiffCache
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 kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -66,19 +67,23 @@ class TimelineItemsFactory @Inject constructor(
suspend fun replaceWith(
timelineItems: List<MatrixTimelineItem>,
roomMembers: List<RoomMember>,
) = withContext(dispatchers.computation) {
lock.withLock {
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>()
for (index in diffCache.indices().reversed()) {
val cacheItem = diffCache.get(index)
if (cacheItem == null) {
buildAndCacheItem(timelineItems, index)?.also { timelineItemState ->
buildAndCacheItem(timelineItems, index, roomMembers)?.also { timelineItemState ->
newTimelineItemStates.add(timelineItemState)
}
} else {
@ -91,11 +96,12 @@ class TimelineItemsFactory @Inject constructor(
private suspend fun buildAndCacheItem(
timelineItems: List<MatrixTimelineItem>,
index: Int
index: Int,
roomMembers: List<RoomMember>,
): TimelineItem? {
val timelineItemState =
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)
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.model.AggregatedReaction
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.TimelineItemGroupPosition
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.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
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.item.event.ProfileTimelineDetails
import kotlinx.collections.immutable.toImmutableList
@ -36,12 +40,14 @@ import javax.inject.Inject
class TimelineItemEventFactory @Inject constructor(
private val contentFactory: TimelineItemContentFactory,
private val matrixClient: MatrixClient,
private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
) {
suspend fun create(
currentTimelineItem: MatrixTimelineItem.Event,
index: Int,
timelineItems: List<MatrixTimelineItem>,
roomMembers: List<RoomMember>,
): TimelineItem.Event {
val currentSender = currentTimelineItem.event.sender
val groupPosition =
@ -84,6 +90,7 @@ class TimelineItemEventFactory @Inject constructor(
sentTime = sentTime,
groupPosition = groupPosition,
reactionsState = currentTimelineItem.computeReactionsState(),
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
localSendState = currentTimelineItem.event.localSendState,
inReplyTo = currentTimelineItem.event.inReplyTo(),
isThreaded = currentTimelineItem.event.isThreaded(),
@ -102,7 +109,7 @@ class TimelineItemEventFactory @Inject constructor(
key = reaction.key,
currentUserId = matrixClient.sessionId,
senders = reaction.senders
.sortedByDescending{ it.timestamp }
.sortedByDescending { it.timestamp }
.map {
val date = Date(it.timestamp)
AggregatedReactionSender(
@ -124,6 +131,27 @@ class TimelineItemEventFactory @Inject constructor(
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(
currentTimelineItem: MatrixTimelineItem.Event,
timelineItems: List<MatrixTimelineItem>,

View file

@ -65,6 +65,7 @@ sealed interface TimelineItem {
val isMine: Boolean = false,
val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
val reactionsState: TimelineItemReactions,
val readReceiptState: TimelineItemReadReceipts,
val localSendState: LocalEventSendState?,
val inReplyTo: InReplyTo?,
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.components.customreaction.CustomReactionPresenter
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.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
@ -654,10 +655,12 @@ class MessagesPresenterTest {
analyticsService = analyticsService,
encryptionService = FakeEncryptionService(),
verificationService = FakeSessionVerificationService(),
featureFlagService = FakeFeatureFlagService(),
redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
)
val preferencesStore = InMemoryPreferencesStore(isRichTextEditorEnabled = true)
val actionListPresenter = ActionListPresenter(preferencesStore = preferencesStore)
val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter()
val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom)
@ -670,6 +673,7 @@ class MessagesPresenterTest {
customReactionPresenter = customReactionPresenter,
reactionSummaryPresenter = reactionSummaryPresenter,
retrySendMenuPresenter = retrySendMenuPresenter,
readReceiptBottomSheetPresenter = readReceiptBottomSheetPresenter,
networkMonitor = FakeNetworkMonitor(),
snackbarDispatcher = SnackbarDispatcher(),
messageSummaryFormatter = FakeMessageSummaryFormatter(),

View file

@ -17,7 +17,9 @@
package io.element.android.features.messages.fixtures
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.TimelineItemReadReceipts
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.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_NAME
import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo
import kotlinx.collections.immutable.toImmutableList
internal fun aMessageEvent(
eventId: EventId? = AN_EVENT_ID,
@ -50,6 +53,7 @@ internal fun aMessageEvent(
sentTime = "",
isMine = isMine,
reactionsState = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
localSendState = sendState,
inReplyTo = inReplyTo,
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.libraries.androidutils.filesize.FakeFileSizeFormatter
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.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
@ -65,6 +66,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
),
matrixClient = matrixClient,
lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(),
),
virtualItemFactory = TimelineItemVirtualFactory(
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.model.TimelineItem
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.voicemessages.timeline.FakeRedactedVoiceMessageManager
import io.element.android.features.messages.voicemessages.timeline.aRedactedMatrixTimeline
@ -347,6 +348,7 @@ class TimelinePresenterTest {
analyticsService = FakeAnalyticsService(),
encryptionService = FakeEncryptionService(),
verificationService = FakeSessionVerificationService(),
featureFlagService = FakeFeatureFlagService(),
redactedVoiceMessageManager = redactedVoiceMessageManager,
)
}
@ -363,6 +365,7 @@ class TimelinePresenterTest {
analyticsService = analyticsService,
encryptionService = FakeEncryptionService(),
verificationService = FakeSessionVerificationService(),
featureFlagService = FakeFeatureFlagService(),
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.groups.TimelineItemGrouper
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.TimelineItemReadReceipts
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.libraries.designsystem.components.avatar.anAvatarData
@ -42,6 +44,7 @@ class TimelineItemGrouperTest {
senderDisplayName = "",
content = TimelineItemStateEventContent(body = "a state event"),
reactionsState = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
inReplyTo = null,
isThreaded = false,

View file

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