Benoit Marty
10 months ago
committed by
Benoit Marty
17 changed files with 519 additions and 9 deletions
@ -0,0 +1,21 @@ |
|||||||
|
/* |
||||||
|
* Copyright (c) 2023 New Vector Ltd |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package io.element.android.appconfig |
||||||
|
|
||||||
|
object TimelineConfig { |
||||||
|
const val maxReadReceiptToDisplay = 3 |
||||||
|
} |
@ -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 = {}, |
||||||
|
) |
||||||
|
} |
@ -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 |
||||||
|
} |
@ -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> |
@ -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…
Reference in new issue