diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt new file mode 100644 index 0000000000..625282f1e8 --- /dev/null +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt @@ -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 +} diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 809cc9441e..6faf07f916 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -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) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index c9063f2a79..dbb8778131 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -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, ) }, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 0a0feedf65..fdd182b694 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -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 { 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() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index affb77e88a..ed98fedfa6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 5b6b8c6d1d..1c08291f24 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -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 = {}, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index cfb81f71a7..ec277fb96b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -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 = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewState.kt new file mode 100644 index 0000000000..2bf1d524ae --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewState.kt @@ -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, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateProvider.kt new file mode 100644 index 0000000000..31134812f3 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateProvider.kt @@ -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 { + override val values: Sequence + get() = sequenceOf( + aReadReceiptViewState(), + aReadReceiptViewState(sendState = LocalEventSendState.NotSentYet), + aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId"))), + aReadReceiptViewState( + sendState = LocalEventSendState.Sent(EventId("\$eventId")), + receipts = mutableListOf().apply { repeat(1) { add(aReadReceiptData(it)) } }, + ), + aReadReceiptViewState( + sendState = LocalEventSendState.Sent(EventId("\$eventId")), + receipts = mutableListOf().apply { repeat(2) { add(aReadReceiptData(it)) } }, + ), + aReadReceiptViewState( + sendState = LocalEventSendState.Sent(EventId("\$eventId")), + receipts = mutableListOf().apply { repeat(3) { add(aReadReceiptData(it)) } }, + ), + aReadReceiptViewState( + sendState = LocalEventSendState.Sent(EventId("\$eventId")), + receipts = mutableListOf().apply { repeat(4) { add(aReadReceiptData(it)) } }, + ), + aReadReceiptViewState( + sendState = LocalEventSendState.Sent(EventId("\$eventId")), + receipts = mutableListOf().apply { repeat(5) { add(aReadReceiptData(it)) } }, + ), + ) +} + +private fun aReadReceiptViewState( + sendState: LocalEventSendState? = null, + receipts: List = 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, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt new file mode 100644 index 0000000000..b826291c2f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt @@ -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, + 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 = {}, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt index 8c894bc99a..e48720f92a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt @@ -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, + roomMembers: List?, ) = withContext(dispatchers.computation) { lock.withLock { diffCacheUpdater.updateWith(timelineItems) - buildAndEmitTimelineItemStates(timelineItems) + buildAndEmitTimelineItemStates(timelineItems, roomMembers) } } - private suspend fun buildAndEmitTimelineItemStates(timelineItems: List) { + private suspend fun buildAndEmitTimelineItemStates( + timelineItems: List, + roomMembers: List?, + ) { val newTimelineItemStates = ArrayList() 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, - index: Int + index: Int, + roomMembers: List?, ): 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 } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt index 14f8429c85..399eeec539 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt @@ -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, + roomMembers: List?, ): 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?, + ): 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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt index bd3090e390..5ceaba5550 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemReadReceipts.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemReadReceipts.kt new file mode 100644 index 0000000000..fca0040790 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemReadReceipts.kt @@ -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, + ) : TimelineItemReadReceipts +} + +data class ReadReceiptData( + val avatarData: AvatarData, + val timestamp: Long +) + +fun TimelineItemReadReceipts.receipts(): ImmutableList = when (this) { + TimelineItemReadReceipts.Hidden -> persistentListOf() + is TimelineItemReadReceipts.ReadReceipts -> receipts +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index b2004ed204..cb3858fa47 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -37,6 +37,7 @@ enum class AvatarSize(val dp: Dp) { TimelineRoom(32.dp), TimelineSender(32.dp), + TimelineReadReceipt(16.dp), MessageActionSender(32.dp), diff --git a/libraries/designsystem/src/main/res/drawable/ic_sending.xml b/libraries/designsystem/src/main/res/drawable/ic_sending.xml new file mode 100644 index 0000000000..92a8312f70 --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_sending.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/libraries/designsystem/src/main/res/drawable/ic_sent.xml b/libraries/designsystem/src/main/res/drawable/ic_sent.xml new file mode 100644 index 0000000000..9a3ea31479 --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_sent.xml @@ -0,0 +1,29 @@ + + + + + + + +