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

View File

@@ -58,3 +58,7 @@ data class AvatarData(
.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),
TimelineSender(32.dp),
TimelineReadReceipt(16.dp),
ReadReceiptList(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",
description = "Allow access to backup and restore chat history settings",
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.Mentions -> false
FeatureFlags.SecureStorage -> false
FeatureFlags.ReadReceipts -> false
}
} else {
false

View File

@@ -30,6 +30,7 @@ data class EventTimelineItem(
val isRemote: Boolean,
val localSendState: LocalEventSendState?,
val reactions: List<EventReaction>,
val receipts: List<Receipt>,
val sender: UserId,
val senderProfile: ProfileTimelineDetails,
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.UserId
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.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
import 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.EventItemOrigin as RustEventItemOrigin
import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState
import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem
import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo as RustEventTimelineItemDebugInfo
import org.matrix.rustcomponents.sdk.ProfileDetails as RustProfileDetails
import org.matrix.rustcomponents.sdk.Receipt as RustReceipt
class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper()) {
@@ -45,6 +47,7 @@ class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMap
isRemote = it.isRemote(),
localSendState = it.localSendState()?.map(),
reactions = it.reactions().map(),
receipts = it.readReceipts().map(),
sender = UserId(it.sender()),
senderProfile = it.senderProfile().map(),
timestamp = it.timestamp().toLong(),
@@ -92,6 +95,15 @@ private fun List<Reaction>?.map(): List<EventReaction> {
} ?: 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 {
return TimelineItemDebugInfo(
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.ProfileChangeContent
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.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
@@ -107,6 +108,7 @@ fun anEventTimelineItem(
isRemote: Boolean = false,
localSendState: LocalEventSendState? = null,
reactions: List<EventReaction> = emptyList(),
receipts: List<Receipt> = emptyList(),
sender: UserId = A_USER_ID,
senderProfile: ProfileTimelineDetails = aProfileTimelineDetails(),
timestamp: Long = 0L,
@@ -121,6 +123,7 @@ fun anEventTimelineItem(
isRemote = isRemote,
localSendState = localSendState,
reactions = reactions,
receipts = receipts,
sender = sender,
senderProfile = senderProfile,
timestamp = timestamp,

View File

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

View File

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

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