add fullscreen image viewing when tapping image
This commit is contained in:
parent
3df8c0bdab
commit
2ad4ca1c61
|
@ -47,24 +47,29 @@ sealed interface BubbleModel {
|
||||||
|
|
||||||
data class Event(val authorId: String, val authorName: String, val edited: Boolean, val time: String)
|
data class Event(val authorId: String, val authorName: String, val edited: Boolean, val time: String)
|
||||||
|
|
||||||
|
|
||||||
|
data class Action(
|
||||||
|
val onLongClick: (BubbleModel) -> Unit,
|
||||||
|
val onImageClick: (Image) -> Unit,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun BubbleModel.Reply.isReplyingToSelf() = this.replyingTo.event.authorId == this.reply.event.authorId
|
private fun BubbleModel.Reply.isReplyingToSelf() = this.replyingTo.event.authorId == this.reply.event.authorId
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageBubble(bubble: BubbleMeta, model: BubbleModel, status: @Composable () -> Unit, onLongClick: (BubbleModel) -> Unit) {
|
fun MessageBubble(bubble: BubbleMeta, model: BubbleModel, status: @Composable () -> Unit, actions: BubbleModel.Action) {
|
||||||
val itemisedLongClick = { onLongClick.invoke(model) }
|
val itemisedLongClick = { actions.onLongClick.invoke(model) }
|
||||||
when (model) {
|
when (model) {
|
||||||
is BubbleModel.Text -> TextBubble(bubble, model, status, itemisedLongClick)
|
is BubbleModel.Text -> TextBubble(bubble, model, status, itemisedLongClick)
|
||||||
is BubbleModel.Encrypted -> EncryptedBubble(bubble, model, status, itemisedLongClick)
|
is BubbleModel.Encrypted -> EncryptedBubble(bubble, model, status, itemisedLongClick)
|
||||||
is BubbleModel.Image -> ImageBubble(bubble, model, status, itemisedLongClick)
|
is BubbleModel.Image -> ImageBubble(bubble, model, status, onItemClick = { actions.onImageClick(model) }, itemisedLongClick)
|
||||||
is BubbleModel.Reply -> ReplyBubble(bubble, model, status, itemisedLongClick)
|
is BubbleModel.Reply -> ReplyBubble(bubble, model, status, itemisedLongClick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TextBubble(bubble: BubbleMeta, model: BubbleModel.Text, status: @Composable () -> Unit, onLongClick: () -> Unit) {
|
private fun TextBubble(bubble: BubbleMeta, model: BubbleModel.Text, status: @Composable () -> Unit, onLongClick: () -> Unit) {
|
||||||
Bubble(bubble, onLongClick) {
|
Bubble(bubble, onItemClick = {}, onLongClick) {
|
||||||
if (bubble.isNotSelf()) {
|
if (bubble.isNotSelf()) {
|
||||||
AuthorName(model.event, bubble)
|
AuthorName(model.event, bubble)
|
||||||
}
|
}
|
||||||
|
@ -79,8 +84,8 @@ private fun EncryptedBubble(bubble: BubbleMeta, model: BubbleModel.Encrypted, st
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @Composable () -> Unit, onLongClick: () -> Unit) {
|
private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @Composable () -> Unit, onItemClick: () -> Unit, onLongClick: () -> Unit) {
|
||||||
Bubble(bubble, onLongClick) {
|
Bubble(bubble, onItemClick, onLongClick) {
|
||||||
if (bubble.isNotSelf()) {
|
if (bubble.isNotSelf()) {
|
||||||
AuthorName(model.event, bubble)
|
AuthorName(model.event, bubble)
|
||||||
}
|
}
|
||||||
|
@ -97,7 +102,7 @@ private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @C
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @Composable () -> Unit, onLongClick: () -> Unit) {
|
private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @Composable () -> Unit, onLongClick: () -> Unit) {
|
||||||
Bubble(bubble, onLongClick) {
|
Bubble(bubble, onItemClick = {}, onLongClick) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
@ -204,7 +209,7 @@ private fun Int.scalerFor(max: Float): Float {
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun Bubble(bubble: BubbleMeta, onLongClick: () -> Unit, content: @Composable () -> Unit) {
|
private fun Bubble(bubble: BubbleMeta, onItemClick: () -> Unit, onLongClick: () -> Unit, content: @Composable () -> Unit) {
|
||||||
Box(modifier = Modifier.padding(start = 6.dp)) {
|
Box(modifier = Modifier.padding(start = 6.dp)) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
|
@ -212,7 +217,7 @@ private fun Bubble(bubble: BubbleMeta, onLongClick: () -> Unit, content: @Compos
|
||||||
.clip(bubble.shape)
|
.clip(bubble.shape)
|
||||||
.background(bubble.background)
|
.background(bubble.background)
|
||||||
.height(IntrinsicSize.Max)
|
.height(IntrinsicSize.Max)
|
||||||
.combinedClickable(onLongClick = onLongClick, onClick = {}),
|
.combinedClickable(onLongClick = onLongClick, onClick = onItemClick),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
|
|
|
@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
|
||||||
|
@ -16,13 +17,14 @@ fun Toolbar(
|
||||||
onNavigate: (() -> Unit)? = null,
|
onNavigate: (() -> Unit)? = null,
|
||||||
title: String? = null,
|
title: String? = null,
|
||||||
offset: (Density.() -> IntOffset)? = null,
|
offset: (Density.() -> IntOffset)? = null,
|
||||||
|
color: Color = MaterialTheme.colorScheme.background,
|
||||||
actions: @Composable RowScope.() -> Unit = {}
|
actions: @Composable RowScope.() -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val navigationIcon = foo(onNavigate)
|
val navigationIcon = foo(onNavigate)
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
modifier = offset?.let { Modifier.offset(it) } ?: Modifier,
|
modifier = offset?.let { Modifier.offset(it) } ?: Modifier,
|
||||||
colors = TopAppBarDefaults.smallTopAppBarColors(
|
colors = TopAppBarDefaults.smallTopAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.background
|
containerColor = color,
|
||||||
),
|
),
|
||||||
navigationIcon = navigationIcon,
|
navigationIcon = navigationIcon,
|
||||||
title = title?.let { { Text(it, maxLines = 2) } } ?: {},
|
title = title?.let { { Text(it, maxLines = 2) } } ?: {},
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package app.dapk.st.messenger
|
package app.dapk.st.messenger
|
||||||
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
@ -8,6 +9,7 @@ import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.*
|
import androidx.compose.foundation.lazy.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
@ -24,7 +26,11 @@ import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
@ -51,6 +57,8 @@ import app.dapk.st.navigator.Navigator
|
||||||
import coil.compose.rememberAsyncImagePainter
|
import coil.compose.rememberAsyncImagePainter
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MessengerScreen(
|
internal fun MessengerScreen(
|
||||||
|
@ -76,7 +84,8 @@ internal fun MessengerScreen(
|
||||||
val messageActions = MessageActions(
|
val messageActions = MessageActions(
|
||||||
onReply = { viewModel.post(MessengerAction.ComposerEnterReplyMode(it)) },
|
onReply = { viewModel.post(MessengerAction.ComposerEnterReplyMode(it)) },
|
||||||
onDismiss = { viewModel.post(MessengerAction.ComposerExitReplyMode) },
|
onDismiss = { viewModel.post(MessengerAction.ComposerExitReplyMode) },
|
||||||
onLongClick = { viewModel.post(MessengerAction.CopyToClipboard(it)) }
|
onLongClick = { viewModel.post(MessengerAction.CopyToClipboard(it)) },
|
||||||
|
onImageClick = { viewModel.selectImage(it) }
|
||||||
)
|
)
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
|
@ -85,6 +94,7 @@ internal fun MessengerScreen(
|
||||||
// DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {})
|
// DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {})
|
||||||
// }
|
// }
|
||||||
})
|
})
|
||||||
|
|
||||||
when (state.composerState) {
|
when (state.composerState) {
|
||||||
is ComposerState.Text -> {
|
is ComposerState.Text -> {
|
||||||
Room(state.roomState, messageActions, onRetry = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) })
|
Room(state.roomState, messageActions, onRetry = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) })
|
||||||
|
@ -106,6 +116,86 @@ internal fun MessengerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
when (state.viewerState) {
|
||||||
|
null -> {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
Box(Modifier.fillMaxSize().background(Color.Black)) {
|
||||||
|
BackHandler(onBack = { viewModel.unselectImage() })
|
||||||
|
ZoomableImage(state.viewerState)
|
||||||
|
Toolbar(
|
||||||
|
onNavigate = { viewModel.unselectImage() },
|
||||||
|
title = state.viewerState.event.event.authorName,
|
||||||
|
color = Color.Black.copy(alpha = 0.4f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ZoomableImage(viewerState: ViewerState) {
|
||||||
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val angle by remember { mutableStateOf(0f) }
|
||||||
|
var zoom by remember { mutableStateOf(1f) }
|
||||||
|
var offsetX by remember { mutableStateOf(0f) }
|
||||||
|
var offsetY by remember { mutableStateOf(0f) }
|
||||||
|
|
||||||
|
val screenWidth = constraints.maxWidth
|
||||||
|
val screenHeight = constraints.maxHeight
|
||||||
|
|
||||||
|
val renderedSize = remember {
|
||||||
|
val imageContent = viewerState.event.imageContent
|
||||||
|
val imageHeight = imageContent.height ?: 120
|
||||||
|
val heightScaleFactor = screenHeight.toFloat() / imageHeight.toFloat()
|
||||||
|
val imageWidth = imageContent.width ?: 120
|
||||||
|
val widthScaleFactor = screenWidth.toFloat() / imageWidth.toFloat()
|
||||||
|
val scaler = min(heightScaleFactor, widthScaleFactor)
|
||||||
|
IntSize((imageWidth * scaler).roundToInt(), (imageHeight * scaler).roundToInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(
|
||||||
|
painter = rememberAsyncImagePainter(model = viewerState.event.imageRequest),
|
||||||
|
contentDescription = "",
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = zoom
|
||||||
|
scaleY = zoom
|
||||||
|
rotationZ = angle
|
||||||
|
translationX = offsetX
|
||||||
|
translationY = offsetY
|
||||||
|
}
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTransformGestures(
|
||||||
|
onGesture = { _, pan, gestureZoom, _ ->
|
||||||
|
zoom = (zoom * gestureZoom).coerceIn(1F..4F)
|
||||||
|
if (zoom > 1) {
|
||||||
|
val x = (pan.x * zoom)
|
||||||
|
val y = (pan.y * zoom)
|
||||||
|
|
||||||
|
if (renderedSize.width * zoom > screenWidth) {
|
||||||
|
val maxZoomedWidthOffset = ((renderedSize.width * zoom) - screenWidth) / 2
|
||||||
|
offsetX = (offsetX + x).coerceIn(-maxZoomedWidthOffset..maxZoomedWidthOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renderedSize.height * zoom > screenHeight) {
|
||||||
|
val maxZoomedHeightOffset = ((renderedSize.height * zoom) - screenHeight) / 2
|
||||||
|
offsetY = (offsetY + y).coerceIn(-maxZoomedHeightOffset..maxZoomedHeightOffset)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
offsetX = 0F
|
||||||
|
offsetY = 0F
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@ -180,6 +270,11 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, messageActio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val bubbleActions = BubbleModel.Action(
|
||||||
|
onLongClick = { messageActions.onLongClick(it) },
|
||||||
|
onImageClick = { messageActions.onImageClick(it) }
|
||||||
|
)
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
@ -201,7 +296,7 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, messageActio
|
||||||
onReply = { messageActions.onReply(item) },
|
onReply = { messageActions.onReply(item) },
|
||||||
) {
|
) {
|
||||||
val status = @Composable { SendStatus(item) }
|
val status = @Composable { SendStatus(item) }
|
||||||
MessageBubble(this, item.toModel(), status, onLongClick = messageActions.onLongClick)
|
MessageBubble(this, item.toModel(), status, bubbleActions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -283,7 +378,13 @@ private fun SendStatus(message: RoomEvent) {
|
||||||
|
|
||||||
@OptIn(ExperimentalAnimationApi::class)
|
@OptIn(ExperimentalAnimationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Unit, onSend: () -> Unit, onAttach: () -> Unit, messageActions: MessageActions) {
|
private fun TextComposer(
|
||||||
|
state: ComposerState.Text,
|
||||||
|
onTextChange: (String) -> Unit,
|
||||||
|
onSend: () -> Unit,
|
||||||
|
onAttach: () -> Unit,
|
||||||
|
messageActions: MessageActions
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
@ -447,4 +548,5 @@ class MessageActions(
|
||||||
val onReply: (RoomEvent) -> Unit,
|
val onReply: (RoomEvent) -> Unit,
|
||||||
val onDismiss: () -> Unit,
|
val onDismiss: () -> Unit,
|
||||||
val onLongClick: (BubbleModel) -> Unit,
|
val onLongClick: (BubbleModel) -> Unit,
|
||||||
|
val onImageClick: (BubbleModel.Image) -> Unit,
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package app.dapk.st.messenger
|
package app.dapk.st.messenger
|
||||||
|
|
||||||
import app.dapk.st.core.Lce
|
import app.dapk.st.core.Lce
|
||||||
|
import app.dapk.st.design.components.BubbleModel
|
||||||
import app.dapk.st.engine.MessengerState
|
import app.dapk.st.engine.MessengerState
|
||||||
import app.dapk.st.engine.RoomEvent
|
import app.dapk.st.engine.RoomEvent
|
||||||
import app.dapk.st.matrix.common.RoomId
|
import app.dapk.st.matrix.common.RoomId
|
||||||
|
@ -10,6 +11,11 @@ data class MessengerScreenState(
|
||||||
val roomId: RoomId?,
|
val roomId: RoomId?,
|
||||||
val roomState: Lce<MessengerState>,
|
val roomState: Lce<MessengerState>,
|
||||||
val composerState: ComposerState,
|
val composerState: ComposerState,
|
||||||
|
val viewerState: ViewerState?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ViewerState(
|
||||||
|
val event: BubbleModel.Image,
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface MessengerEvent {
|
sealed interface MessengerEvent {
|
||||||
|
|
|
@ -32,7 +32,8 @@ internal class MessengerViewModel(
|
||||||
initialState = MessengerScreenState(
|
initialState = MessengerScreenState(
|
||||||
roomId = null,
|
roomId = null,
|
||||||
roomState = Lce.Loading(),
|
roomState = Lce.Loading(),
|
||||||
composerState = ComposerState.Text(value = "", reply = null)
|
composerState = ComposerState.Text(value = "", reply = null),
|
||||||
|
viewerState = null,
|
||||||
),
|
),
|
||||||
factory = factory,
|
factory = factory,
|
||||||
) {
|
) {
|
||||||
|
@ -157,6 +158,18 @@ internal class MessengerViewModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun selectImage(image: BubbleModel.Image) {
|
||||||
|
updateState {
|
||||||
|
copy(viewerState = ViewerState(image))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unselectImage() {
|
||||||
|
updateState {
|
||||||
|
copy(viewerState = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun BubbleModel.findCopyableContent(): CopyableResult = when (this) {
|
private fun BubbleModel.findCopyableContent(): CopyableResult = when (this) {
|
||||||
|
|
|
@ -48,7 +48,8 @@ class MessengerViewModelTest {
|
||||||
MessengerScreenState(
|
MessengerScreenState(
|
||||||
roomId = null,
|
roomId = null,
|
||||||
roomState = Lce.Loading(),
|
roomState = Lce.Loading(),
|
||||||
composerState = ComposerState.Text(value = "", reply = null)
|
composerState = ComposerState.Text(value = "", reply = null),
|
||||||
|
viewerState = null,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -114,7 +115,8 @@ class MessengerViewModelTest {
|
||||||
fun aMessageScreenState(roomId: RoomId = aRoomId(), roomState: MessengerState, messageContent: String?) = MessengerScreenState(
|
fun aMessageScreenState(roomId: RoomId = aRoomId(), roomState: MessengerState, messageContent: String?) = MessengerScreenState(
|
||||||
roomId = roomId,
|
roomId = roomId,
|
||||||
roomState = Lce.Content(roomState),
|
roomState = Lce.Content(roomState),
|
||||||
composerState = ComposerState.Text(value = messageContent ?: "", reply = null)
|
composerState = ComposerState.Text(value = messageContent ?: "", reply = null),
|
||||||
|
viewerState = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
class FakeCopyToClipboard {
|
class FakeCopyToClipboard {
|
||||||
|
|
Loading…
Reference in New Issue