Merge pull request #1834 from vector-im/feature/bma/readReceipts
Render send state and read receipts
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -58,3 +58,7 @@ data class AvatarData(
|
||||
.uppercase()
|
||||
}
|
||||
}
|
||||
|
||||
fun AvatarData.getBestName(): String {
|
||||
return name?.takeIf { it.isNotEmpty() } ?: id
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
29
libraries/designsystem/src/main/res/drawable/ic_sending.xml
Normal file
29
libraries/designsystem/src/main/res/drawable/ic_sending.xml
Normal 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>
|
||||
29
libraries/designsystem/src/main/res/drawable/ic_sent.xml
Normal file
29
libraries/designsystem/src/main/res/drawable/ic_sent.xml
Normal 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>
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ class StaticFeatureFlagProvider @Inject constructor() :
|
||||
FeatureFlags.PinUnlock -> true
|
||||
FeatureFlags.Mentions -> false
|
||||
FeatureFlags.SecureStorage -> false
|
||||
FeatureFlags.ReadReceipts -> false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user