Browse Source

Read receipt: model and UI.

pull/1834/head
Benoit Marty 10 months ago committed by Benoit Marty
parent
commit
87d5ed82b9
  1. 21
      appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt
  2. 1
      features/messages/impl/build.gradle.kts
  3. 6
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
  4. 18
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
  5. 5
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
  6. 6
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
  7. 39
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
  8. 26
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewState.kt
  9. 75
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateProvider.kt
  10. 186
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt
  11. 16
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt
  12. 29
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt
  13. 1
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt
  14. 40
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemReadReceipts.kt
  15. 1
      libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt
  16. 29
      libraries/designsystem/src/main/res/drawable/ic_sending.xml
  17. 29
      libraries/designsystem/src/main/res/drawable/ic_sent.xml

21
appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt

@ -0,0 +1,21 @@ @@ -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
}

1
features/messages/impl/build.gradle.kts

@ -33,6 +33,7 @@ dependencies { @@ -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)

6
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt

@ -212,6 +212,10 @@ fun MessagesView( @@ -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( @@ -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( @@ -381,6 +386,7 @@ private fun MessagesViewContent(
onReactionClicked = onReactionClicked,
onReactionLongClicked = onReactionLongClicked,
onMoreReactionsClicked = onMoreReactionsClicked,
onReadReceiptClick = onReadReceiptClick,
onSwipeToReply = onSwipeToReply,
)
},

18
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 @@ -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( @@ -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( @@ -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( @@ -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()

5
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 @@ -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( @@ -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( @@ -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,

6
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt

@ -92,6 +92,7 @@ fun TimelineView( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -362,6 +367,7 @@ internal fun TimelineViewPreview(
onReactionLongClicked = { _, _ -> },
onMoreReactionsClicked = {},
onSwipeToReply = {},
onReadReceiptClick = {},
)
}
}

39
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 @@ -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 @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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 { @@ -659,6 +688,7 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onTimestampClicked = {},
onSwipeToReply = {},
eventSink = {},
@ -680,6 +710,7 @@ internal fun TimelineItemEventRowPreview() = ElementPreview { @@ -680,6 +710,7 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onTimestampClicked = {},
onSwipeToReply = {},
eventSink = {},
@ -719,6 +750,7 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview { @@ -719,6 +750,7 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onTimestampClicked = {},
onSwipeToReply = {},
eventSink = {},
@ -742,6 +774,7 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview { @@ -742,6 +774,7 @@ internal fun TimelineItemEventRowWithReplyPreview() = ElementPreview {
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onTimestampClicked = {},
onSwipeToReply = {},
eventSink = {},
@ -793,6 +826,7 @@ internal fun TimelineItemEventRowTimestampPreview( @@ -793,6 +826,7 @@ internal fun TimelineItemEventRowTimestampPreview(
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onTimestampClicked = {},
onSwipeToReply = {},
eventSink = {},
@ -825,6 +859,7 @@ internal fun TimelineItemEventRowWithManyReactionsPreview() = ElementPreview { @@ -825,6 +859,7 @@ internal fun TimelineItemEventRowWithManyReactionsPreview() = ElementPreview {
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onSwipeToReply = {},
onTimestampClicked = {},
eventSink = {},
@ -850,6 +885,7 @@ internal fun TimelineItemEventRowLongSenderNamePreview() = ElementPreviewLight { @@ -850,6 +885,7 @@ internal fun TimelineItemEventRowLongSenderNamePreview() = ElementPreviewLight {
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onSwipeToReply = {},
onTimestampClicked = {},
eventSink = {},
@ -871,6 +907,7 @@ internal fun TimelineItemEventTimestampBelowPreview() = ElementPreviewLight { @@ -871,6 +907,7 @@ internal fun TimelineItemEventTimestampBelowPreview() = ElementPreviewLight {
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
onSwipeToReply = {},
onTimestampClicked = {},
eventSink = {},

26
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewState.kt

@ -0,0 +1,26 @@ @@ -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>,
)

75
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateProvider.kt

@ -0,0 +1,75 @@ @@ -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,
)

186
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt

@ -0,0 +1,186 @@ @@ -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 = {},
)
}

16
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 @@ -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( @@ -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( @@ -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
}

29
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 @@ -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( @@ -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( @@ -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( @@ -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( @@ -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>,

1
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt

@ -65,6 +65,7 @@ sealed interface TimelineItem { @@ -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,

40
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemReadReceipts.kt

@ -0,0 +1,40 @@ @@ -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
}

1
libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt

@ -37,6 +37,7 @@ enum class AvatarSize(val dp: Dp) { @@ -37,6 +37,7 @@ enum class AvatarSize(val dp: Dp) {
TimelineRoom(32.dp),
TimelineSender(32.dp),
TimelineReadReceipt(16.dp),
MessageActionSender(32.dp),

29
libraries/designsystem/src/main/res/drawable/ic_sending.xml

@ -0,0 +1,29 @@ @@ -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

@ -0,0 +1,29 @@ @@ -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>
Loading…
Cancel
Save