mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor(messaging): Redesign message bubbles and reaction UI (#4217)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
b84dcb3971
commit
f144454053
4 changed files with 193 additions and 137 deletions
|
|
@ -22,7 +22,7 @@ import androidx.compose.foundation.layout.Row
|
|||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Reply
|
||||
import androidx.compose.material.icons.filled.EmojiEmotions
|
||||
import androidx.compose.material.icons.filled.AddReaction
|
||||
import androidx.compose.material.icons.twotone.AddLink
|
||||
import androidx.compose.material.icons.twotone.Cloud
|
||||
import androidx.compose.material.icons.twotone.CloudDone
|
||||
|
|
@ -61,7 +61,7 @@ internal fun ReactionButton(onSendReaction: (String) -> Unit = {}) {
|
|||
)
|
||||
}
|
||||
IconButton(onClick = { showEmojiPickerDialog = true }) {
|
||||
Icon(imageVector = Icons.Default.EmojiEmotions, contentDescription = stringResource(Res.string.react))
|
||||
Icon(imageVector = Icons.Default.AddReaction, contentDescription = stringResource(Res.string.react))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,9 +39,9 @@ internal fun getMessageBubbleShape(
|
|||
)
|
||||
} else {
|
||||
RoundedCornerShape(
|
||||
topStart = if (hasSamePrev) square else round,
|
||||
topStart = square,
|
||||
topEnd = if (hasSamePrev) square else round,
|
||||
bottomStart = square,
|
||||
bottomStart = if (hasSameNext) square else round,
|
||||
bottomEnd = if (hasSameNext) square else round,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
*/
|
||||
package org.meshtastic.feature.messaging.component
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
|
|
@ -27,10 +28,8 @@ import androidx.compose.foundation.layout.Row
|
|||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.FormatQuote
|
||||
|
|
@ -113,7 +112,17 @@ internal fun MessageItem(
|
|||
onStatusClick: () -> Unit = {},
|
||||
hasSamePrev: Boolean = false,
|
||||
hasSameNext: Boolean = false,
|
||||
) = Column(modifier = modifier.padding(top = if (showUserName) 32.dp else 4.dp)) {
|
||||
) = Column(
|
||||
modifier =
|
||||
modifier.padding(
|
||||
top =
|
||||
if (showUserName) {
|
||||
16.dp
|
||||
} else {
|
||||
4.dp
|
||||
},
|
||||
),
|
||||
) {
|
||||
var activeSheet by remember { mutableStateOf<ActiveSheet?>(null) }
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
|
@ -180,13 +189,6 @@ internal fun MessageItem(
|
|||
} else {
|
||||
Color(node.colors.second).copy(alpha = alpha)
|
||||
}
|
||||
.apply {
|
||||
if (inSelectionMode) {
|
||||
copy(alpha = if (selected) 0.6f else 0.2f)
|
||||
} else {
|
||||
copy(alpha = 0.4f)
|
||||
}
|
||||
}
|
||||
val cardColors =
|
||||
CardDefaults.cardColors()
|
||||
.copy(containerColor = containerColor, contentColor = contentColorFor(containerColor))
|
||||
|
|
@ -206,117 +208,115 @@ internal fun MessageItem(
|
|||
Modifier
|
||||
},
|
||||
)
|
||||
Box(modifier = Modifier.wrapContentSize()) {
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier.align(if (message.fromLocal) Alignment.TopEnd else Alignment.TopStart)
|
||||
.padding(
|
||||
start = if (!message.fromLocal) 0.dp else 16.dp,
|
||||
end = if (message.fromLocal) 0.dp else 16.dp,
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = {
|
||||
onLongClick()
|
||||
if (!inSelectionMode) {
|
||||
activeSheet = ActiveSheet.Actions
|
||||
}
|
||||
},
|
||||
onDoubleClick = onDoubleClick,
|
||||
)
|
||||
.then(messageModifier)
|
||||
.semantics(mergeDescendants = true) {
|
||||
val senderName = if (message.fromLocal) ourNode.user.longName else node.user.longName
|
||||
contentDescription = "Message from $senderName: ${message.text}"
|
||||
},
|
||||
color = containerColor,
|
||||
contentColor = contentColorFor(containerColor),
|
||||
shape = messageShape,
|
||||
if (showUserName && !message.fromLocal) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
OriginalMessageSnippet(
|
||||
message = message,
|
||||
ourNode = ourNode,
|
||||
hasSamePrev = hasSamePrev,
|
||||
onNavigateToOriginalMessage = onNavigateToOriginalMessage,
|
||||
NodeChip(node = node, onClick = onClickChip)
|
||||
Text(
|
||||
text = node.user.longName,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
if (message.viaMqtt) {
|
||||
Icon(
|
||||
Icons.Default.Cloud,
|
||||
contentDescription = stringResource(Res.string.via_mqtt),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (showUserName) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
val chipNode = if (message.fromLocal) ourNode else node
|
||||
NodeChip(node = chipNode, onClick = onClickChip)
|
||||
Text(
|
||||
text = (if (message.fromLocal) ourNode.user else node.user).longName,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
if (message.viaMqtt) {
|
||||
Icon(
|
||||
Icons.Default.Cloud,
|
||||
contentDescription = stringResource(Res.string.via_mqtt),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier.padding(
|
||||
start = if (!message.fromLocal) 0.dp else 24.dp,
|
||||
end = if (message.fromLocal) 0.dp else 24.dp,
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = {
|
||||
onLongClick()
|
||||
if (!inSelectionMode) {
|
||||
activeSheet = ActiveSheet.Actions
|
||||
}
|
||||
},
|
||||
onDoubleClick = onDoubleClick,
|
||||
)
|
||||
.then(messageModifier)
|
||||
.semantics(mergeDescendants = true) {
|
||||
val senderName = if (message.fromLocal) ourNode.user.longName else node.user.longName
|
||||
contentDescription = "Message from $senderName: ${message.text}"
|
||||
},
|
||||
color = containerColor,
|
||||
contentColor = contentColorFor(containerColor),
|
||||
shape = messageShape,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
OriginalMessageSnippet(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
message = message,
|
||||
ourNode = ourNode,
|
||||
hasSamePrev = hasSamePrev,
|
||||
onNavigateToOriginalMessage = onNavigateToOriginalMessage,
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
AutoLinkText(
|
||||
text = message.text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = cardColors.contentColor,
|
||||
)
|
||||
|
||||
Row(modifier = Modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
if (!message.fromLocal) {
|
||||
if (message.hopsAway == 0) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Snr(message.snr)
|
||||
Rssi(message.rssi)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(Res.string.hops_away_template, message.hopsAway),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (containsBel) {
|
||||
Text(text = "\uD83D\uDD14", modifier = Modifier.padding(end = 4.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(text = message.time, style = MaterialTheme.typography.labelSmall)
|
||||
if (message.fromLocal) {
|
||||
Spacer(modifier = Modifier.size(4.dp))
|
||||
MessageStatusIcon(status = message.status ?: MessageStatus.UNKNOWN, onClick = onStatusClick)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp)) {
|
||||
AutoLinkText(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = message.text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = cardColors.contentColor,
|
||||
Text(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
text = message.time,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (!message.fromLocal) {
|
||||
if (message.hopsAway == 0) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Snr(message.snr)
|
||||
Rssi(message.rssi)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(Res.string.hops_away_template, message.hopsAway),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (containsBel) {
|
||||
Text(text = "\uD83D\uDD14", modifier = Modifier.padding(end = 4.dp))
|
||||
}
|
||||
if (message.fromLocal) {
|
||||
MessageStatusIcon(
|
||||
status = message.status ?: MessageStatus.UNKNOWN,
|
||||
onClick = onStatusClick,
|
||||
modifier = modifier.size(24.dp).padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
}
|
||||
AnimatedVisibility(emojis.isNotEmpty()) {
|
||||
ReactionRow(
|
||||
modifier =
|
||||
Modifier.align(if (message.fromLocal) Alignment.BottomEnd else Alignment.BottomStart)
|
||||
.padding(horizontal = 12.dp)
|
||||
.offset(y = 20.dp),
|
||||
) {
|
||||
ReactionRow(
|
||||
reactions = if (message.fromLocal) emojis.reversed() else emojis,
|
||||
myId = ourNode.user.id,
|
||||
onSendReaction = sendReaction,
|
||||
onShowReactions = onShowReactions,
|
||||
)
|
||||
}
|
||||
Modifier.padding(
|
||||
start = if (!message.fromLocal) 0.dp else 24.dp,
|
||||
end = if (message.fromLocal) 0.dp else 24.dp,
|
||||
),
|
||||
reactions = if (message.fromLocal) emojis.reversed() else emojis,
|
||||
myId = ourNode.user.id,
|
||||
onSendReaction = sendReaction,
|
||||
onShowReactions = onShowReactions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -330,7 +330,7 @@ private enum class ActiveSheet {
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageStatusIcon(status: MessageStatus, onClick: () -> Unit) {
|
||||
private fun MessageStatusIcon(status: MessageStatus, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val icon =
|
||||
when (status) {
|
||||
MessageStatus.RECEIVED -> Icons.TwoTone.HowToReg
|
||||
|
|
@ -345,7 +345,7 @@ private fun MessageStatusIcon(status: MessageStatus, onClick: () -> Unit) {
|
|||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = stringResource(Res.string.message_delivery_status),
|
||||
modifier = Modifier.size(24.dp).clickable(onClick = onClick),
|
||||
modifier = modifier.clickable(onClick = onClick),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -355,6 +355,7 @@ private fun OriginalMessageSnippet(
|
|||
ourNode: Node,
|
||||
hasSamePrev: Boolean,
|
||||
onNavigateToOriginalMessage: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val originalMessage = message.originalMessage
|
||||
if (originalMessage != null && originalMessage.packetId != 0) {
|
||||
|
|
@ -366,7 +367,7 @@ private fun OriginalMessageSnippet(
|
|||
contentColor = Color(originalMessageNode.colors.first),
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().clickable { onNavigateToOriginalMessage(originalMessage.packetId) },
|
||||
modifier = modifier.fillMaxWidth().clickable { onNavigateToOriginalMessage(originalMessage.packetId) },
|
||||
contentColor = cardColors.contentColor,
|
||||
color = cardColors.containerColor,
|
||||
shape =
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package org.meshtastic.feature.messaging.component
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
|
|
@ -28,13 +29,15 @@ import androidx.compose.foundation.layout.Spacer
|
|||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AddReaction
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
|
|
@ -52,6 +55,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.database.entity.Packet
|
||||
import org.meshtastic.core.database.entity.Reaction
|
||||
|
|
@ -67,10 +71,12 @@ import org.meshtastic.core.strings.hops_away_template
|
|||
import org.meshtastic.core.strings.message_delivery_status
|
||||
import org.meshtastic.core.strings.message_status_enroute
|
||||
import org.meshtastic.core.strings.message_status_queued
|
||||
import org.meshtastic.core.strings.react
|
||||
import org.meshtastic.core.strings.you
|
||||
import org.meshtastic.core.ui.component.BottomSheetDialog
|
||||
import org.meshtastic.core.ui.component.Rssi
|
||||
import org.meshtastic.core.ui.component.Snr
|
||||
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.feature.messaging.DeliveryInfo
|
||||
import org.meshtastic.proto.MeshProtos
|
||||
|
|
@ -87,26 +93,43 @@ private fun ReactionItem(
|
|||
val isSending = status == MessageStatus.QUEUED || status == MessageStatus.ENROUTE
|
||||
val isError = status == MessageStatus.ERROR
|
||||
|
||||
BadgedBox(
|
||||
modifier = modifier,
|
||||
badge = {
|
||||
if (emojiCount > 1) {
|
||||
Badge { Text(fontWeight = FontWeight.Bold, text = emojiCount.toString()) }
|
||||
}
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.then(if (isSending) Modifier.graphicsLayer(alpha = 0.5f) else Modifier),
|
||||
color =
|
||||
when {
|
||||
isError -> MaterialTheme.colorScheme.errorContainer
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
|
||||
},
|
||||
) {
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.then(if (isSending) Modifier.graphicsLayer(alpha = 0.5f) else Modifier),
|
||||
shape = if (emojiCount > 1) MaterialTheme.shapes.small else CircleShape,
|
||||
border =
|
||||
BorderStroke(
|
||||
width = 1.dp,
|
||||
color =
|
||||
when {
|
||||
isError -> MaterialTheme.colorScheme.errorContainer
|
||||
else -> MaterialTheme.colorScheme.primaryContainer
|
||||
if (isError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)
|
||||
},
|
||||
shape = CircleShape,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(text = emoji, modifier = Modifier.padding(4.dp).clip(CircleShape))
|
||||
Text(text = emoji, fontSize = 14.sp)
|
||||
if (emojiCount > 1) {
|
||||
Text(
|
||||
text = emojiCount.toString(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -123,11 +146,14 @@ internal fun ReactionRow(
|
|||
val emojiGroups = reactions.groupBy { it.emoji }
|
||||
|
||||
AnimatedVisibility(emojiGroups.isNotEmpty()) {
|
||||
LazyRow(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
LazyRow(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(emojiGroups.entries.toList()) { (emoji, reactions) ->
|
||||
val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId }
|
||||
ReactionItem(
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
emoji = emoji,
|
||||
emojiCount = reactions.size,
|
||||
status = localReaction?.status ?: MessageStatus.RECEIVED,
|
||||
|
|
@ -135,10 +161,39 @@ internal fun ReactionRow(
|
|||
onLongClick = onShowReactions,
|
||||
)
|
||||
}
|
||||
item { AddReactionButton(onSendReaction = onSendReaction) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddReactionButton(modifier: Modifier = Modifier, onSendReaction: (String) -> Unit = {}) {
|
||||
var showEmojiPickerDialog by remember { mutableStateOf(false) }
|
||||
if (showEmojiPickerDialog) {
|
||||
EmojiPickerDialog(
|
||||
onConfirm = { selectedEmoji ->
|
||||
showEmojiPickerDialog = false
|
||||
onSendReaction(selectedEmoji)
|
||||
},
|
||||
onDismiss = { showEmojiPickerDialog = false },
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
onClick = { showEmojiPickerDialog = true },
|
||||
modifier = modifier.size(28.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
shape = CircleShape,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AddReaction,
|
||||
contentDescription = stringResource(Res.string.react),
|
||||
modifier = Modifier.padding(6.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
internal fun ReactionDialog(
|
||||
|
|
@ -266,7 +321,7 @@ private fun ReactionItemPreview() {
|
|||
Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
|
||||
ReactionItem(emoji = "\uD83D\uDE42")
|
||||
ReactionItem(emoji = "\uD83D\uDE42", emojiCount = 2)
|
||||
ReactionButton()
|
||||
AddReactionButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue