Merge pull request #826 from vector-im/feature/bma/swipeAction
Improve swipe to reply rendering
This commit is contained in:
commit
0fbf799d15
18 changed files with 205 additions and 93 deletions
1
.idea/dictionaries/shared.xml
generated
1
.idea/dictionaries/shared.xml
generated
|
|
@ -9,6 +9,7 @@
|
|||
<w>placeables</w>
|
||||
<w>showkase</w>
|
||||
<w>snackbar</w>
|
||||
<w>swipeable</w>
|
||||
<w>textfields</w>
|
||||
</words>
|
||||
</dictionary>
|
||||
|
|
|
|||
|
|
@ -247,6 +247,7 @@ koverMerged {
|
|||
excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState"
|
||||
excludes += "io.element.android.features.location.impl.map.MapState"
|
||||
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState*"
|
||||
excludes += "io.element.android.libraries.designsystem.swipe.SwipeableActionsState*"
|
||||
}
|
||||
bound {
|
||||
minValue = 90
|
||||
|
|
|
|||
|
|
@ -14,13 +14,13 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.absoluteOffset
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -35,26 +36,25 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DismissDirection
|
||||
import androidx.compose.material3.DismissState
|
||||
import androidx.compose.material3.DismissValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SwipeToDismiss
|
||||
import androidx.compose.material3.rememberDismissState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.platform.ViewConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
|
|
@ -66,6 +66,7 @@ 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.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||
|
|
@ -78,6 +79,9 @@ import io.element.android.libraries.designsystem.components.avatar.Avatar
|
|||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState
|
||||
import io.element.android.libraries.designsystem.swipe.rememberSwipeableActionsState
|
||||
import io.element.android.libraries.designsystem.text.toPx
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
|
@ -93,7 +97,10 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
|||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jsoup.Jsoup
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun TimelineItemEventRow(
|
||||
|
|
@ -110,6 +117,7 @@ fun TimelineItemEventRow(
|
|||
onSwipeToReply: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
fun onUserDataClicked() {
|
||||
|
|
@ -121,56 +129,88 @@ fun TimelineItemEventRow(
|
|||
inReplyToClick(inReplyToEventId)
|
||||
}
|
||||
|
||||
if (canReply) {
|
||||
val dismissState = rememberDismissState(
|
||||
confirmValueChange = {
|
||||
if (it == DismissValue.DismissedToEnd) {
|
||||
onSwipeToReply()
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
if (event.groupPosition.isNew()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
}
|
||||
if (canReply) {
|
||||
val state: SwipeableActionsState = rememberSwipeableActionsState()
|
||||
val offset = state.offset.value
|
||||
val swipeThresholdPx = 40.dp.toPx()
|
||||
val thresholdCrossed = abs(offset) > swipeThresholdPx
|
||||
SwipeSensitivity(3f) {
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
Row(modifier = Modifier.matchParentSize()) {
|
||||
ReplySwipeIndicator({ offset / 120 })
|
||||
}
|
||||
TimelineItemEventRowContent(
|
||||
modifier = Modifier
|
||||
.absoluteOffset { IntOffset(x = offset.roundToInt(), y = 0) }
|
||||
.draggable(
|
||||
orientation = Orientation.Horizontal,
|
||||
enabled = !state.isResettingOnRelease,
|
||||
onDragStopped = {
|
||||
coroutineScope.launch {
|
||||
if (thresholdCrossed) {
|
||||
onSwipeToReply()
|
||||
}
|
||||
state.resetOffset()
|
||||
}
|
||||
},
|
||||
state = state.draggableState,
|
||||
),
|
||||
event = event,
|
||||
isHighlighted = isHighlighted,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
inReplyToClicked = ::inReplyToClicked,
|
||||
onUserDataClicked = ::onUserDataClicked,
|
||||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||
)
|
||||
}
|
||||
// Do not dismiss the message, return false!
|
||||
false
|
||||
}
|
||||
)
|
||||
SwipeToDismiss(
|
||||
state = dismissState,
|
||||
background = {
|
||||
ReplySwipeIndicator({ dismissState.toSwipeProgress() })
|
||||
},
|
||||
directions = setOf(DismissDirection.StartToEnd),
|
||||
dismissContent = {
|
||||
TimelineItemEventRowContent(
|
||||
event = event,
|
||||
isHighlighted = isHighlighted,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
inReplyToClicked = ::inReplyToClicked,
|
||||
onUserDataClicked = ::onUserDataClicked,
|
||||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
TimelineItemEventRowContent(
|
||||
event = event,
|
||||
isHighlighted = isHighlighted,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
inReplyToClicked = ::inReplyToClicked,
|
||||
onUserDataClicked = ::onUserDataClicked,
|
||||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||
)
|
||||
} else {
|
||||
TimelineItemEventRowContent(
|
||||
event = event,
|
||||
isHighlighted = isHighlighted,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
inReplyToClicked = ::inReplyToClicked,
|
||||
onUserDataClicked = ::onUserDataClicked,
|
||||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||
)
|
||||
}
|
||||
}
|
||||
// This is assuming that we are in a ColumnScope, but this is OK, for both Preview and real usage.
|
||||
if (event.groupPosition.isNew()) {
|
||||
Spacer(modifier = modifier.height(16.dp))
|
||||
} else {
|
||||
Spacer(modifier = modifier.height(2.dp))
|
||||
}
|
||||
|
||||
/**
|
||||
* Impact ViewConfiguration.touchSlop by [sensitivityFactor].
|
||||
* Inspired from https://issuetracker.google.com/u/1/issues/269627294.
|
||||
* @param sensitivityFactor the factor to multiply the touchSlop by. The highest value, the more the user will
|
||||
* have to drag to start the drag.
|
||||
* @param content the content to display.
|
||||
*/
|
||||
@Composable
|
||||
fun SwipeSensitivity(
|
||||
sensitivityFactor: Float,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val current = LocalViewConfiguration.current
|
||||
CompositionLocalProvider(
|
||||
LocalViewConfiguration provides object : ViewConfiguration by current {
|
||||
override val touchSlop: Float
|
||||
get() = current.touchSlop * sensitivityFactor
|
||||
}
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -266,14 +306,6 @@ private fun TimelineItemEventRowContent(
|
|||
}
|
||||
}
|
||||
|
||||
private fun DismissState.toSwipeProgress(): Float {
|
||||
return when (targetValue) {
|
||||
DismissValue.Default -> 0f
|
||||
DismissValue.DismissedToEnd -> progress * 3
|
||||
DismissValue.DismissedToStart -> progress * 3
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageSenderInformation(
|
||||
sender: String,
|
||||
|
|
@ -544,6 +576,7 @@ private fun ContentToPreview() {
|
|||
body = "A long text which will be displayed on several lines and" +
|
||||
" hopefully can be manually adjusted to test different behaviors."
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.First,
|
||||
),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
|
|
@ -562,6 +595,7 @@ private fun ContentToPreview() {
|
|||
content = aTimelineItemImageContent().copy(
|
||||
aspectRatio = 5f
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
|
|
@ -606,7 +640,8 @@ private fun ContentToPreviewWithReply() {
|
|||
body = "A long text which will be displayed on several lines and" +
|
||||
" hopefully can be manually adjusted to test different behaviors."
|
||||
),
|
||||
inReplyTo = aInReplyToReady(replyContent)
|
||||
inReplyTo = aInReplyToReady(replyContent),
|
||||
groupPosition = TimelineItemGroupPosition.First,
|
||||
),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
|
|
@ -625,7 +660,8 @@ private fun ContentToPreviewWithReply() {
|
|||
content = aTimelineItemImageContent().copy(
|
||||
aspectRatio = 5f
|
||||
),
|
||||
inReplyTo = aInReplyToReady(replyContent)
|
||||
inReplyTo = aInReplyToReady(replyContent),
|
||||
groupPosition = TimelineItemGroupPosition.Last,
|
||||
),
|
||||
isHighlighted = false,
|
||||
canReply = true,
|
||||
|
|
@ -699,7 +735,6 @@ private fun ContentTimestampToPreview(event: TimelineItem.Event) {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun TimelineItemEventRowWithManyReactionsLightPreview() =
|
||||
|
|
|
|||
|
|
@ -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.libraries.designsystem.swipe
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.MutatePriority
|
||||
import androidx.compose.foundation.gestures.DraggableState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
/**
|
||||
* Inspired from https://github.com/bmarty/swipe/blob/trunk/swipe/src/main/kotlin/me/saket/swipe/SwipeableActionsState.kt
|
||||
*/
|
||||
@Composable
|
||||
fun rememberSwipeableActionsState(): SwipeableActionsState {
|
||||
return remember { SwipeableActionsState() }
|
||||
}
|
||||
|
||||
@Stable
|
||||
class SwipeableActionsState {
|
||||
/**
|
||||
* The current position (in pixels) of the content.
|
||||
*/
|
||||
val offset: State<Float> get() = offsetState
|
||||
private var offsetState = mutableStateOf(0f)
|
||||
|
||||
/**
|
||||
* Whether the content is currently animating to reset its offset after it was swiped.
|
||||
*/
|
||||
var isResettingOnRelease: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
val draggableState = DraggableState { delta ->
|
||||
val targetOffset = offsetState.value + delta
|
||||
val isAllowed = isResettingOnRelease || targetOffset > 0f
|
||||
|
||||
offsetState.value += if (isAllowed) delta else 0f
|
||||
}
|
||||
|
||||
suspend fun resetOffset() {
|
||||
draggableState.drag(MutatePriority.PreventUserInput) {
|
||||
isResettingOnRelease = true
|
||||
try {
|
||||
Animatable(offsetState.value).animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(durationMillis = 300),
|
||||
) {
|
||||
dragBy(value - offsetState.value)
|
||||
}
|
||||
} finally {
|
||||
isResettingOnRelease = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b8ca3e1c8ce3c9476cdc7db46aebc8b6970486ff01e05b82c162256373c8957f
|
||||
size 141055
|
||||
oid sha256:323b80f3b2fcee42ffe50f333ff9d3952da05138f413f15882bf8ff7079347d8
|
||||
size 138598
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2bf89d0228a9771360eaad8a0bc514731153a351551df427cf7f7e44b886edfe
|
||||
size 145398
|
||||
oid sha256:21c3a584e3d47d030e45aac8d2162bd9b1d03f0ef43c3b8681f69eadd2d6fcc2
|
||||
size 142344
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:043097099dd08f0e192debd0ef650e8effe76c71f29dd3a993bf756d8e933d25
|
||||
size 62314
|
||||
oid sha256:7d62f35da442eba33e6913f6704de1e80918e50cef6237c00678ec9de5a23759
|
||||
size 62310
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4fe9d9d0ff870ccbb095c53b37900c00feb8153e76965ba432bad9d37387e512
|
||||
size 64523
|
||||
oid sha256:8d488277351ea1f53f11cabef46ff46768a42d61dc913b903b670635b9e44816
|
||||
size 64503
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e78c43cdaa5532d854087567fb8deef13fe89b1e896450428186df6b6cd83443
|
||||
size 68731
|
||||
oid sha256:e6b3750046ba185168395f20c75451524754b003a293cfcb0ed5a45be8f3335b
|
||||
size 68707
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:960e7c05075107db3a2b57f630f114927d4d019ffc5d38f5d501d44107753ea5
|
||||
size 70789
|
||||
oid sha256:963bf003585c82dddbb2efe1f6cf2f0564db88ea5de69e2b4322a6d256639de8
|
||||
size 70761
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f39fd269e68edb95e0b7c49bbf21a51c6c4233bfbfc173fdbc0e80eec2cf8d83
|
||||
oid sha256:b75482695c36693390a1100ff2758ed8910f65da42ddb270cbc07252c25ae439
|
||||
size 64252
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:eeb7f709da25c8864e712ab1532fd7803ab1d692c7baea970283a4702640840c
|
||||
size 66900
|
||||
oid sha256:401cbd6022543957388ec5fe81181604495238e80c95c1baa036c67bd62460d6
|
||||
size 66869
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:79493cad80934a053f300864189dd47bf6c1d9aabac780a5c5ea1ca497fc7a47
|
||||
size 71250
|
||||
oid sha256:ac20d25de28ae59dd2e9ea2743480792c3c10644128219966af1eee1e2034ac0
|
||||
size 71215
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c2b4ed0df17a6f96dd0031b820585346ec6ccac419a2f1237e0decdfb50dddb7
|
||||
size 73644
|
||||
oid sha256:5ca3befc8d4142f5be8252ffffef5ac82a25786085b0552b348d4f0a412bc9ae
|
||||
size 73743
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:82b4fb72cd3517b4eedeba3172cc428ba67199df816c686d998027ed021c1bb0
|
||||
size 84164
|
||||
oid sha256:1963827d549b8144bd1c71f85da6208307b8939f621b57c705a2ca61309f2e0d
|
||||
size 84239
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7c95c7df9616dca071528f4185e63485e7e738e51e595d828f94b08205c6e33d
|
||||
size 87695
|
||||
oid sha256:6e4ae356384002a19f779236ed6f2a3722c8f7bda91357dee3cacbb95d6aa61e
|
||||
size 87794
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:02f2eb6e37f1022aa13cbcdfc34e7955b99ee4c3d6b35668b548afc94d33ae31
|
||||
size 119137
|
||||
oid sha256:dcd2fb7256950a8a31267d6be90c342ae8774e945b04d954a7cc3d83878cb25a
|
||||
size 119550
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d50e5ac4433e4a44091623f1813373884007506002cc0c657ca7231a6e309462
|
||||
size 123938
|
||||
oid sha256:5628adde34216d1888481918628c82655252acfc15296c4fddd3220d0b72eaa4
|
||||
size 124893
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue