Read receipt: model and UI.
This commit is contained in:
parent
651a64b51b
commit
87d5ed82b9
17 changed files with 519 additions and 9 deletions
|
|
@ -33,6 +33,7 @@ dependencies {
|
|||
implementation(projects.anvilannotations)
|
||||
anvil(projects.anvilcodegen)
|
||||
api(projects.features.messages.api)
|
||||
implementation(projects.appconfig)
|
||||
implementation(projects.features.call)
|
||||
implementation(projects.features.location.api)
|
||||
implementation(projects.features.poll.api)
|
||||
|
|
|
|||
|
|
@ -212,6 +212,10 @@ fun MessagesView(
|
|||
onReactionClicked = ::onEmojiReactionClicked,
|
||||
onReactionLongClicked = ::onEmojiReactionLongClicked,
|
||||
onMoreReactionsClicked = ::onMoreReactionsClicked,
|
||||
onReadReceiptClick = { // targetEvent ->
|
||||
// TODO Open bottom sheet with read receipts
|
||||
// state.eventSink(MessagesEvents.HandleAction(TimelineItemAction.ShowReadReceipts, targetEvent))
|
||||
},
|
||||
onSendLocationClicked = onSendLocationClicked,
|
||||
onCreatePollClicked = onCreatePollClicked,
|
||||
onSwipeToReply = { targetEvent ->
|
||||
|
|
@ -310,6 +314,7 @@ private fun MessagesViewContent(
|
|||
onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
|
||||
onReactionLongClicked: (key: String, TimelineItem.Event) -> Unit,
|
||||
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
|
||||
onReadReceiptClick: (TimelineItem.Event) -> Unit,
|
||||
onMessageLongClicked: (TimelineItem.Event) -> Unit,
|
||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||
onSendLocationClicked: () -> Unit,
|
||||
|
|
@ -381,6 +386,7 @@ private fun MessagesViewContent(
|
|||
onReactionClicked = onReactionClicked,
|
||||
onReactionLongClicked = onReactionLongClicked,
|
||||
onMoreReactionsClicked = onMoreReactionsClicked,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -34,11 +34,14 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
|||
import io.element.android.features.messages.impl.timeline.session.SessionState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.encryption.BackupState
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MessageEventType
|
||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
|
|
@ -63,6 +66,7 @@ class TimelinePresenter @Inject constructor(
|
|||
private val analyticsService: AnalyticsService,
|
||||
private val verificationService: SessionVerificationService,
|
||||
private val encryptionService: EncryptionService,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : Presenter<TimelineState> {
|
||||
|
||||
private val timeline = room.timeline
|
||||
|
|
@ -97,6 +101,9 @@ class TimelinePresenter @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
val readReceiptsEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.ReadReceipts).collectAsState(initial = false)
|
||||
val membersState by room.membersStateFlow.collectAsState()
|
||||
|
||||
fun handleEvents(event: TimelineEvents) {
|
||||
when (event) {
|
||||
TimelineEvents.LoadMore -> localScope.paginateBackwards()
|
||||
|
|
@ -136,7 +143,16 @@ class TimelinePresenter @Inject constructor(
|
|||
LaunchedEffect(Unit) {
|
||||
timeline
|
||||
.timelineItems
|
||||
.onEach(timelineItemsFactory::replaceWith)
|
||||
.onEach {
|
||||
timelineItemsFactory.replaceWith(
|
||||
timelineItems = it,
|
||||
roomMembers = if (readReceiptsEnabled) {
|
||||
membersState.roomMembers()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
.onEach { timelineItems ->
|
||||
if (timelineItems.isEmpty()) {
|
||||
paginateBackwards()
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline
|
|||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
|
||||
import io.element.android.features.messages.impl.timeline.model.anAggregatedReaction
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
|
||||
|
|
@ -118,11 +119,12 @@ internal fun aTimelineItemEvent(
|
|||
senderDisplayName: String = "Sender",
|
||||
content: TimelineItemEventContent = aTimelineItemTextContent(),
|
||||
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
||||
sendState: LocalEventSendState = LocalEventSendState.Sent(eventId),
|
||||
sendState: LocalEventSendState? = if (isMine) LocalEventSendState.Sent(eventId) else null,
|
||||
inReplyTo: InReplyTo? = null,
|
||||
isThreaded: Boolean = false,
|
||||
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
|
||||
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
|
||||
readReceiptState: TimelineItemReadReceipts = TimelineItemReadReceipts.Hidden,
|
||||
): TimelineItem.Event {
|
||||
return TimelineItem.Event(
|
||||
id = UUID.randomUUID().toString(),
|
||||
|
|
@ -132,6 +134,7 @@ internal fun aTimelineItemEvent(
|
|||
senderAvatar = AvatarData("@senderId:domain", "sender", size = AvatarSize.TimelineSender),
|
||||
content = content,
|
||||
reactionsState = timelineItemReactions,
|
||||
readReceiptState = readReceiptState,
|
||||
sentTime = "12:34",
|
||||
isMine = isMine,
|
||||
senderDisplayName = senderDisplayName,
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ fun TimelineView(
|
|||
onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit,
|
||||
onReactionLongClicked: (emoji: String, TimelineItem.Event) -> Unit,
|
||||
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
|
||||
onReadReceiptClick: (TimelineItem.Event) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun onReachedLoadMore() {
|
||||
|
|
@ -135,6 +136,7 @@ fun TimelineView(
|
|||
onReactionClick = onReactionClicked,
|
||||
onReactionLongClick = onReactionLongClicked,
|
||||
onMoreReactionsClick = onMoreReactionsClicked,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
sessionState = state.sessionState,
|
||||
eventSink = state.eventSink,
|
||||
|
|
@ -179,6 +181,7 @@ private fun TimelineItemRow(
|
|||
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
||||
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
|
||||
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
|
||||
onReadReceiptClick: (TimelineItem.Event) -> Unit,
|
||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||
eventSink: (TimelineEvents) -> Unit,
|
||||
|
|
@ -214,6 +217,7 @@ private fun TimelineItemRow(
|
|||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onSwipeToReply = { onSwipeToReply(timelineItem) },
|
||||
eventSink = eventSink,
|
||||
|
|
@ -255,6 +259,7 @@ private fun TimelineItemRow(
|
|||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
onMoreReactionsClick = onMoreReactionsClick,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
eventSink = eventSink,
|
||||
onSwipeToReply = {},
|
||||
)
|
||||
|
|
@ -362,6 +367,7 @@ internal fun TimelineViewPreview(
|
|||
onReactionLongClicked = { _, _ -> },
|
||||
onMoreReactionsClicked = {},
|
||||
onSwipeToReply = {},
|
||||
onReadReceiptClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
|||
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||
import io.element.android.features.messages.impl.timeline.components.event.toExtraPadding
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
|
||||
|
|
@ -77,6 +79,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.receipts
|
||||
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
|
||||
import io.element.android.libraries.designsystem.components.EqualWidthColumn
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
|
|
@ -124,6 +127,7 @@ fun TimelineItemEventRow(
|
|||
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
|
||||
onReactionLongClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
|
||||
onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit,
|
||||
onReadReceiptClick: (event: TimelineItem.Event) -> Unit,
|
||||
onSwipeToReply: () -> Unit,
|
||||
eventSink: (TimelineEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
|
|
@ -183,6 +187,7 @@ fun TimelineItemEventRow(
|
|||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
|
||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||
onReadReceiptsClicked = { onReadReceiptClick(event) },
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
|
|
@ -200,6 +205,7 @@ fun TimelineItemEventRow(
|
|||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
|
||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||
onReadReceiptsClicked = { onReadReceiptClick(event) },
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
|
|
@ -240,6 +246,7 @@ private fun TimelineItemEventRowContent(
|
|||
inReplyToClicked: () -> Unit,
|
||||
onUserDataClicked: () -> Unit,
|
||||
onReactionClicked: (emoji: String) -> Unit,
|
||||
onReadReceiptsClicked: () -> Unit,
|
||||
onReactionLongClicked: (emoji: String) -> Unit,
|
||||
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
|
||||
eventSink: (TimelineEvents) -> Unit,
|
||||
|
|
@ -256,7 +263,12 @@ private fun TimelineItemEventRowContent(
|
|||
.wrapContentHeight()
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
val (sender, message, reactions) = createRefs()
|
||||
val (
|
||||
sender,
|
||||
message,
|
||||
reactions,
|
||||
readReceipts,
|
||||
) = createRefs()
|
||||
|
||||
// Sender
|
||||
val avatarStrokeSize = 3.dp
|
||||
|
|
@ -322,6 +334,23 @@ private fun TimelineItemEventRowContent(
|
|||
.padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Read receipts / Send state
|
||||
TimelineItemReadReceiptView(
|
||||
state = ReadReceiptViewState(
|
||||
sendState = event.localSendState,
|
||||
receipts = event.readReceiptState.receipts(),
|
||||
),
|
||||
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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -659,6 +688,7 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
|
|||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onReadReceiptClick = {},
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
eventSink = {},
|
||||
|
|
@ -680,6 +710,7 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
|
|||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onReadReceiptClick = {},
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
eventSink = {},
|
||||
|
|
@ -719,6 +750,7 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
|
|||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onReadReceiptClick = {},
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
eventSink = {},
|
||||
|
|
@ -742,6 +774,7 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
|
|||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onReadReceiptClick = {},
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
eventSink = {},
|
||||
|
|
@ -793,6 +826,7 @@ internal fun TimelineItemEventRowTimestampPreview(
|
|||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onReadReceiptClick = {},
|
||||
onTimestampClicked = {},
|
||||
onSwipeToReply = {},
|
||||
eventSink = {},
|
||||
|
|
@ -825,6 +859,7 @@ internal fun TimelineItemEventRowWithManyReactionsPreview() = ElementPreview {
|
|||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onReadReceiptClick = {},
|
||||
onSwipeToReply = {},
|
||||
onTimestampClicked = {},
|
||||
eventSink = {},
|
||||
|
|
@ -850,6 +885,7 @@ internal fun TimelineItemEventRowLongSenderNamePreview() = ElementPreviewLight {
|
|||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onReadReceiptClick = {},
|
||||
onSwipeToReply = {},
|
||||
onTimestampClicked = {},
|
||||
eventSink = {},
|
||||
|
|
@ -871,6 +907,7 @@ internal fun TimelineItemEventTimestampBelowPreview() = ElementPreviewLight {
|
|||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
onMoreReactionsClick = {},
|
||||
onReadReceiptClick = {},
|
||||
onSwipeToReply = {},
|
||||
onTimestampClicked = {},
|
||||
eventSink = {},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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 receipts: ImmutableList<ReadReceiptData>,
|
||||
)
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* 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,
|
||||
receipts: List<ReadReceiptData> = emptyList(),
|
||||
) = ReadReceiptViewState(
|
||||
sendState = sendState,
|
||||
receipts = receipts.toImmutableList(),
|
||||
)
|
||||
|
||||
private fun aReadReceiptData(
|
||||
index: Int,
|
||||
avatarData: AvatarData = anAvatarData(
|
||||
id = "$index",
|
||||
size = AvatarSize.TimelineReadReceipt
|
||||
),
|
||||
timestamp: Long = 1629780000000L,
|
||||
) = ReadReceiptData(
|
||||
avatarData = avatarData,
|
||||
timestamp = timestamp,
|
||||
)
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
/*
|
||||
* 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.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.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 kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Composable
|
||||
fun TimelineItemReadReceiptView(
|
||||
state: ReadReceiptViewState,
|
||||
onReadReceiptsClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (state.sendState) {
|
||||
LocalEventSendState.Canceled -> Unit
|
||||
LocalEventSendState.NotSentYet -> {
|
||||
ReadReceiptsRow(modifier) {
|
||||
Icon(
|
||||
modifier = Modifier.padding(2.dp),
|
||||
resourceId = CommonDrawables.ic_sending,
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.iconSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
is LocalEventSendState.SendingFailed -> {
|
||||
// Error? The timestamp is already displayed in red
|
||||
}
|
||||
is LocalEventSendState.Sent -> {
|
||||
if (state.receipts.isEmpty()) {
|
||||
ReadReceiptsRow(modifier = modifier) {
|
||||
Icon(
|
||||
modifier = Modifier.padding(2.dp),
|
||||
resourceId = CommonDrawables.ic_sent,
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.iconSecondary
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ReadReceiptsRow(modifier = modifier) {
|
||||
ReadReceiptsAvatars(
|
||||
receipts = state.receipts,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.clickable { onReadReceiptsClicked() }
|
||||
.padding(2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
null -> {
|
||||
if (state.receipts.isNotEmpty()) {
|
||||
ReadReceiptsRow(modifier = modifier) {
|
||||
ReadReceiptsAvatars(
|
||||
receipts = state.receipts,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.clickable { onReadReceiptsClicked() }
|
||||
.padding(2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp - avatarStrokeSize),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
receipts
|
||||
.take(TimelineConfig.maxReadReceiptToDisplay)
|
||||
.reversed()
|
||||
.forEachIndexed { index, it ->
|
||||
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 = it.avatarData,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (receipts.size > 3) {
|
||||
Text(
|
||||
text = "+" + (receipts.size - TimelineConfig.maxReadReceiptToDisplay),
|
||||
style = ElementTheme.typography.fontBodyXsRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineItemReactionsViewPreview(
|
||||
@PreviewParameter(ReadReceiptViewStateProvider::class) state: ReadReceiptViewState,
|
||||
) = ElementPreview {
|
||||
TimelineItemReadReceiptView(
|
||||
state = state,
|
||||
onReadReceiptsClicked = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
|||
import io.element.android.libraries.androidutils.diff.DiffCacheUpdater
|
||||
import io.element.android.libraries.androidutils.diff.MutableListDiffCache
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -66,19 +67,23 @@ class TimelineItemsFactory @Inject constructor(
|
|||
|
||||
suspend fun replaceWith(
|
||||
timelineItems: List<MatrixTimelineItem>,
|
||||
roomMembers: List<RoomMember>?,
|
||||
) = withContext(dispatchers.computation) {
|
||||
lock.withLock {
|
||||
diffCacheUpdater.updateWith(timelineItems)
|
||||
buildAndEmitTimelineItemStates(timelineItems)
|
||||
buildAndEmitTimelineItemStates(timelineItems, roomMembers)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun buildAndEmitTimelineItemStates(timelineItems: List<MatrixTimelineItem>) {
|
||||
private suspend fun buildAndEmitTimelineItemStates(
|
||||
timelineItems: List<MatrixTimelineItem>,
|
||||
roomMembers: List<RoomMember>?,
|
||||
) {
|
||||
val newTimelineItemStates = ArrayList<TimelineItem>()
|
||||
for (index in diffCache.indices().reversed()) {
|
||||
val cacheItem = diffCache.get(index)
|
||||
if (cacheItem == null) {
|
||||
buildAndCacheItem(timelineItems, index)?.also { timelineItemState ->
|
||||
buildAndCacheItem(timelineItems, index, roomMembers)?.also { timelineItemState ->
|
||||
newTimelineItemStates.add(timelineItemState)
|
||||
}
|
||||
} else {
|
||||
|
|
@ -91,11 +96,12 @@ class TimelineItemsFactory @Inject constructor(
|
|||
|
||||
private suspend fun buildAndCacheItem(
|
||||
timelineItems: List<MatrixTimelineItem>,
|
||||
index: Int
|
||||
index: Int,
|
||||
roomMembers: List<RoomMember>?,
|
||||
): TimelineItem? {
|
||||
val timelineItemState =
|
||||
when (val currentTimelineItem = timelineItems[index]) {
|
||||
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems)
|
||||
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers)
|
||||
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
|
||||
MatrixTimelineItem.Other -> null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,13 +19,16 @@ package io.element.android.features.messages.impl.timeline.factories.event
|
|||
import io.element.android.features.messages.impl.timeline.groups.canBeDisplayedInBubbleBlock
|
||||
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
|
||||
import io.element.android.features.messages.impl.timeline.model.AggregatedReactionSender
|
||||
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
|
||||
import io.element.android.libraries.core.bool.orTrue
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
|
@ -42,6 +45,7 @@ class TimelineItemEventFactory @Inject constructor(
|
|||
currentTimelineItem: MatrixTimelineItem.Event,
|
||||
index: Int,
|
||||
timelineItems: List<MatrixTimelineItem>,
|
||||
roomMembers: List<RoomMember>?,
|
||||
): TimelineItem.Event {
|
||||
val currentSender = currentTimelineItem.event.sender
|
||||
val groupPosition =
|
||||
|
|
@ -84,6 +88,7 @@ class TimelineItemEventFactory @Inject constructor(
|
|||
sentTime = sentTime,
|
||||
groupPosition = groupPosition,
|
||||
reactionsState = currentTimelineItem.computeReactionsState(),
|
||||
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
|
||||
localSendState = currentTimelineItem.event.localSendState,
|
||||
inReplyTo = currentTimelineItem.event.inReplyTo(),
|
||||
isThreaded = currentTimelineItem.event.isThreaded(),
|
||||
|
|
@ -102,7 +107,7 @@ class TimelineItemEventFactory @Inject constructor(
|
|||
key = reaction.key,
|
||||
currentUserId = matrixClient.sessionId,
|
||||
senders = reaction.senders
|
||||
.sortedByDescending{ it.timestamp }
|
||||
.sortedByDescending { it.timestamp }
|
||||
.map {
|
||||
val date = Date(it.timestamp)
|
||||
AggregatedReactionSender(
|
||||
|
|
@ -124,6 +129,28 @@ class TimelineItemEventFactory @Inject constructor(
|
|||
return TimelineItemReactions(aggregatedReactions.toImmutableList())
|
||||
}
|
||||
|
||||
private fun MatrixTimelineItem.Event.computeReadReceiptState(
|
||||
roomMembers: List<RoomMember>?,
|
||||
): TimelineItemReadReceipts {
|
||||
if (roomMembers == null) return TimelineItemReadReceipts.Hidden
|
||||
return TimelineItemReadReceipts.ReadReceipts(
|
||||
receipts = event.receipts
|
||||
.map { receipt ->
|
||||
val roomMember = roomMembers.find { it.userId == receipt.userId }
|
||||
ReadReceiptData(
|
||||
avatarData = AvatarData(
|
||||
id = receipt.userId.value,
|
||||
name = roomMember?.displayName ?: receipt.userId.value,
|
||||
url = roomMember?.avatarUrl,
|
||||
size = AvatarSize.TimelineReadReceipt,
|
||||
),
|
||||
timestamp = receipt.timestamp
|
||||
)
|
||||
}
|
||||
.toImmutableList()
|
||||
)
|
||||
}
|
||||
|
||||
private fun computeGroupPosition(
|
||||
currentTimelineItem: MatrixTimelineItem.Event,
|
||||
timelineItems: List<MatrixTimelineItem>,
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ sealed interface TimelineItem {
|
|||
val isMine: Boolean = false,
|
||||
val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
||||
val reactionsState: TimelineItemReactions,
|
||||
val readReceiptState: TimelineItemReadReceipts,
|
||||
val localSendState: LocalEventSendState?,
|
||||
val inReplyTo: InReplyTo?,
|
||||
val isThreaded: Boolean,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* 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
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
sealed interface TimelineItemReadReceipts {
|
||||
/** Value when the feature is disabled */
|
||||
data object Hidden : TimelineItemReadReceipts
|
||||
|
||||
data class ReadReceipts(
|
||||
val receipts: ImmutableList<ReadReceiptData>,
|
||||
) : TimelineItemReadReceipts
|
||||
}
|
||||
|
||||
data class ReadReceiptData(
|
||||
val avatarData: AvatarData,
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
fun TimelineItemReadReceipts.receipts(): ImmutableList<ReadReceiptData> = when (this) {
|
||||
TimelineItemReadReceipts.Hidden -> persistentListOf()
|
||||
is TimelineItemReadReceipts.ReadReceipts -> receipts
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue