Merge pull request 'feat(app): copy messages via bubble context menu + selection toolbar' (#9) from feat/message-copy-context-menu into master
Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
commit
560d9c19b3
1 file changed
+243
-135
@@ -487,6 +487,22 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
var selectionMode by remember { mutableStateOf(false) }
|
var selectionMode by remember { mutableStateOf(false) }
|
||||||
var selectedIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
var selectedIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||||
|
// True when the confirm dialog was opened from a bubble's context menu
|
||||||
|
// (which stages selectedIds WITHOUT entering selection mode) — cancel
|
||||||
|
// must then clear the staged selection so the bubble doesn't stay
|
||||||
|
// highlighted.
|
||||||
|
var deleteConfirmFromMenu by remember { mutableStateOf(false) }
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
|
||||||
|
// Copy raw markdown (formatting survives pasting into other markdown
|
||||||
|
// apps) with a toast, since the clipboard write itself is silent.
|
||||||
|
// Blank text (e.g. a media-only message) is a no-op: writing an empty
|
||||||
|
// string would wipe the clipboard for no reason.
|
||||||
|
fun copyText(text: String) {
|
||||||
|
if (text.isBlank()) return
|
||||||
|
clipboard.setText(AnnotatedString(text))
|
||||||
|
toastMessage = "Copied"
|
||||||
|
}
|
||||||
|
|
||||||
fun enterSelection(id: String) {
|
fun enterSelection(id: String) {
|
||||||
selectionMode = true
|
selectionMode = true
|
||||||
@@ -670,14 +686,30 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
selected = selectable && item.id in selectedIds,
|
selected = selectable && item.id in selectedIds,
|
||||||
onToggleSelect = { if (selectable) toggleSelect(item.id) },
|
onToggleSelect = { if (selectable) toggleSelect(item.id) },
|
||||||
onLongPress = {
|
onLongPress = {
|
||||||
if (selectable) {
|
if (selectable && selectionMode) toggleSelect(item.id)
|
||||||
if (selectionMode) {
|
|
||||||
toggleSelect(item.id)
|
|
||||||
} else {
|
|
||||||
enterSelection(item.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
onCopy =
|
||||||
|
if (selectable) {
|
||||||
|
{ copyText(item.text) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onDelete =
|
||||||
|
if (selectable) {
|
||||||
|
{
|
||||||
|
selectedIds = setOf(item.id)
|
||||||
|
deleteConfirmFromMenu = true
|
||||||
|
showDeleteConfirm = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onSelect =
|
||||||
|
if (selectable) {
|
||||||
|
{ enterSelection(item.id) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
runtimeFooterEnabled = runtimeFooterEnabled,
|
runtimeFooterEnabled = runtimeFooterEnabled,
|
||||||
runtimeFooterFields = runtimeFooterFields,
|
runtimeFooterFields = runtimeFooterFields,
|
||||||
)
|
)
|
||||||
@@ -913,7 +945,20 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
SelectionToolbar(
|
SelectionToolbar(
|
||||||
count = selectedIds.size,
|
count = selectedIds.size,
|
||||||
onCancel = { exitSelection() },
|
onCancel = { exitSelection() },
|
||||||
onDelete = { showDeleteConfirm = true },
|
// Multi-select copy: join the selected messages in
|
||||||
|
// display order, separated by a blank line.
|
||||||
|
onCopy = {
|
||||||
|
val text =
|
||||||
|
items
|
||||||
|
.filterIsInstance<MessageItem>()
|
||||||
|
.filter { it.id in selectedIds }
|
||||||
|
.joinToString("\n\n") { it.text }
|
||||||
|
copyText(text)
|
||||||
|
},
|
||||||
|
onDelete = {
|
||||||
|
deleteConfirmFromMenu = false
|
||||||
|
showDeleteConfirm = true
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1062,8 +1107,16 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
// Message selection: confirm deleting the selected message(s).
|
// Message selection: confirm deleting the selected message(s).
|
||||||
if (showDeleteConfirm) {
|
if (showDeleteConfirm) {
|
||||||
val n = selectedIds.size
|
val n = selectedIds.size
|
||||||
|
|
||||||
|
// Cancel: a menu-opened confirm staged selectedIds without
|
||||||
|
// entering selection mode — clear it so the bubble doesn't
|
||||||
|
// stay highlighted (toolbar path keeps its selection).
|
||||||
|
fun cancelDeleteConfirm() {
|
||||||
|
showDeleteConfirm = false
|
||||||
|
if (deleteConfirmFromMenu) selectedIds = emptySet()
|
||||||
|
}
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showDeleteConfirm = false },
|
onDismissRequest = { cancelDeleteConfirm() },
|
||||||
title = { Text(if (n == 1) "Delete message?" else "Delete $n messages?") },
|
title = { Text(if (n == 1) "Delete message?" else "Delete $n messages?") },
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
@@ -1082,7 +1135,7 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
}) { Text("Delete") }
|
}) { Text("Delete") }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") }
|
TextButton(onClick = { cancelDeleteConfirm() }) { Text("Cancel") }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2460,6 +2513,9 @@ private fun MessageBubble(
|
|||||||
selected: Boolean = false,
|
selected: Boolean = false,
|
||||||
onToggleSelect: () -> Unit = {},
|
onToggleSelect: () -> Unit = {},
|
||||||
onLongPress: () -> Unit = {},
|
onLongPress: () -> Unit = {},
|
||||||
|
onCopy: (() -> Unit)? = null,
|
||||||
|
onDelete: (() -> Unit)? = null,
|
||||||
|
onSelect: (() -> Unit)? = null,
|
||||||
runtimeFooterEnabled: Boolean = false,
|
runtimeFooterEnabled: Boolean = false,
|
||||||
runtimeFooterFields: List<String> = emptyList(),
|
runtimeFooterFields: List<String> = emptyList(),
|
||||||
) {
|
) {
|
||||||
@@ -2489,141 +2545,178 @@ private fun MessageBubble(
|
|||||||
SelectionCheck(selected)
|
SelectionCheck(selected)
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
}
|
}
|
||||||
Column(
|
// Context menu (long-press / right-click): copy / select / delete.
|
||||||
modifier =
|
// Anchored to the bubble itself (Box), not the full-width row, so it
|
||||||
Modifier
|
// pops up next to the bubble on both sides.
|
||||||
.widthIn(max = maxWidth)
|
val hasMenu = onCopy != null || onDelete != null || onSelect != null
|
||||||
.clip(RoundedCornerShape(14.dp))
|
var menuOpen by remember { mutableStateOf(false) }
|
||||||
.background(if (selected) IrisColors.chipSelected else bubbleColor)
|
Box {
|
||||||
.then(
|
Column(
|
||||||
// Long-press / right-click enters (or toggles within) message
|
modifier =
|
||||||
// selection; a plain tap toggles while selecting, or retries a
|
Modifier
|
||||||
// failed user send otherwise. Attached always so the long-press
|
.widthIn(max = maxWidth)
|
||||||
// affordance exists even outside selection mode.
|
.clip(RoundedCornerShape(14.dp))
|
||||||
Modifier
|
.background(if (selected) IrisColors.chipSelected else bubbleColor)
|
||||||
.combinedClickable(
|
.then(
|
||||||
onClick = {
|
// Long-press / right-click opens the context menu (or
|
||||||
if (selectionMode) {
|
// toggles selection while selecting); a plain tap toggles
|
||||||
onToggleSelect()
|
// while selecting, or retries a failed user send otherwise.
|
||||||
} else if (isUser && msg.status == MsgStatus.Failed) {
|
// Attached always so the long-press affordance exists even
|
||||||
onRetry()
|
// outside selection mode.
|
||||||
}
|
Modifier
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = {
|
||||||
|
if (selectionMode) {
|
||||||
|
onToggleSelect()
|
||||||
|
} else if (isUser && msg.status == MsgStatus.Failed) {
|
||||||
|
onRetry()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
if (hasMenu && !selectionMode) menuOpen = true else onLongPress()
|
||||||
|
},
|
||||||
|
).rightClick {
|
||||||
|
if (hasMenu && !selectionMode) menuOpen = true else onLongPress()
|
||||||
},
|
},
|
||||||
onLongClick = { onLongPress() },
|
).padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
).rightClick { onLongPress() },
|
) {
|
||||||
).padding(horizontal = 12.dp, vertical = 8.dp),
|
// Reasoning block above the answer (assistant, non-commentary).
|
||||||
) {
|
if (!isUser && !isCommentary && !msg.reasoning.isNullOrBlank()) {
|
||||||
// Reasoning block above the answer (assistant, non-commentary).
|
ReasoningBlock(msg.reasoning!!, reasoningAutoCollapse, bubbleColor)
|
||||||
if (!isUser && !isCommentary && !msg.reasoning.isNullOrBlank()) {
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
ReasoningBlock(msg.reasoning!!, reasoningAutoCollapse, bubbleColor)
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
}
|
|
||||||
if (isUser) {
|
|
||||||
// M7: user text rendered as markdown (bold / italic / inline
|
|
||||||
// code), so a pasted snippet or emphasis shows up the same as
|
|
||||||
// in replies.
|
|
||||||
if (msg.text.isNotBlank() || msg.streaming) {
|
|
||||||
MarkdownText(
|
|
||||||
text =
|
|
||||||
msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
|
|
||||||
if (msg.streaming) " ▉" else "",
|
|
||||||
color = textColor,
|
|
||||||
fontSize = 15.sp,
|
|
||||||
isStreaming = msg.streaming,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
// M7: timestamp + delivery status on their own line at the
|
if (isUser) {
|
||||||
// bottom-right (Telegram look, same as agent bubbles).
|
// M7: user text rendered as markdown (bold / italic / inline
|
||||||
Row(
|
// code), so a pasted snippet or emphasis shows up the same as
|
||||||
modifier = Modifier.fillMaxWidth(),
|
// in replies.
|
||||||
horizontalArrangement = Arrangement.End,
|
if (msg.text.isNotBlank() || msg.streaming) {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
MarkdownText(
|
||||||
) {
|
text =
|
||||||
if (time.isNotEmpty()) {
|
msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
|
||||||
Text(time, fontSize = 10.sp, color = textColor.copy(alpha = 0.6f))
|
if (msg.streaming) " ▉" else "",
|
||||||
|
color = textColor,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
isStreaming = msg.streaming,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
when (msg.status) {
|
// M7: timestamp + delivery status on their own line at the
|
||||||
MsgStatus.Pending -> {
|
// bottom-right (Telegram look, same as agent bubbles).
|
||||||
Text(" sending…", fontSize = 10.sp, color = textColor.copy(alpha = 0.6f))
|
|
||||||
}
|
|
||||||
|
|
||||||
MsgStatus.Sent -> {
|
|
||||||
Text(" ✓", fontSize = 10.sp, color = textColor.copy(alpha = 0.8f))
|
|
||||||
}
|
|
||||||
|
|
||||||
MsgStatus.Read -> {
|
|
||||||
Text(" ✓✓", fontSize = 10.sp, color = textColor.copy(alpha = 0.8f))
|
|
||||||
}
|
|
||||||
|
|
||||||
MsgStatus.Failed -> {
|
|
||||||
Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (msg.status == MsgStatus.Failed) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.End,
|
horizontalArrangement = Arrangement.End,
|
||||||
) {
|
|
||||||
Text("Failed to send — tap to retry", fontSize = 10.sp, color = IrisColors.errorText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (msg.text.isNotBlank() || msg.streaming) {
|
|
||||||
// M8: render the agent's reply as markdown (bold / italic /
|
|
||||||
// underscore, tables, highlighted code blocks). Leading
|
|
||||||
// newlines are stripped so the text hugs the top of the
|
|
||||||
// bubble; single newlines become hard breaks (models write
|
|
||||||
// status lines and wrapped text expecting a break per line,
|
|
||||||
// same as user input); the ▉ cursor is kept while streaming.
|
|
||||||
val displayText =
|
|
||||||
msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
|
|
||||||
if (msg.streaming) " ▉" else ""
|
|
||||||
MarkdownText(
|
|
||||||
text = displayText,
|
|
||||||
color = textColor,
|
|
||||||
fontSize = if (isCommentary) 13.sp else 15.sp,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
isStreaming = msg.streaming,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// M4: media attachments (image / player / document chip).
|
|
||||||
if (msg.media.isNotEmpty()) {
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
msg.media.forEach { m -> MediaAttachment(m) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!isUser) {
|
|
||||||
// Runtime-metadata footer + timestamp on ONE line (footer left,
|
|
||||||
// time right) — Telegram-style. The app controls whether/what
|
|
||||||
// the footer shows via Settings → Runtime footer.
|
|
||||||
val footerText =
|
|
||||||
if (!isCommentary && !msg.streaming && runtimeFooterEnabled) {
|
|
||||||
buildRuntimeFooterText(msg, runtimeFooterFields)
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
if (footerText.isNotEmpty() || time.isNotEmpty()) {
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
if (footerText.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
footerText,
|
|
||||||
color = textColor.copy(alpha = 0.4f),
|
|
||||||
fontSize = 10.sp,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
if (time.isNotEmpty()) {
|
if (time.isNotEmpty()) {
|
||||||
Text(time, fontSize = 10.sp, color = textColor.copy(alpha = 0.5f))
|
Text(time, fontSize = 10.sp, color = textColor.copy(alpha = 0.6f))
|
||||||
}
|
}
|
||||||
|
when (msg.status) {
|
||||||
|
MsgStatus.Pending -> {
|
||||||
|
Text(" sending…", fontSize = 10.sp, color = textColor.copy(alpha = 0.6f))
|
||||||
|
}
|
||||||
|
|
||||||
|
MsgStatus.Sent -> {
|
||||||
|
Text(" ✓", fontSize = 10.sp, color = textColor.copy(alpha = 0.8f))
|
||||||
|
}
|
||||||
|
|
||||||
|
MsgStatus.Read -> {
|
||||||
|
Text(" ✓✓", fontSize = 10.sp, color = textColor.copy(alpha = 0.8f))
|
||||||
|
}
|
||||||
|
|
||||||
|
MsgStatus.Failed -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msg.status == MsgStatus.Failed) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
Text("Failed to send — tap to retry", fontSize = 10.sp, color = IrisColors.errorText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (msg.text.isNotBlank() || msg.streaming) {
|
||||||
|
// M8: render the agent's reply as markdown (bold / italic /
|
||||||
|
// underscore, tables, highlighted code blocks). Leading
|
||||||
|
// newlines are stripped so the text hugs the top of the
|
||||||
|
// bubble; single newlines become hard breaks (models write
|
||||||
|
// status lines and wrapped text expecting a break per line,
|
||||||
|
// same as user input); the ▉ cursor is kept while streaming.
|
||||||
|
val displayText =
|
||||||
|
msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
|
||||||
|
if (msg.streaming) " ▉" else ""
|
||||||
|
MarkdownText(
|
||||||
|
text = displayText,
|
||||||
|
color = textColor,
|
||||||
|
fontSize = if (isCommentary) 13.sp else 15.sp,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
isStreaming = msg.streaming,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// M4: media attachments (image / player / document chip).
|
||||||
|
if (msg.media.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
msg.media.forEach { m -> MediaAttachment(m) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isUser) {
|
||||||
|
// Runtime-metadata footer + timestamp on ONE line (footer left,
|
||||||
|
// time right) — Telegram-style. The app controls whether/what
|
||||||
|
// the footer shows via Settings → Runtime footer.
|
||||||
|
val footerText =
|
||||||
|
if (!isCommentary && !msg.streaming && runtimeFooterEnabled) {
|
||||||
|
buildRuntimeFooterText(msg, runtimeFooterFields)
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
if (footerText.isNotEmpty() || time.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (footerText.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
footerText,
|
||||||
|
color = textColor.copy(alpha = 0.4f),
|
||||||
|
fontSize = 10.sp,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
if (time.isNotEmpty()) {
|
||||||
|
Text(time, fontSize = 10.sp, color = textColor.copy(alpha = 0.5f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasMenu) {
|
||||||
|
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||||
|
onCopy?.let {
|
||||||
|
DropdownMenuItem(text = { Text("Copy") }, onClick = {
|
||||||
|
menuOpen = false
|
||||||
|
it()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onSelect?.let {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Select messages") },
|
||||||
|
onClick = {
|
||||||
|
menuOpen = false
|
||||||
|
it()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDelete?.let {
|
||||||
|
DropdownMenuItem(text = { Text("Delete") }, onClick = {
|
||||||
|
menuOpen = false
|
||||||
|
it()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2699,6 +2792,7 @@ private fun SelectionCheck(selected: Boolean) {
|
|||||||
private fun SelectionToolbar(
|
private fun SelectionToolbar(
|
||||||
count: Int,
|
count: Int,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
|
onCopy: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -2720,6 +2814,20 @@ private fun SelectionToolbar(
|
|||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
// Copy the selection (secondary action → neutral chip, Delete stays
|
||||||
|
// the accent primary action).
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(20.dp))
|
||||||
|
.background(if (count > 0) IrisColors.chip else Color.Transparent)
|
||||||
|
.clickable(enabled = count > 0) { onCopy() }
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text("⧉ Copy", color = if (count > 0) IrisColors.textBright else IrisColors.textDim, fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
|||||||
Reference in new issue
Block a user