Merge pull request #1834 from vector-im/feature/bma/readReceipts
Render send state and read receipts
This commit is contained in:
commit
71ef0c3c78
118 changed files with 1159 additions and 56 deletions
|
|
@ -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)
|
implementation(projects.anvilannotations)
|
||||||
anvil(projects.anvilcodegen)
|
anvil(projects.anvilcodegen)
|
||||||
api(projects.features.messages.api)
|
api(projects.features.messages.api)
|
||||||
|
implementation(projects.appconfig)
|
||||||
implementation(projects.features.call)
|
implementation(projects.features.call)
|
||||||
implementation(projects.features.location.api)
|
implementation(projects.features.location.api)
|
||||||
implementation(projects.features.poll.api)
|
implementation(projects.features.poll.api)
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineState
|
import io.element.android.features.messages.impl.timeline.TimelineState
|
||||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
|
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
|
||||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter
|
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetPresenter
|
||||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
|
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||||
|
|
@ -97,6 +98,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
private val customReactionPresenter: CustomReactionPresenter,
|
private val customReactionPresenter: CustomReactionPresenter,
|
||||||
private val reactionSummaryPresenter: ReactionSummaryPresenter,
|
private val reactionSummaryPresenter: ReactionSummaryPresenter,
|
||||||
private val retrySendMenuPresenter: RetrySendMenuPresenter,
|
private val retrySendMenuPresenter: RetrySendMenuPresenter,
|
||||||
|
private val readReceiptBottomSheetPresenter: ReadReceiptBottomSheetPresenter,
|
||||||
private val networkMonitor: NetworkMonitor,
|
private val networkMonitor: NetworkMonitor,
|
||||||
private val snackbarDispatcher: SnackbarDispatcher,
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
private val messageSummaryFormatter: MessageSummaryFormatter,
|
private val messageSummaryFormatter: MessageSummaryFormatter,
|
||||||
|
|
@ -124,6 +126,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
val customReactionState = customReactionPresenter.present()
|
val customReactionState = customReactionPresenter.present()
|
||||||
val reactionSummaryState = reactionSummaryPresenter.present()
|
val reactionSummaryState = reactionSummaryPresenter.present()
|
||||||
val retryState = retrySendMenuPresenter.present()
|
val retryState = retrySendMenuPresenter.present()
|
||||||
|
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
|
||||||
|
|
||||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||||
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
|
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
|
||||||
|
|
@ -201,6 +204,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
customReactionState = customReactionState,
|
customReactionState = customReactionState,
|
||||||
reactionSummaryState = reactionSummaryState,
|
reactionSummaryState = reactionSummaryState,
|
||||||
retrySendMenuState = retryState,
|
retrySendMenuState = retryState,
|
||||||
|
readReceiptBottomSheetState = readReceiptBottomSheetState,
|
||||||
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
|
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
|
||||||
snackbarMessage = snackbarMessage,
|
snackbarMessage = snackbarMessage,
|
||||||
showReinvitePrompt = showReinvitePrompt,
|
showReinvitePrompt = showReinvitePrompt,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineState
|
import io.element.android.features.messages.impl.timeline.TimelineState
|
||||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
|
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
|
||||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
|
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
|
||||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
|
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
|
||||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
|
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
|
|
@ -43,6 +44,7 @@ data class MessagesState(
|
||||||
val customReactionState: CustomReactionState,
|
val customReactionState: CustomReactionState,
|
||||||
val reactionSummaryState: ReactionSummaryState,
|
val reactionSummaryState: ReactionSummaryState,
|
||||||
val retrySendMenuState: RetrySendMenuState,
|
val retrySendMenuState: RetrySendMenuState,
|
||||||
|
val readReceiptBottomSheetState: ReadReceiptBottomSheetState,
|
||||||
val hasNetworkConnection: Boolean,
|
val hasNetworkConnection: Boolean,
|
||||||
val snackbarMessage: SnackbarMessage?,
|
val snackbarMessage: SnackbarMessage?,
|
||||||
val inviteProgress: Async<Unit>,
|
val inviteProgress: Async<Unit>,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemList
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineState
|
import io.element.android.features.messages.impl.timeline.aTimelineState
|
||||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
|
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
|
||||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
|
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
|
||||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
|
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||||
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
|
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
|
||||||
|
|
@ -96,6 +97,10 @@ fun aMessagesState() = MessagesState(
|
||||||
selectedEvent = null,
|
selectedEvent = null,
|
||||||
eventSink = {},
|
eventSink = {},
|
||||||
),
|
),
|
||||||
|
readReceiptBottomSheetState = ReadReceiptBottomSheetState(
|
||||||
|
selectedEvent = null,
|
||||||
|
eventSink = {},
|
||||||
|
),
|
||||||
actionListState = anActionListState(),
|
actionListState = anActionListState(),
|
||||||
customReactionState = CustomReactionState(
|
customReactionState = CustomReactionState(
|
||||||
target = CustomReactionState.Target.None,
|
target = CustomReactionState.Target.None,
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,8 @@ import io.element.android.features.messages.impl.timeline.components.customreact
|
||||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents
|
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents
|
||||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents
|
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents
|
||||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
|
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
|
||||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents
|
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents
|
||||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu
|
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
|
@ -212,6 +214,9 @@ fun MessagesView(
|
||||||
onReactionClicked = ::onEmojiReactionClicked,
|
onReactionClicked = ::onEmojiReactionClicked,
|
||||||
onReactionLongClicked = ::onEmojiReactionLongClicked,
|
onReactionLongClicked = ::onEmojiReactionLongClicked,
|
||||||
onMoreReactionsClicked = ::onMoreReactionsClicked,
|
onMoreReactionsClicked = ::onMoreReactionsClicked,
|
||||||
|
onReadReceiptClick = { event ->
|
||||||
|
state.readReceiptBottomSheetState.eventSink(ReadReceiptBottomSheetEvents.EventSelected(event))
|
||||||
|
},
|
||||||
onSendLocationClicked = onSendLocationClicked,
|
onSendLocationClicked = onSendLocationClicked,
|
||||||
onCreatePollClicked = onCreatePollClicked,
|
onCreatePollClicked = onCreatePollClicked,
|
||||||
onSwipeToReply = { targetEvent ->
|
onSwipeToReply = { targetEvent ->
|
||||||
|
|
@ -246,13 +251,12 @@ fun MessagesView(
|
||||||
)
|
)
|
||||||
|
|
||||||
ReactionSummaryView(state = state.reactionSummaryState)
|
ReactionSummaryView(state = state.reactionSummaryState)
|
||||||
RetrySendMessageMenu(
|
RetrySendMessageMenu(state = state.retrySendMenuState)
|
||||||
state = state.retrySendMenuState
|
ReadReceiptBottomSheet(
|
||||||
)
|
state = state.readReceiptBottomSheetState,
|
||||||
|
onUserDataClicked = onUserDataClicked,
|
||||||
ReinviteDialog(
|
|
||||||
state = state
|
|
||||||
)
|
)
|
||||||
|
ReinviteDialog(state = state)
|
||||||
|
|
||||||
// Since the textfield is now based on an Android view, this is no longer done automatically.
|
// Since the textfield is now based on an Android view, this is no longer done automatically.
|
||||||
// We need to hide the keyboard automatically when navigating out of this screen.
|
// We need to hide the keyboard automatically when navigating out of this screen.
|
||||||
|
|
@ -310,6 +314,7 @@ private fun MessagesViewContent(
|
||||||
onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
|
onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
|
||||||
onReactionLongClicked: (key: String, TimelineItem.Event) -> Unit,
|
onReactionLongClicked: (key: String, TimelineItem.Event) -> Unit,
|
||||||
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
|
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
|
||||||
|
onReadReceiptClick: (TimelineItem.Event) -> Unit,
|
||||||
onMessageLongClicked: (TimelineItem.Event) -> Unit,
|
onMessageLongClicked: (TimelineItem.Event) -> Unit,
|
||||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||||
onSendLocationClicked: () -> Unit,
|
onSendLocationClicked: () -> Unit,
|
||||||
|
|
@ -381,6 +386,7 @@ private fun MessagesViewContent(
|
||||||
onReactionClicked = onReactionClicked,
|
onReactionClicked = onReactionClicked,
|
||||||
onReactionLongClicked = onReactionLongClicked,
|
onReactionLongClicked = onReactionLongClicked,
|
||||||
onMoreReactionsClicked = onMoreReactionsClicked,
|
onMoreReactionsClicked = onMoreReactionsClicked,
|
||||||
|
onReadReceiptClick = onReadReceiptClick,
|
||||||
onSwipeToReply = onSwipeToReply,
|
onSwipeToReply = onSwipeToReply,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -406,7 +412,8 @@ private fun MessagesViewComposerBottomSheetContents(
|
||||||
if (state.userHasPermissionToSendMessage) {
|
if (state.userHasPermissionToSendMessage) {
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
MentionSuggestionsPickerView(
|
MentionSuggestionsPickerView(
|
||||||
modifier = Modifier.heightIn(max = 230.dp)
|
modifier = Modifier
|
||||||
|
.heightIn(max = 230.dp)
|
||||||
// Consume all scrolling, preventing the bottom sheet from being dragged when interacting with the list of suggestions
|
// Consume all scrolling, preventing the bottom sheet from being dragged when interacting with the list of suggestions
|
||||||
.nestedScroll(object : NestedScrollConnection {
|
.nestedScroll(object : NestedScrollConnection {
|
||||||
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
|
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
|
||||||
|
|
|
||||||
|
|
@ -35,11 +35,14 @@ import io.element.android.features.messages.impl.timeline.session.SessionState
|
||||||
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
|
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.encryption.BackupState
|
import io.element.android.libraries.matrix.api.encryption.BackupState
|
||||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.MessageEventType
|
import io.element.android.libraries.matrix.api.room.MessageEventType
|
||||||
|
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||||
|
|
@ -64,6 +67,7 @@ class TimelinePresenter @Inject constructor(
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val verificationService: SessionVerificationService,
|
private val verificationService: SessionVerificationService,
|
||||||
private val encryptionService: EncryptionService,
|
private val encryptionService: EncryptionService,
|
||||||
|
private val featureFlagService: FeatureFlagService,
|
||||||
private val redactedVoiceMessageManager: RedactedVoiceMessageManager,
|
private val redactedVoiceMessageManager: RedactedVoiceMessageManager,
|
||||||
) : Presenter<TimelineState> {
|
) : Presenter<TimelineState> {
|
||||||
|
|
||||||
|
|
@ -99,6 +103,9 @@ class TimelinePresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val readReceiptsEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.ReadReceipts).collectAsState(initial = false)
|
||||||
|
val membersState by room.membersStateFlow.collectAsState()
|
||||||
|
|
||||||
fun handleEvents(event: TimelineEvents) {
|
fun handleEvents(event: TimelineEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
TimelineEvents.LoadMore -> localScope.paginateBackwards()
|
TimelineEvents.LoadMore -> localScope.paginateBackwards()
|
||||||
|
|
@ -138,7 +145,17 @@ class TimelinePresenter @Inject constructor(
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
timeline
|
timeline
|
||||||
.timelineItems
|
.timelineItems
|
||||||
.onEach(timelineItemsFactory::replaceWith)
|
.onEach {
|
||||||
|
timelineItemsFactory.replaceWith(
|
||||||
|
timelineItems = it,
|
||||||
|
roomMembers = if (readReceiptsEnabled) {
|
||||||
|
membersState.roomMembers().orEmpty()
|
||||||
|
} else {
|
||||||
|
// Give an empty list to not affect performance
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
.onEach { timelineItems ->
|
.onEach { timelineItems ->
|
||||||
if (timelineItems.isEmpty()) {
|
if (timelineItems.isEmpty()) {
|
||||||
paginateBackwards()
|
paginateBackwards()
|
||||||
|
|
@ -153,6 +170,7 @@ class TimelinePresenter @Inject constructor(
|
||||||
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
||||||
paginationState = paginationState,
|
paginationState = paginationState,
|
||||||
timelineItems = timelineItems,
|
timelineItems = timelineItems,
|
||||||
|
showReadReceipts = readReceiptsEnabled,
|
||||||
hasNewItems = hasNewItems.value,
|
hasNewItems = hasNewItems.value,
|
||||||
sessionState = sessionState,
|
sessionState = sessionState,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||||
@Immutable
|
@Immutable
|
||||||
data class TimelineState(
|
data class TimelineState(
|
||||||
val timelineItems: ImmutableList<TimelineItem>,
|
val timelineItems: ImmutableList<TimelineItem>,
|
||||||
|
val showReadReceipts: Boolean,
|
||||||
val highlightedEventId: EventId?,
|
val highlightedEventId: EventId?,
|
||||||
val userHasPermissionToSendMessage: Boolean,
|
val userHasPermissionToSendMessage: Boolean,
|
||||||
val paginationState: MatrixTimeline.PaginationState,
|
val paginationState: MatrixTimeline.PaginationState,
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,11 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline
|
package io.element.android.features.messages.impl.timeline
|
||||||
|
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
|
||||||
import io.element.android.features.messages.impl.timeline.model.anAggregatedReaction
|
import io.element.android.features.messages.impl.timeline.model.anAggregatedReaction
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
|
||||||
|
|
@ -43,6 +45,7 @@ import kotlin.random.Random
|
||||||
|
|
||||||
fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf()) = TimelineState(
|
fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf()) = TimelineState(
|
||||||
timelineItems = timelineItems,
|
timelineItems = timelineItems,
|
||||||
|
showReadReceipts = false,
|
||||||
paginationState = MatrixTimeline.PaginationState(
|
paginationState = MatrixTimeline.PaginationState(
|
||||||
isBackPaginating = false,
|
isBackPaginating = false,
|
||||||
hasMoreToLoadBackwards = true,
|
hasMoreToLoadBackwards = true,
|
||||||
|
|
@ -118,11 +121,12 @@ internal fun aTimelineItemEvent(
|
||||||
senderDisplayName: String = "Sender",
|
senderDisplayName: String = "Sender",
|
||||||
content: TimelineItemEventContent = aTimelineItemTextContent(),
|
content: TimelineItemEventContent = aTimelineItemTextContent(),
|
||||||
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
||||||
sendState: LocalEventSendState = LocalEventSendState.Sent(eventId),
|
sendState: LocalEventSendState? = null,
|
||||||
inReplyTo: InReplyTo? = null,
|
inReplyTo: InReplyTo? = null,
|
||||||
isThreaded: Boolean = false,
|
isThreaded: Boolean = false,
|
||||||
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
|
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
|
||||||
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
|
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
|
||||||
|
readReceiptState: TimelineItemReadReceipts = aTimelineItemReadReceipts(),
|
||||||
): TimelineItem.Event {
|
): TimelineItem.Event {
|
||||||
return TimelineItem.Event(
|
return TimelineItem.Event(
|
||||||
id = UUID.randomUUID().toString(),
|
id = UUID.randomUUID().toString(),
|
||||||
|
|
@ -132,6 +136,7 @@ internal fun aTimelineItemEvent(
|
||||||
senderAvatar = AvatarData("@senderId:domain", "sender", size = AvatarSize.TimelineSender),
|
senderAvatar = AvatarData("@senderId:domain", "sender", size = AvatarSize.TimelineSender),
|
||||||
content = content,
|
content = content,
|
||||||
reactionsState = timelineItemReactions,
|
reactionsState = timelineItemReactions,
|
||||||
|
readReceiptState = readReceiptState,
|
||||||
sentTime = "12:34",
|
sentTime = "12:34",
|
||||||
isMine = isMine,
|
isMine = isMine,
|
||||||
senderDisplayName = senderDisplayName,
|
senderDisplayName = senderDisplayName,
|
||||||
|
|
@ -173,6 +178,12 @@ internal fun aTimelineItemDebugInfo(
|
||||||
model, originalJson, latestEditedJson
|
model, originalJson, latestEditedJson
|
||||||
)
|
)
|
||||||
|
|
||||||
|
internal fun aTimelineItemReadReceipts(): TimelineItemReadReceipts {
|
||||||
|
return TimelineItemReadReceipts(
|
||||||
|
receipts = emptyList<ReadReceiptData>().toImmutableList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents {
|
fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents {
|
||||||
val event = aTimelineItemEvent(
|
val event = aTimelineItemEvent(
|
||||||
isMine = true,
|
isMine = true,
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ fun TimelineView(
|
||||||
onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit,
|
onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit,
|
||||||
onReactionLongClicked: (emoji: String, TimelineItem.Event) -> Unit,
|
onReactionLongClicked: (emoji: String, TimelineItem.Event) -> Unit,
|
||||||
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
|
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
|
||||||
|
onReadReceiptClick: (TimelineItem.Event) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
fun onReachedLoadMore() {
|
fun onReachedLoadMore() {
|
||||||
|
|
@ -126,6 +127,9 @@ fun TimelineView(
|
||||||
) { timelineItem ->
|
) { timelineItem ->
|
||||||
TimelineItemRow(
|
TimelineItemRow(
|
||||||
timelineItem = timelineItem,
|
timelineItem = timelineItem,
|
||||||
|
showReadReceipts = state.showReadReceipts,
|
||||||
|
isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true
|
||||||
|
&& state.timelineItems.first().identifier() == timelineItem.identifier(),
|
||||||
highlightedItem = state.highlightedEventId?.value,
|
highlightedItem = state.highlightedEventId?.value,
|
||||||
userHasPermissionToSendMessage = state.userHasPermissionToSendMessage,
|
userHasPermissionToSendMessage = state.userHasPermissionToSendMessage,
|
||||||
onClick = onMessageClicked,
|
onClick = onMessageClicked,
|
||||||
|
|
@ -135,6 +139,7 @@ fun TimelineView(
|
||||||
onReactionClick = onReactionClicked,
|
onReactionClick = onReactionClicked,
|
||||||
onReactionLongClick = onReactionLongClicked,
|
onReactionLongClick = onReactionLongClicked,
|
||||||
onMoreReactionsClick = onMoreReactionsClicked,
|
onMoreReactionsClick = onMoreReactionsClicked,
|
||||||
|
onReadReceiptClick = onReadReceiptClick,
|
||||||
onTimestampClicked = onTimestampClicked,
|
onTimestampClicked = onTimestampClicked,
|
||||||
sessionState = state.sessionState,
|
sessionState = state.sessionState,
|
||||||
eventSink = state.eventSink,
|
eventSink = state.eventSink,
|
||||||
|
|
@ -169,6 +174,8 @@ fun TimelineView(
|
||||||
@Composable
|
@Composable
|
||||||
private fun TimelineItemRow(
|
private fun TimelineItemRow(
|
||||||
timelineItem: TimelineItem,
|
timelineItem: TimelineItem,
|
||||||
|
showReadReceipts: Boolean,
|
||||||
|
isLastOutgoingMessage: Boolean,
|
||||||
highlightedItem: String?,
|
highlightedItem: String?,
|
||||||
userHasPermissionToSendMessage: Boolean,
|
userHasPermissionToSendMessage: Boolean,
|
||||||
sessionState: SessionState,
|
sessionState: SessionState,
|
||||||
|
|
@ -179,6 +186,7 @@ private fun TimelineItemRow(
|
||||||
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
||||||
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
|
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
|
||||||
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
|
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
|
||||||
|
onReadReceiptClick: (TimelineItem.Event) -> Unit,
|
||||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||||
eventSink: (TimelineEvents) -> Unit,
|
eventSink: (TimelineEvents) -> Unit,
|
||||||
|
|
@ -205,6 +213,8 @@ private fun TimelineItemRow(
|
||||||
} else {
|
} else {
|
||||||
TimelineItemEventRow(
|
TimelineItemEventRow(
|
||||||
event = timelineItem,
|
event = timelineItem,
|
||||||
|
showReadReceipts = showReadReceipts,
|
||||||
|
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||||
isHighlighted = highlightedItem == timelineItem.identifier(),
|
isHighlighted = highlightedItem == timelineItem.identifier(),
|
||||||
canReply = userHasPermissionToSendMessage && timelineItem.content.canBeRepliedTo(),
|
canReply = userHasPermissionToSendMessage && timelineItem.content.canBeRepliedTo(),
|
||||||
onClick = { onClick(timelineItem) },
|
onClick = { onClick(timelineItem) },
|
||||||
|
|
@ -214,6 +224,7 @@ private fun TimelineItemRow(
|
||||||
onReactionClick = onReactionClick,
|
onReactionClick = onReactionClick,
|
||||||
onReactionLongClick = onReactionLongClick,
|
onReactionLongClick = onReactionLongClick,
|
||||||
onMoreReactionsClick = onMoreReactionsClick,
|
onMoreReactionsClick = onMoreReactionsClick,
|
||||||
|
onReadReceiptClick = onReadReceiptClick,
|
||||||
onTimestampClicked = onTimestampClicked,
|
onTimestampClicked = onTimestampClicked,
|
||||||
onSwipeToReply = { onSwipeToReply(timelineItem) },
|
onSwipeToReply = { onSwipeToReply(timelineItem) },
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
|
|
@ -244,6 +255,8 @@ private fun TimelineItemRow(
|
||||||
timelineItem.events.forEach { subGroupEvent ->
|
timelineItem.events.forEach { subGroupEvent ->
|
||||||
TimelineItemRow(
|
TimelineItemRow(
|
||||||
timelineItem = subGroupEvent,
|
timelineItem = subGroupEvent,
|
||||||
|
showReadReceipts = showReadReceipts,
|
||||||
|
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||||
highlightedItem = highlightedItem,
|
highlightedItem = highlightedItem,
|
||||||
sessionState = sessionState,
|
sessionState = sessionState,
|
||||||
userHasPermissionToSendMessage = false,
|
userHasPermissionToSendMessage = false,
|
||||||
|
|
@ -255,6 +268,7 @@ private fun TimelineItemRow(
|
||||||
onReactionClick = onReactionClick,
|
onReactionClick = onReactionClick,
|
||||||
onReactionLongClick = onReactionLongClick,
|
onReactionLongClick = onReactionLongClick,
|
||||||
onMoreReactionsClick = onMoreReactionsClick,
|
onMoreReactionsClick = onMoreReactionsClick,
|
||||||
|
onReadReceiptClick = onReadReceiptClick,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
onSwipeToReply = {},
|
onSwipeToReply = {},
|
||||||
)
|
)
|
||||||
|
|
@ -362,6 +376,7 @@ internal fun TimelineViewPreview(
|
||||||
onReactionLongClicked = { _, _ -> },
|
onReactionLongClicked = { _, _ -> },
|
||||||
onMoreReactionsClicked = {},
|
onMoreReactionsClicked = {},
|
||||||
onSwipeToReply = {},
|
onSwipeToReply = {},
|
||||||
|
onReadReceiptClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,8 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||||
import io.element.android.features.messages.impl.timeline.components.event.toExtraPadding
|
import io.element.android.features.messages.impl.timeline.components.event.toExtraPadding
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||||
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
|
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
|
||||||
|
|
@ -114,6 +116,8 @@ import kotlin.math.roundToInt
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineItemEventRow(
|
fun TimelineItemEventRow(
|
||||||
event: TimelineItem.Event,
|
event: TimelineItem.Event,
|
||||||
|
showReadReceipts: Boolean,
|
||||||
|
isLastOutgoingMessage: Boolean,
|
||||||
isHighlighted: Boolean,
|
isHighlighted: Boolean,
|
||||||
canReply: Boolean,
|
canReply: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
|
@ -124,6 +128,7 @@ fun TimelineItemEventRow(
|
||||||
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
|
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
|
||||||
onReactionLongClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
|
onReactionLongClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
|
||||||
onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit,
|
onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit,
|
||||||
|
onReadReceiptClick: (event: TimelineItem.Event) -> Unit,
|
||||||
onSwipeToReply: () -> Unit,
|
onSwipeToReply: () -> Unit,
|
||||||
eventSink: (TimelineEvents) -> Unit,
|
eventSink: (TimelineEvents) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
|
|
@ -173,6 +178,8 @@ fun TimelineItemEventRow(
|
||||||
state = state.draggableState,
|
state = state.draggableState,
|
||||||
),
|
),
|
||||||
event = event,
|
event = event,
|
||||||
|
showReadReceipts = showReadReceipts,
|
||||||
|
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||||
isHighlighted = isHighlighted,
|
isHighlighted = isHighlighted,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|
@ -183,6 +190,7 @@ fun TimelineItemEventRow(
|
||||||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||||
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
|
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
|
||||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||||
|
onReadReceiptsClicked = { onReadReceiptClick(event) },
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -190,6 +198,8 @@ fun TimelineItemEventRow(
|
||||||
} else {
|
} else {
|
||||||
TimelineItemEventRowContent(
|
TimelineItemEventRowContent(
|
||||||
event = event,
|
event = event,
|
||||||
|
showReadReceipts = showReadReceipts,
|
||||||
|
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||||
isHighlighted = isHighlighted,
|
isHighlighted = isHighlighted,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|
@ -200,6 +210,7 @@ fun TimelineItemEventRow(
|
||||||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||||
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
|
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
|
||||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||||
|
onReadReceiptsClicked = { onReadReceiptClick(event) },
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -232,6 +243,8 @@ private fun SwipeSensitivity(
|
||||||
@Composable
|
@Composable
|
||||||
private fun TimelineItemEventRowContent(
|
private fun TimelineItemEventRowContent(
|
||||||
event: TimelineItem.Event,
|
event: TimelineItem.Event,
|
||||||
|
showReadReceipts: Boolean,
|
||||||
|
isLastOutgoingMessage: Boolean,
|
||||||
isHighlighted: Boolean,
|
isHighlighted: Boolean,
|
||||||
interactionSource: MutableInteractionSource,
|
interactionSource: MutableInteractionSource,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
|
@ -240,6 +253,7 @@ private fun TimelineItemEventRowContent(
|
||||||
inReplyToClicked: () -> Unit,
|
inReplyToClicked: () -> Unit,
|
||||||
onUserDataClicked: () -> Unit,
|
onUserDataClicked: () -> Unit,
|
||||||
onReactionClicked: (emoji: String) -> Unit,
|
onReactionClicked: (emoji: String) -> Unit,
|
||||||
|
onReadReceiptsClicked: () -> Unit,
|
||||||
onReactionLongClicked: (emoji: String) -> Unit,
|
onReactionLongClicked: (emoji: String) -> Unit,
|
||||||
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
|
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
|
||||||
eventSink: (TimelineEvents) -> Unit,
|
eventSink: (TimelineEvents) -> Unit,
|
||||||
|
|
@ -256,7 +270,12 @@ private fun TimelineItemEventRowContent(
|
||||||
.wrapContentHeight()
|
.wrapContentHeight()
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
val (sender, message, reactions) = createRefs()
|
val (
|
||||||
|
sender,
|
||||||
|
message,
|
||||||
|
reactions,
|
||||||
|
readReceipts,
|
||||||
|
) = createRefs()
|
||||||
|
|
||||||
// Sender
|
// Sender
|
||||||
val avatarStrokeSize = 3.dp
|
val avatarStrokeSize = 3.dp
|
||||||
|
|
@ -322,6 +341,25 @@ private fun TimelineItemEventRowContent(
|
||||||
.padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp)
|
.padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read receipts / Send state
|
||||||
|
TimelineItemReadReceiptView(
|
||||||
|
state = ReadReceiptViewState(
|
||||||
|
sendState = event.localSendState,
|
||||||
|
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||||
|
receipts = event.readReceiptState.receipts,
|
||||||
|
),
|
||||||
|
showReadReceipts = showReadReceipts,
|
||||||
|
onReadReceiptsClicked = onReadReceiptsClicked,
|
||||||
|
modifier = Modifier
|
||||||
|
.constrainAs(readReceipts) {
|
||||||
|
if (event.reactionsState.reactions.isNotEmpty()) {
|
||||||
|
top.linkTo(reactions.bottom, margin = 4.dp)
|
||||||
|
} else {
|
||||||
|
top.linkTo(message.bottom, margin = 4.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -658,6 +696,8 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
|
||||||
),
|
),
|
||||||
groupPosition = TimelineItemGroupPosition.First,
|
groupPosition = TimelineItemGroupPosition.First,
|
||||||
),
|
),
|
||||||
|
showReadReceipts = false,
|
||||||
|
isLastOutgoingMessage = false,
|
||||||
isHighlighted = false,
|
isHighlighted = false,
|
||||||
canReply = true,
|
canReply = true,
|
||||||
onClick = {},
|
onClick = {},
|
||||||
|
|
@ -667,6 +707,7 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
|
||||||
onReactionClick = { _, _ -> },
|
onReactionClick = { _, _ -> },
|
||||||
onReactionLongClick = { _, _ -> },
|
onReactionLongClick = { _, _ -> },
|
||||||
onMoreReactionsClick = {},
|
onMoreReactionsClick = {},
|
||||||
|
onReadReceiptClick = {},
|
||||||
onTimestampClicked = {},
|
onTimestampClicked = {},
|
||||||
onSwipeToReply = {},
|
onSwipeToReply = {},
|
||||||
eventSink = {},
|
eventSink = {},
|
||||||
|
|
@ -679,6 +720,8 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
|
||||||
),
|
),
|
||||||
groupPosition = TimelineItemGroupPosition.Last,
|
groupPosition = TimelineItemGroupPosition.Last,
|
||||||
),
|
),
|
||||||
|
showReadReceipts = false,
|
||||||
|
isLastOutgoingMessage = false,
|
||||||
isHighlighted = false,
|
isHighlighted = false,
|
||||||
canReply = true,
|
canReply = true,
|
||||||
onClick = {},
|
onClick = {},
|
||||||
|
|
@ -688,6 +731,7 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
|
||||||
onReactionClick = { _, _ -> },
|
onReactionClick = { _, _ -> },
|
||||||
onReactionLongClick = { _, _ -> },
|
onReactionLongClick = { _, _ -> },
|
||||||
onMoreReactionsClick = {},
|
onMoreReactionsClick = {},
|
||||||
|
onReadReceiptClick = {},
|
||||||
onTimestampClicked = {},
|
onTimestampClicked = {},
|
||||||
onSwipeToReply = {},
|
onSwipeToReply = {},
|
||||||
eventSink = {},
|
eventSink = {},
|
||||||
|
|
@ -718,6 +762,8 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
|
||||||
inReplyTo = aInReplyToReady(replyContent),
|
inReplyTo = aInReplyToReady(replyContent),
|
||||||
groupPosition = TimelineItemGroupPosition.First,
|
groupPosition = TimelineItemGroupPosition.First,
|
||||||
),
|
),
|
||||||
|
showReadReceipts = false,
|
||||||
|
isLastOutgoingMessage = false,
|
||||||
isHighlighted = false,
|
isHighlighted = false,
|
||||||
canReply = true,
|
canReply = true,
|
||||||
onClick = {},
|
onClick = {},
|
||||||
|
|
@ -727,6 +773,7 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
|
||||||
onReactionClick = { _, _ -> },
|
onReactionClick = { _, _ -> },
|
||||||
onReactionLongClick = { _, _ -> },
|
onReactionLongClick = { _, _ -> },
|
||||||
onMoreReactionsClick = {},
|
onMoreReactionsClick = {},
|
||||||
|
onReadReceiptClick = {},
|
||||||
onTimestampClicked = {},
|
onTimestampClicked = {},
|
||||||
onSwipeToReply = {},
|
onSwipeToReply = {},
|
||||||
eventSink = {},
|
eventSink = {},
|
||||||
|
|
@ -741,6 +788,8 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
|
||||||
isThreaded = true,
|
isThreaded = true,
|
||||||
groupPosition = TimelineItemGroupPosition.Last,
|
groupPosition = TimelineItemGroupPosition.Last,
|
||||||
),
|
),
|
||||||
|
showReadReceipts = false,
|
||||||
|
isLastOutgoingMessage = false,
|
||||||
isHighlighted = false,
|
isHighlighted = false,
|
||||||
canReply = true,
|
canReply = true,
|
||||||
onClick = {},
|
onClick = {},
|
||||||
|
|
@ -750,6 +799,7 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
|
||||||
onReactionClick = { _, _ -> },
|
onReactionClick = { _, _ -> },
|
||||||
onReactionLongClick = { _, _ -> },
|
onReactionLongClick = { _, _ -> },
|
||||||
onMoreReactionsClick = {},
|
onMoreReactionsClick = {},
|
||||||
|
onReadReceiptClick = {},
|
||||||
onTimestampClicked = {},
|
onTimestampClicked = {},
|
||||||
onSwipeToReply = {},
|
onSwipeToReply = {},
|
||||||
eventSink = {},
|
eventSink = {},
|
||||||
|
|
@ -792,6 +842,8 @@ internal fun TimelineItemEventRowTimestampPreview(
|
||||||
reactionsState = aTimelineItemReactions(count = 0),
|
reactionsState = aTimelineItemReactions(count = 0),
|
||||||
senderDisplayName = if (useDocument) "Document case" else "Text case",
|
senderDisplayName = if (useDocument) "Document case" else "Text case",
|
||||||
),
|
),
|
||||||
|
showReadReceipts = false,
|
||||||
|
isLastOutgoingMessage = false,
|
||||||
isHighlighted = false,
|
isHighlighted = false,
|
||||||
canReply = true,
|
canReply = true,
|
||||||
onClick = {},
|
onClick = {},
|
||||||
|
|
@ -801,6 +853,7 @@ internal fun TimelineItemEventRowTimestampPreview(
|
||||||
onReactionClick = { _, _ -> },
|
onReactionClick = { _, _ -> },
|
||||||
onReactionLongClick = { _, _ -> },
|
onReactionLongClick = { _, _ -> },
|
||||||
onMoreReactionsClick = {},
|
onMoreReactionsClick = {},
|
||||||
|
onReadReceiptClick = {},
|
||||||
onTimestampClicked = {},
|
onTimestampClicked = {},
|
||||||
onSwipeToReply = {},
|
onSwipeToReply = {},
|
||||||
eventSink = {},
|
eventSink = {},
|
||||||
|
|
@ -824,6 +877,8 @@ internal fun TimelineItemEventRowWithManyReactionsPreview() = ElementPreview {
|
||||||
),
|
),
|
||||||
timelineItemReactions = aTimelineItemReactions(count = 20),
|
timelineItemReactions = aTimelineItemReactions(count = 20),
|
||||||
),
|
),
|
||||||
|
showReadReceipts = false,
|
||||||
|
isLastOutgoingMessage = false,
|
||||||
isHighlighted = false,
|
isHighlighted = false,
|
||||||
canReply = true,
|
canReply = true,
|
||||||
onClick = {},
|
onClick = {},
|
||||||
|
|
@ -833,6 +888,7 @@ internal fun TimelineItemEventRowWithManyReactionsPreview() = ElementPreview {
|
||||||
onReactionClick = { _, _ -> },
|
onReactionClick = { _, _ -> },
|
||||||
onReactionLongClick = { _, _ -> },
|
onReactionLongClick = { _, _ -> },
|
||||||
onMoreReactionsClick = {},
|
onMoreReactionsClick = {},
|
||||||
|
onReadReceiptClick = {},
|
||||||
onSwipeToReply = {},
|
onSwipeToReply = {},
|
||||||
onTimestampClicked = {},
|
onTimestampClicked = {},
|
||||||
eventSink = {},
|
eventSink = {},
|
||||||
|
|
@ -849,6 +905,8 @@ internal fun TimelineItemEventRowLongSenderNamePreview() = ElementPreviewLight {
|
||||||
event = aTimelineItemEvent(
|
event = aTimelineItemEvent(
|
||||||
senderDisplayName = "a long sender display name to test single line and ellipsis at the end of the line",
|
senderDisplayName = "a long sender display name to test single line and ellipsis at the end of the line",
|
||||||
),
|
),
|
||||||
|
showReadReceipts = false,
|
||||||
|
isLastOutgoingMessage = false,
|
||||||
isHighlighted = false,
|
isHighlighted = false,
|
||||||
canReply = true,
|
canReply = true,
|
||||||
onClick = {},
|
onClick = {},
|
||||||
|
|
@ -858,6 +916,7 @@ internal fun TimelineItemEventRowLongSenderNamePreview() = ElementPreviewLight {
|
||||||
onReactionClick = { _, _ -> },
|
onReactionClick = { _, _ -> },
|
||||||
onReactionLongClick = { _, _ -> },
|
onReactionLongClick = { _, _ -> },
|
||||||
onMoreReactionsClick = {},
|
onMoreReactionsClick = {},
|
||||||
|
onReadReceiptClick = {},
|
||||||
onSwipeToReply = {},
|
onSwipeToReply = {},
|
||||||
onTimestampClicked = {},
|
onTimestampClicked = {},
|
||||||
eventSink = {},
|
eventSink = {},
|
||||||
|
|
@ -870,6 +929,8 @@ internal fun TimelineItemEventRowLongSenderNamePreview() = ElementPreviewLight {
|
||||||
internal fun TimelineItemEventTimestampBelowPreview() = ElementPreviewLight {
|
internal fun TimelineItemEventTimestampBelowPreview() = ElementPreviewLight {
|
||||||
TimelineItemEventRow(
|
TimelineItemEventRow(
|
||||||
event = aTimelineItemEvent(content = aTimelineItemPollContent()),
|
event = aTimelineItemEvent(content = aTimelineItemPollContent()),
|
||||||
|
showReadReceipts = false,
|
||||||
|
isLastOutgoingMessage = false,
|
||||||
isHighlighted = false,
|
isHighlighted = false,
|
||||||
canReply = true,
|
canReply = true,
|
||||||
onClick = {},
|
onClick = {},
|
||||||
|
|
@ -879,6 +940,7 @@ internal fun TimelineItemEventTimestampBelowPreview() = ElementPreviewLight {
|
||||||
onReactionClick = { _, _ -> },
|
onReactionClick = { _, _ -> },
|
||||||
onReactionLongClick = { _, _ -> },
|
onReactionLongClick = { _, _ -> },
|
||||||
onMoreReactionsClick = {},
|
onMoreReactionsClick = {},
|
||||||
|
onReadReceiptClick = {},
|
||||||
onSwipeToReply = {},
|
onSwipeToReply = {},
|
||||||
onTimestampClicked = {},
|
onTimestampClicked = {},
|
||||||
eventSink = {},
|
eventSink = {},
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ private fun TimelineItemReactionsView(
|
||||||
onToggleExpandClick: () -> Unit,
|
onToggleExpandClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
// In LTR languages we want an incoming message's reactions to be LRT and outgoing to be RTL.
|
// In LTR languages we want an incoming message's reactions to be LTR and outgoing to be RTL.
|
||||||
// For RTL languages it should be the opposite.
|
// For RTL languages it should be the opposite.
|
||||||
val currentLayout = LocalLayoutDirection.current
|
val currentLayout = LocalLayoutDirection.current
|
||||||
val reactionsLayoutDirection = if (!isOutgoing) currentLayout
|
val reactionsLayoutDirection = if (!isOutgoing) currentLayout
|
||||||
|
|
|
||||||
|
|
@ -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.DiffCacheUpdater
|
||||||
import io.element.android.libraries.androidutils.diff.MutableListDiffCache
|
import io.element.android.libraries.androidutils.diff.MutableListDiffCache
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
@ -66,19 +67,23 @@ class TimelineItemsFactory @Inject constructor(
|
||||||
|
|
||||||
suspend fun replaceWith(
|
suspend fun replaceWith(
|
||||||
timelineItems: List<MatrixTimelineItem>,
|
timelineItems: List<MatrixTimelineItem>,
|
||||||
|
roomMembers: List<RoomMember>,
|
||||||
) = withContext(dispatchers.computation) {
|
) = withContext(dispatchers.computation) {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
diffCacheUpdater.updateWith(timelineItems)
|
diffCacheUpdater.updateWith(timelineItems)
|
||||||
buildAndEmitTimelineItemStates(timelineItems)
|
buildAndEmitTimelineItemStates(timelineItems, roomMembers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildAndEmitTimelineItemStates(timelineItems: List<MatrixTimelineItem>) {
|
private suspend fun buildAndEmitTimelineItemStates(
|
||||||
|
timelineItems: List<MatrixTimelineItem>,
|
||||||
|
roomMembers: List<RoomMember>,
|
||||||
|
) {
|
||||||
val newTimelineItemStates = ArrayList<TimelineItem>()
|
val newTimelineItemStates = ArrayList<TimelineItem>()
|
||||||
for (index in diffCache.indices().reversed()) {
|
for (index in diffCache.indices().reversed()) {
|
||||||
val cacheItem = diffCache.get(index)
|
val cacheItem = diffCache.get(index)
|
||||||
if (cacheItem == null) {
|
if (cacheItem == null) {
|
||||||
buildAndCacheItem(timelineItems, index)?.also { timelineItemState ->
|
buildAndCacheItem(timelineItems, index, roomMembers)?.also { timelineItemState ->
|
||||||
newTimelineItemStates.add(timelineItemState)
|
newTimelineItemStates.add(timelineItemState)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -91,11 +96,12 @@ class TimelineItemsFactory @Inject constructor(
|
||||||
|
|
||||||
private suspend fun buildAndCacheItem(
|
private suspend fun buildAndCacheItem(
|
||||||
timelineItems: List<MatrixTimelineItem>,
|
timelineItems: List<MatrixTimelineItem>,
|
||||||
index: Int
|
index: Int,
|
||||||
|
roomMembers: List<RoomMember>,
|
||||||
): TimelineItem? {
|
): TimelineItem? {
|
||||||
val timelineItemState =
|
val timelineItemState =
|
||||||
when (val currentTimelineItem = timelineItems[index]) {
|
when (val currentTimelineItem = timelineItems[index]) {
|
||||||
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems)
|
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers)
|
||||||
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
|
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
|
||||||
MatrixTimelineItem.Other -> null
|
MatrixTimelineItem.Other -> null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,17 @@ package io.element.android.features.messages.impl.timeline.factories.event
|
||||||
import io.element.android.features.messages.impl.timeline.groups.canBeDisplayedInBubbleBlock
|
import io.element.android.features.messages.impl.timeline.groups.canBeDisplayedInBubbleBlock
|
||||||
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
|
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
|
||||||
import io.element.android.features.messages.impl.timeline.model.AggregatedReactionSender
|
import io.element.android.features.messages.impl.timeline.model.AggregatedReactionSender
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
|
||||||
import io.element.android.libraries.core.bool.orTrue
|
import io.element.android.libraries.core.bool.orTrue
|
||||||
|
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
@ -36,12 +40,14 @@ import javax.inject.Inject
|
||||||
class TimelineItemEventFactory @Inject constructor(
|
class TimelineItemEventFactory @Inject constructor(
|
||||||
private val contentFactory: TimelineItemContentFactory,
|
private val contentFactory: TimelineItemContentFactory,
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
|
private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun create(
|
suspend fun create(
|
||||||
currentTimelineItem: MatrixTimelineItem.Event,
|
currentTimelineItem: MatrixTimelineItem.Event,
|
||||||
index: Int,
|
index: Int,
|
||||||
timelineItems: List<MatrixTimelineItem>,
|
timelineItems: List<MatrixTimelineItem>,
|
||||||
|
roomMembers: List<RoomMember>,
|
||||||
): TimelineItem.Event {
|
): TimelineItem.Event {
|
||||||
val currentSender = currentTimelineItem.event.sender
|
val currentSender = currentTimelineItem.event.sender
|
||||||
val groupPosition =
|
val groupPosition =
|
||||||
|
|
@ -84,6 +90,7 @@ class TimelineItemEventFactory @Inject constructor(
|
||||||
sentTime = sentTime,
|
sentTime = sentTime,
|
||||||
groupPosition = groupPosition,
|
groupPosition = groupPosition,
|
||||||
reactionsState = currentTimelineItem.computeReactionsState(),
|
reactionsState = currentTimelineItem.computeReactionsState(),
|
||||||
|
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
|
||||||
localSendState = currentTimelineItem.event.localSendState,
|
localSendState = currentTimelineItem.event.localSendState,
|
||||||
inReplyTo = currentTimelineItem.event.inReplyTo(),
|
inReplyTo = currentTimelineItem.event.inReplyTo(),
|
||||||
isThreaded = currentTimelineItem.event.isThreaded(),
|
isThreaded = currentTimelineItem.event.isThreaded(),
|
||||||
|
|
@ -102,7 +109,7 @@ class TimelineItemEventFactory @Inject constructor(
|
||||||
key = reaction.key,
|
key = reaction.key,
|
||||||
currentUserId = matrixClient.sessionId,
|
currentUserId = matrixClient.sessionId,
|
||||||
senders = reaction.senders
|
senders = reaction.senders
|
||||||
.sortedByDescending{ it.timestamp }
|
.sortedByDescending { it.timestamp }
|
||||||
.map {
|
.map {
|
||||||
val date = Date(it.timestamp)
|
val date = Date(it.timestamp)
|
||||||
AggregatedReactionSender(
|
AggregatedReactionSender(
|
||||||
|
|
@ -124,6 +131,27 @@ class TimelineItemEventFactory @Inject constructor(
|
||||||
return TimelineItemReactions(aggregatedReactions.toImmutableList())
|
return TimelineItemReactions(aggregatedReactions.toImmutableList())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun MatrixTimelineItem.Event.computeReadReceiptState(
|
||||||
|
roomMembers: List<RoomMember>,
|
||||||
|
): TimelineItemReadReceipts {
|
||||||
|
return TimelineItemReadReceipts(
|
||||||
|
receipts = event.receipts
|
||||||
|
.map { receipt ->
|
||||||
|
val roomMember = roomMembers.find { it.userId == receipt.userId }
|
||||||
|
ReadReceiptData(
|
||||||
|
avatarData = AvatarData(
|
||||||
|
id = receipt.userId.value,
|
||||||
|
name = roomMember?.displayName,
|
||||||
|
url = roomMember?.avatarUrl,
|
||||||
|
size = AvatarSize.TimelineReadReceipt,
|
||||||
|
),
|
||||||
|
formattedDate = lastMessageTimestampFormatter.format(receipt.timestamp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toImmutableList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun computeGroupPosition(
|
private fun computeGroupPosition(
|
||||||
currentTimelineItem: MatrixTimelineItem.Event,
|
currentTimelineItem: MatrixTimelineItem.Event,
|
||||||
timelineItems: List<MatrixTimelineItem>,
|
timelineItems: List<MatrixTimelineItem>,
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ sealed interface TimelineItem {
|
||||||
val isMine: Boolean = false,
|
val isMine: Boolean = false,
|
||||||
val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
||||||
val reactionsState: TimelineItemReactions,
|
val reactionsState: TimelineItemReactions,
|
||||||
|
val readReceiptState: TimelineItemReadReceipts,
|
||||||
val localSendState: LocalEventSendState?,
|
val localSendState: LocalEventSendState?,
|
||||||
val inReplyTo: InReplyTo?,
|
val inReplyTo: InReplyTo?,
|
||||||
val isThreaded: Boolean,
|
val isThreaded: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -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.TimelinePresenter
|
||||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
|
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
|
||||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter
|
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetPresenter
|
||||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
|
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||||
|
|
@ -654,10 +655,12 @@ class MessagesPresenterTest {
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
encryptionService = FakeEncryptionService(),
|
encryptionService = FakeEncryptionService(),
|
||||||
verificationService = FakeSessionVerificationService(),
|
verificationService = FakeSessionVerificationService(),
|
||||||
|
featureFlagService = FakeFeatureFlagService(),
|
||||||
redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
|
redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
|
||||||
)
|
)
|
||||||
val preferencesStore = InMemoryPreferencesStore(isRichTextEditorEnabled = true)
|
val preferencesStore = InMemoryPreferencesStore(isRichTextEditorEnabled = true)
|
||||||
val actionListPresenter = ActionListPresenter(preferencesStore = preferencesStore)
|
val actionListPresenter = ActionListPresenter(preferencesStore = preferencesStore)
|
||||||
|
val readReceiptBottomSheetPresenter = ReadReceiptBottomSheetPresenter()
|
||||||
val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
|
val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
|
||||||
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
|
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
|
||||||
val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom)
|
val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom)
|
||||||
|
|
@ -670,6 +673,7 @@ class MessagesPresenterTest {
|
||||||
customReactionPresenter = customReactionPresenter,
|
customReactionPresenter = customReactionPresenter,
|
||||||
reactionSummaryPresenter = reactionSummaryPresenter,
|
reactionSummaryPresenter = reactionSummaryPresenter,
|
||||||
retrySendMenuPresenter = retrySendMenuPresenter,
|
retrySendMenuPresenter = retrySendMenuPresenter,
|
||||||
|
readReceiptBottomSheetPresenter = readReceiptBottomSheetPresenter,
|
||||||
networkMonitor = FakeNetworkMonitor(),
|
networkMonitor = FakeNetworkMonitor(),
|
||||||
snackbarDispatcher = SnackbarDispatcher(),
|
snackbarDispatcher = SnackbarDispatcher(),
|
||||||
messageSummaryFormatter = FakeMessageSummaryFormatter(),
|
messageSummaryFormatter = FakeMessageSummaryFormatter(),
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,9 @@
|
||||||
package io.element.android.features.messages.fixtures
|
package io.element.android.features.messages.fixtures
|
||||||
|
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
|
@ -31,6 +33,7 @@ import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo
|
import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
internal fun aMessageEvent(
|
internal fun aMessageEvent(
|
||||||
eventId: EventId? = AN_EVENT_ID,
|
eventId: EventId? = AN_EVENT_ID,
|
||||||
|
|
@ -50,6 +53,7 @@ internal fun aMessageEvent(
|
||||||
sentTime = "",
|
sentTime = "",
|
||||||
isMine = isMine,
|
isMine = isMine,
|
||||||
reactionsState = aTimelineItemReactions(count = 0),
|
reactionsState = aTimelineItemReactions(count = 0),
|
||||||
|
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
|
||||||
localSendState = sendState,
|
localSendState = sendState,
|
||||||
inReplyTo = inReplyTo,
|
inReplyTo = inReplyTo,
|
||||||
debugInfo = debugInfo,
|
debugInfo = debugInfo,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import io.element.android.features.messages.impl.timeline.groups.TimelineItemGro
|
||||||
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractorWithoutValidation
|
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractorWithoutValidation
|
||||||
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
||||||
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
|
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
|
||||||
|
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
|
||||||
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
|
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
|
||||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
||||||
|
|
@ -65,6 +66,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
|
||||||
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
|
failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(),
|
||||||
),
|
),
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
|
lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(),
|
||||||
),
|
),
|
||||||
virtualItemFactory = TimelineItemVirtualFactory(
|
virtualItemFactory = TimelineItemVirtualFactory(
|
||||||
daySeparatorFactory = TimelineItemDaySeparatorFactory(
|
daySeparatorFactory = TimelineItemDaySeparatorFactory(
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||||
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.session.SessionState
|
import io.element.android.features.messages.impl.timeline.session.SessionState
|
||||||
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
|
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
|
||||||
import io.element.android.features.messages.voicemessages.timeline.FakeRedactedVoiceMessageManager
|
import io.element.android.features.messages.voicemessages.timeline.FakeRedactedVoiceMessageManager
|
||||||
import io.element.android.features.messages.voicemessages.timeline.aRedactedMatrixTimeline
|
import io.element.android.features.messages.voicemessages.timeline.aRedactedMatrixTimeline
|
||||||
|
|
@ -347,6 +348,7 @@ class TimelinePresenterTest {
|
||||||
analyticsService = FakeAnalyticsService(),
|
analyticsService = FakeAnalyticsService(),
|
||||||
encryptionService = FakeEncryptionService(),
|
encryptionService = FakeEncryptionService(),
|
||||||
verificationService = FakeSessionVerificationService(),
|
verificationService = FakeSessionVerificationService(),
|
||||||
|
featureFlagService = FakeFeatureFlagService(),
|
||||||
redactedVoiceMessageManager = redactedVoiceMessageManager,
|
redactedVoiceMessageManager = redactedVoiceMessageManager,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -363,6 +365,7 @@ class TimelinePresenterTest {
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
encryptionService = FakeEncryptionService(),
|
encryptionService = FakeEncryptionService(),
|
||||||
verificationService = FakeSessionVerificationService(),
|
verificationService = FakeSessionVerificationService(),
|
||||||
|
featureFlagService = FakeFeatureFlagService(),
|
||||||
redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
|
redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.aTimelineItemReactions
|
||||||
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
|
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
|
||||||
import io.element.android.features.messages.impl.timeline.groups.computeGroupIdWith
|
import io.element.android.features.messages.impl.timeline.groups.computeGroupIdWith
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
|
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
|
||||||
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
|
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
|
||||||
|
|
@ -42,6 +44,7 @@ class TimelineItemGrouperTest {
|
||||||
senderDisplayName = "",
|
senderDisplayName = "",
|
||||||
content = TimelineItemStateEventContent(body = "a state event"),
|
content = TimelineItemStateEventContent(body = "a state event"),
|
||||||
reactionsState = aTimelineItemReactions(count = 0),
|
reactionsState = aTimelineItemReactions(count = 0),
|
||||||
|
readReceiptState = TimelineItemReadReceipts(emptyList<ReadReceiptData>().toImmutableList()),
|
||||||
localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
|
localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
|
||||||
inReplyTo = null,
|
inReplyTo = null,
|
||||||
isThreaded = false,
|
isThreaded = false,
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,7 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
|
||||||
isRemote = false,
|
isRemote = false,
|
||||||
localSendState = null,
|
localSendState = null,
|
||||||
reactions = listOf(),
|
reactions = listOf(),
|
||||||
|
receipts = listOf(),
|
||||||
sender = A_USER_ID,
|
sender = A_USER_ID,
|
||||||
senderProfile = ProfileTimelineDetails.Unavailable,
|
senderProfile = ProfileTimelineDetails.Unavailable,
|
||||||
timestamp = 9442,
|
timestamp = 9442,
|
||||||
|
|
|
||||||
|
|
@ -58,3 +58,7 @@ data class AvatarData(
|
||||||
.uppercase()
|
.uppercase()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun AvatarData.getBestName(): String {
|
||||||
|
return name?.takeIf { it.isNotEmpty() } ?: id
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,9 @@ enum class AvatarSize(val dp: Dp) {
|
||||||
|
|
||||||
TimelineRoom(32.dp),
|
TimelineRoom(32.dp),
|
||||||
TimelineSender(32.dp),
|
TimelineSender(32.dp),
|
||||||
|
TimelineReadReceipt(16.dp),
|
||||||
|
|
||||||
|
ReadReceiptList(32.dp),
|
||||||
|
|
||||||
MessageActionSender(32.dp),
|
MessageActionSender(32.dp),
|
||||||
|
|
||||||
|
|
|
||||||
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",
|
title = "Chat backup",
|
||||||
description = "Allow access to backup and restore chat history settings",
|
description = "Allow access to backup and restore chat history settings",
|
||||||
defaultValue = false,
|
defaultValue = false,
|
||||||
)
|
),
|
||||||
|
ReadReceipts(
|
||||||
|
key = "feature.readreceipts",
|
||||||
|
title = "Show read receipts",
|
||||||
|
description = null,
|
||||||
|
defaultValue = false,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ class StaticFeatureFlagProvider @Inject constructor() :
|
||||||
FeatureFlags.PinUnlock -> true
|
FeatureFlags.PinUnlock -> true
|
||||||
FeatureFlags.Mentions -> false
|
FeatureFlags.Mentions -> false
|
||||||
FeatureFlags.SecureStorage -> false
|
FeatureFlags.SecureStorage -> false
|
||||||
|
FeatureFlags.ReadReceipts -> false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ data class EventTimelineItem(
|
||||||
val isRemote: Boolean,
|
val isRemote: Boolean,
|
||||||
val localSendState: LocalEventSendState?,
|
val localSendState: LocalEventSendState?,
|
||||||
val reactions: List<EventReaction>,
|
val reactions: List<EventReaction>,
|
||||||
|
val receipts: List<Receipt>,
|
||||||
val sender: UserId,
|
val sender: UserId,
|
||||||
val senderProfile: ProfileTimelineDetails,
|
val senderProfile: ProfileTimelineDetails,
|
||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
|
|
|
||||||
|
|
@ -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.TransactionId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||||
import org.matrix.rustcomponents.sdk.Reaction
|
import org.matrix.rustcomponents.sdk.Reaction
|
||||||
import org.matrix.rustcomponents.sdk.EventItemOrigin as RustEventItemOrigin
|
import org.matrix.rustcomponents.sdk.EventItemOrigin as RustEventItemOrigin
|
||||||
import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState
|
import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState
|
||||||
import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem
|
import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem
|
||||||
import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo as RustEventTimelineItemDebugInfo
|
import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo as RustEventTimelineItemDebugInfo
|
||||||
import org.matrix.rustcomponents.sdk.ProfileDetails as RustProfileDetails
|
import org.matrix.rustcomponents.sdk.ProfileDetails as RustProfileDetails
|
||||||
|
import org.matrix.rustcomponents.sdk.Receipt as RustReceipt
|
||||||
|
|
||||||
class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper()) {
|
class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper()) {
|
||||||
|
|
||||||
|
|
@ -45,6 +47,7 @@ class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMap
|
||||||
isRemote = it.isRemote(),
|
isRemote = it.isRemote(),
|
||||||
localSendState = it.localSendState()?.map(),
|
localSendState = it.localSendState()?.map(),
|
||||||
reactions = it.reactions().map(),
|
reactions = it.reactions().map(),
|
||||||
|
receipts = it.readReceipts().map(),
|
||||||
sender = UserId(it.sender()),
|
sender = UserId(it.sender()),
|
||||||
senderProfile = it.senderProfile().map(),
|
senderProfile = it.senderProfile().map(),
|
||||||
timestamp = it.timestamp().toLong(),
|
timestamp = it.timestamp().toLong(),
|
||||||
|
|
@ -92,6 +95,15 @@ private fun List<Reaction>?.map(): List<EventReaction> {
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Map<String, RustReceipt>.map(): List<Receipt> {
|
||||||
|
return map {
|
||||||
|
Receipt(
|
||||||
|
userId = UserId(it.key),
|
||||||
|
timestamp = it.value.timestamp?.toLong() ?: 0
|
||||||
|
)
|
||||||
|
}.sortedByDescending { it.timestamp }
|
||||||
|
}
|
||||||
|
|
||||||
private fun RustEventTimelineItemDebugInfo.map(): TimelineItemDebugInfo {
|
private fun RustEventTimelineItemDebugInfo.map(): TimelineItemDebugInfo {
|
||||||
return TimelineItemDebugInfo(
|
return TimelineItemDebugInfo(
|
||||||
model = model,
|
model = model,
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
|
@ -107,6 +108,7 @@ fun anEventTimelineItem(
|
||||||
isRemote: Boolean = false,
|
isRemote: Boolean = false,
|
||||||
localSendState: LocalEventSendState? = null,
|
localSendState: LocalEventSendState? = null,
|
||||||
reactions: List<EventReaction> = emptyList(),
|
reactions: List<EventReaction> = emptyList(),
|
||||||
|
receipts: List<Receipt> = emptyList(),
|
||||||
sender: UserId = A_USER_ID,
|
sender: UserId = A_USER_ID,
|
||||||
senderProfile: ProfileTimelineDetails = aProfileTimelineDetails(),
|
senderProfile: ProfileTimelineDetails = aProfileTimelineDetails(),
|
||||||
timestamp: Long = 0L,
|
timestamp: Long = 0L,
|
||||||
|
|
@ -121,6 +123,7 @@ fun anEventTimelineItem(
|
||||||
isRemote = isRemote,
|
isRemote = isRemote,
|
||||||
localSendState = localSendState,
|
localSendState = localSendState,
|
||||||
reactions = reactions,
|
reactions = reactions,
|
||||||
|
receipts = receipts,
|
||||||
sender = sender,
|
sender = sender,
|
||||||
senderProfile = senderProfile,
|
senderProfile = senderProfile,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,13 @@ fun MatrixUserRow(
|
||||||
matrixUser: MatrixUser,
|
matrixUser: MatrixUser,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
avatarSize: AvatarSize = AvatarSize.UserListItem,
|
avatarSize: AvatarSize = AvatarSize.UserListItem,
|
||||||
|
trailingContent: @Composable (() -> Unit)? = null,
|
||||||
) = UserRow(
|
) = UserRow(
|
||||||
avatarData = matrixUser.getAvatarData(avatarSize),
|
avatarData = matrixUser.getAvatarData(avatarSize),
|
||||||
name = matrixUser.getBestName(),
|
name = matrixUser.getBestName(),
|
||||||
subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value,
|
subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
trailingContent,
|
||||||
)
|
)
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ internal fun UserRow(
|
||||||
name: String,
|
name: String,
|
||||||
subtext: String?,
|
subtext: String?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
trailingContent: @Composable (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
@ -49,7 +50,8 @@ internal fun UserRow(
|
||||||
Avatar(avatarData)
|
Avatar(avatarData)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(start = 12.dp),
|
.padding(start = 12.dp)
|
||||||
|
.weight(1f),
|
||||||
) {
|
) {
|
||||||
// Name
|
// Name
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -70,5 +72,6 @@ internal fun UserRow(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
trailingContent?.invoke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c2d69362d225e97cf9ca07e071de06c89d8ca4694214dfebde90b771a2c11f54
|
||||||
|
size 6707
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5af53d9bbf0fb985efeb48b3e8f1c808e10fff496d6aafc068e7e50cfafcd798
|
||||||
|
size 12367
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9779c9c980c32b3e8ede34e5f9e7beeca6e0000fc77e1fc1cf0a8c0eb1da0be7
|
||||||
|
size 17760
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:fbcfa1b91e13109969c31b6e2d09c5099a1c27ed15912401db7ac946323a1027
|
||||||
|
size 22729
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:97fd46b3b79c90a4104c2fd4e9f5667abf67d998d9f7136b9c18da920af4cb99
|
||||||
|
size 27836
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:e767451a900ff3629047e9931784c1b5edd3a52e44dd4d172107c71fe92671d3
|
||||||
|
size 33561
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:63620fd5bfea92c632a0a4b1d88eabd51a973e7e4281af2b662b8955cc209e0a
|
||||||
|
size 6549
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:6a8a1900a56b87bb96135cbbfe92e504b486775b5b0ba02bb6626a351081ef37
|
||||||
|
size 12272
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:63e229114afc8317c1690f817e4ba4a275c581c9f107c75e88c99728c6698219
|
||||||
|
size 17755
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:175c2bed417fdfd3c80b8ce3079675decc3a27cd8df6d06c00db0015d193cb4e
|
||||||
|
size 22808
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:bb15b22c4fc99d3f7c655e85d1e2c1b9d0ae4478908a4f40234a8a4651df7d49
|
||||||
|
size 28080
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:21078a2cfa738bc3a2a7f36c52ea7c6ff9c83a9885f6059fe472121f08a08c05
|
||||||
|
size 33681
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ec996e41aaecde031e8b4568f9dbe08b359b4390589b7e25c8da084a0fa9fc93
|
||||||
|
size 5502
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:379c809e565791ae199367bf9e7fe5d2e7767bbd5f33f51309d0caacc976dd5f
|
||||||
|
size 5356
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ec996e41aaecde031e8b4568f9dbe08b359b4390589b7e25c8da084a0fa9fc93
|
||||||
|
size 5502
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:61d4e36b624371171b6ee1909318dcc2ba96c55b42a92fb880b25abbbff48645
|
||||||
|
size 5080
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:821606acb7dcd9bdcad17bff0c33268f2ab22569e846e610c4401d852e17be2e
|
||||||
|
size 5628
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3552965164107b4a6881a5834bef98dfba2494c7c2186f6a6b8315a64c0c0d6b
|
||||||
|
size 6233
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:8fedc10863be4b1777614ecf777ee24b43a4d9fb48059833cd08a122e6cec221
|
||||||
|
size 6410
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b980fa478c1d508284323e094281db096a7b6360339493c59b0c1fd69c2288c3
|
||||||
|
size 6609
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3663e96a724a0a75e693c4c516e6adedc4c20967604c6931142972fcee8fe488
|
||||||
|
size 5475
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:fec37a2d969d74fba0c26a0ff179f204c52c8e3d0c4a2b2d189ba071e4428887
|
||||||
|
size 5339
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3663e96a724a0a75e693c4c516e6adedc4c20967604c6931142972fcee8fe488
|
||||||
|
size 5475
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d33ed61dd503fa4b3956e27cc7a06090c6eb589cf5dbdfd0dd781dfd335b774d
|
||||||
|
size 5213
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0d29f6726900317e6869ae00759b9a861a1036a1a8280621bc1ed47c37752a18
|
||||||
|
size 5901
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0e69990e066a3eb36e66820e2414397fdb415151ba76d9c062588e58e01e6e97
|
||||||
|
size 6425
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c3cfd9aaf78a8e66993d269e3d5b49966eb067f2df1dc2582ba066d5e6a086b0
|
||||||
|
size 6608
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:91961cd20e51f2065a48720fe3a5067d3f640a7e87d5f369eb45842ba1f256f7
|
||||||
|
size 6811
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:748a2562d8c3e9b5e4def3b8ea3c782868e56f449de1b1ddb4bc36cd84945318
|
oid sha256:a5953d21cc18cb10614361f7343fed9375ca851677b4a380e97e86851a082c3b
|
||||||
size 20642
|
size 17118
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:9a6ce0c3b6fe3ac503ee7019dee234a9f96552e1106fe6858030a7c70c506d4f
|
oid sha256:d8134edc79b20771f89e8b15eed3b3660e46ab6f3805130252f32d2fb6b26cb6
|
||||||
size 19869
|
size 16745
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b5af89d5988d168ee2cbd30e3ba6447f5cd1399d35a2ca04c7348e74cb623f37
|
oid sha256:9e6022f750c6e26d822d4ffd1172cd8c34f7afe0188cce2da9f72d3dd8e4f45a
|
||||||
size 22501
|
size 18030
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:0c49437c9f943cf044ec64a0ecab2b70d1fa811a34aff4baa8ed0ed846a4f5c5
|
oid sha256:d5901d9bf468a0ca5fc7c3d6d308898b802b57967afe4d61c814f86e2194ee16
|
||||||
size 18595
|
size 17829
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:063cdf0bbdfc846d3040af143f79a0b6eb325beab62cba5657236d028db6bc26
|
oid sha256:6aa7aa8f18b1a7d3a68c65e5275a69d49138e51046d913686f111f809cb2235f
|
||||||
size 17267
|
size 17054
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:8118e782f4ec7b67b6aa148f1bb31c9d30f03f72a7abfc73dc8d5094a5a7545c
|
oid sha256:e0dd5b1c404dd33a70083e7d775bf4c89e8eb1823e72711e8aa6c4947fb49b6e
|
||||||
size 21791
|
size 19756
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:032ed67865d3ad800371e193ea922589f27cf03697e3c91c8d698957befa8d42
|
oid sha256:748a2562d8c3e9b5e4def3b8ea3c782868e56f449de1b1ddb4bc36cd84945318
|
||||||
size 14590
|
size 20642
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue