Channel context menu: long-press/right-click a row → rename, favorite/unfavorite, add/change/remove icon (color or image), delete; removed star/delete row buttons; favorite + icon/color synced across devices
This commit is contained in:
1 parent
0d99ee5cf5
commit
b681e547e7
12 files changed
+549
-43
No files matched your search
@@ -143,6 +143,33 @@ actual suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? = w
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(path, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@withContext null
|
||||
var sample = 1
|
||||
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > maxSize) sample *= 2
|
||||
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||
val bitmap = BitmapFactory.decodeFile(path, opts) ?: return@withContext null
|
||||
val stream = java.io.ByteArrayOutputStream()
|
||||
bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, stream)
|
||||
bitmap.recycle()
|
||||
android.util.Base64.encodeToString(stream.toByteArray(), android.util.Base64.NO_WRAP)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) {
|
||||
val path = media.localPath
|
||||
|
||||
@@ -54,6 +54,7 @@ class ChannelStore {
|
||||
private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> =
|
||||
list.sortedWith(
|
||||
compareByDescending<ChannelInfo> { it.isDefault }
|
||||
.thenByDescending { it.favorite }
|
||||
.thenBy { it.kind != "thread" }
|
||||
.thenBy { it.name.lowercase() },
|
||||
)
|
||||
|
||||
@@ -37,6 +37,16 @@ expect suspend fun loadMediaImage(path: String): ImageBitmap?
|
||||
/** Load an image scaled to at most [maxSize] px on the longest edge. Null on failure. */
|
||||
expect suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap?
|
||||
|
||||
/**
|
||||
* Encode a local image as a base64 PNG, downscaled to at most [maxSize] px on
|
||||
* the longest edge. Null on failure. Used for channel icons (stored on the
|
||||
* gateway, synced across devices).
|
||||
*/
|
||||
expect suspend fun encodeImageAsBase64(path: String, maxSize: Int): String?
|
||||
|
||||
/** Decode a base64-encoded image (PNG/JPEG) into an [ImageBitmap]. Null on failure. */
|
||||
expect suspend fun decodeBase64Image(base64: String): ImageBitmap?
|
||||
|
||||
/** Inline audio/video player (ExoPlayer on Android; placeholder on desktop). */
|
||||
@Composable
|
||||
expect fun MediaPlayerView(media: MediaItem, modifier: Modifier)
|
||||
|
||||
@@ -69,6 +69,8 @@ const val TYPE_STATUS = "status"
|
||||
const val TYPE_CHANNEL_CREATE = "channel.create"
|
||||
const val TYPE_CHANNEL_RENAME = "channel.rename"
|
||||
const val TYPE_CHANNEL_SET_DEFAULT = "channel.set_default"
|
||||
const val TYPE_CHANNEL_FAVORITE = "channel.favorite"
|
||||
const val TYPE_CHANNEL_ICON = "channel.icon"
|
||||
const val TYPE_CHANNEL_DELETE = "channel.delete"
|
||||
const val TYPE_CHANNEL_CREATED = "channel.created"
|
||||
const val TYPE_CHANNEL_RENAMED = "channel.renamed"
|
||||
@@ -158,6 +160,14 @@ data class ChannelInfo(
|
||||
/** Gateway minted this thread for an incoming message (auto-threading);
|
||||
* the name is a derived title, upgraded by the LLM via channel.renamed. */
|
||||
val auto: Boolean = false,
|
||||
/** Cosmetic favorite flag (sorts to the top of the channel list). */
|
||||
val favorite: Boolean = false,
|
||||
/** Cosmetic icon: a base64-encoded image (PNG/JPEG), or null for the
|
||||
* auto-generated letter avatar. */
|
||||
val icon: String? = null,
|
||||
/** Cosmetic avatar color override ("#RRGGBB"), or null for the
|
||||
* auto-generated name-hash color. */
|
||||
val color: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -326,6 +336,17 @@ data class ChannelCreatePayload(
|
||||
@Serializable
|
||||
data class ChannelRenamePayload(val name: String)
|
||||
|
||||
@Serializable
|
||||
data class ChannelFavoritePayload(val on: Boolean)
|
||||
|
||||
@Serializable
|
||||
data class ChannelIconPayload(
|
||||
/** base64-encoded image, or null to clear the icon. */
|
||||
val icon: String? = null,
|
||||
/** "#RRGGBB" avatar color, or null to clear it. */
|
||||
val color: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChannelListPayload(val channels: List<ChannelInfo> = emptyList())
|
||||
|
||||
@@ -508,6 +529,28 @@ fun channelRenameFrame(id: Int, chatId: String, name: String): Frame =
|
||||
fun channelSetDefaultFrame(id: Int, chatId: String): Frame =
|
||||
Frame(id = id, type = TYPE_CHANNEL_SET_DEFAULT, chatId = chatId)
|
||||
|
||||
fun channelFavoriteFrame(id: Int, chatId: String, on: Boolean): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_CHANNEL_FAVORITE,
|
||||
chatId = chatId,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
ChannelFavoritePayload.serializer(),
|
||||
ChannelFavoritePayload(on = on),
|
||||
),
|
||||
)
|
||||
|
||||
fun channelIconFrame(id: Int, chatId: String, icon: String?, color: String?): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_CHANNEL_ICON,
|
||||
chatId = chatId,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
ChannelIconPayload.serializer(),
|
||||
ChannelIconPayload(icon = icon, color = color),
|
||||
),
|
||||
)
|
||||
|
||||
fun channelDeleteFrame(id: Int, chatId: String): Frame =
|
||||
Frame(id = id, type = TYPE_CHANNEL_DELETE, chatId = chatId)
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ import iris.protocol.TYPE_TYPING
|
||||
import iris.protocol.TypingPayload
|
||||
import iris.protocol.channelCreateFrame
|
||||
import iris.protocol.channelDeleteFrame
|
||||
import iris.protocol.channelFavoriteFrame
|
||||
import iris.protocol.channelIconFrame
|
||||
import iris.protocol.channelListFrame
|
||||
import iris.protocol.channelRenameFrame
|
||||
import iris.protocol.channelSetDefaultFrame
|
||||
@@ -528,6 +530,17 @@ class IrisController(
|
||||
client.sendFrame(channelSetDefaultFrame(0, chatId))
|
||||
}
|
||||
|
||||
/** Toggle the cosmetic favorite flag (sorts the channel to the top). */
|
||||
fun setChannelFavorite(chatId: String, on: Boolean) {
|
||||
client.sendFrame(channelFavoriteFrame(0, chatId, on))
|
||||
}
|
||||
|
||||
/** Set the channel's cosmetic icon (base64 image) and/or avatar color.
|
||||
* Pass null to clear a field. */
|
||||
fun setChannelIcon(chatId: String, icon: String?, color: String?) {
|
||||
client.sendFrame(channelIconFrame(0, chatId, icon, color))
|
||||
}
|
||||
|
||||
fun deleteChannel(chatId: String) {
|
||||
client.sendFrame(channelDeleteFrame(0, chatId))
|
||||
}
|
||||
|
||||
@@ -104,8 +104,12 @@ import iris.data.MessageItem
|
||||
import iris.data.MsgStatus
|
||||
import iris.data.ToolItem
|
||||
import iris.net.GatewayClient
|
||||
import iris.platform.ImageFilePicker
|
||||
import iris.platform.MediaFilePicker
|
||||
import iris.platform.MediaPlayerView
|
||||
import iris.platform.PickedFile
|
||||
import iris.platform.decodeBase64Image
|
||||
import iris.platform.encodeImageAsBase64
|
||||
import iris.platform.isDesktop
|
||||
import iris.platform.loadMediaImage
|
||||
import iris.platform.openDocument
|
||||
@@ -175,6 +179,13 @@ fun ChatScreen(controller: IrisController) {
|
||||
var showNewThread by remember { mutableStateOf(false) }
|
||||
var showPicker by remember { mutableStateOf(false) }
|
||||
var showSettings by remember { mutableStateOf(false) }
|
||||
// Channel context menu (long-press / right-click a row): rename / delete /
|
||||
// icon dialogs. The image pick is a full-screen overlay (platform picker).
|
||||
var channelToRename by remember { mutableStateOf<ChannelInfo?>(null) }
|
||||
var channelToDelete by remember { mutableStateOf<ChannelInfo?>(null) }
|
||||
var channelToEditIcon by remember { mutableStateOf<ChannelInfo?>(null) }
|
||||
var iconImageTarget by remember { mutableStateOf<ChannelInfo?>(null) }
|
||||
var showIconImagePicker by remember { mutableStateOf(false) }
|
||||
// Status bubble tap → transient toast with the current connection status.
|
||||
var toastMessage by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(toastMessage) {
|
||||
@@ -308,7 +319,6 @@ fun ChatScreen(controller: IrisController) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header: title pill on the left, status bubble + search on the
|
||||
// right, single row on both wide and narrow layouts.
|
||||
val channelName = currentChannel?.name ?: "Iris"
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -320,7 +330,7 @@ fun ChatScreen(controller: IrisController) {
|
||||
Text("☰", fontSize = 18.sp)
|
||||
}
|
||||
}
|
||||
TitlePill(channelName, Modifier.weight(1f))
|
||||
TitlePill(currentChannel, Modifier.weight(1f))
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
StatusBubble(state) { toastMessage = statusToastText(state) }
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
@@ -669,8 +679,10 @@ fun ChatScreen(controller: IrisController) {
|
||||
focusRequester = railFocusRequester,
|
||||
onOpen = { ch -> controller.openChannel(ch.chatId) },
|
||||
onNewChannel = { showNewChannel = true },
|
||||
onSetDefault = { ch -> controller.setDefaultChannel(ch.chatId) },
|
||||
onDelete = { ch -> controller.deleteChannel(ch.chatId) },
|
||||
onRename = { ch -> channelToRename = ch },
|
||||
onToggleFavorite = { ch -> controller.setChannelFavorite(ch.chatId, !ch.favorite) },
|
||||
onEditIcon = { ch -> channelToEditIcon = ch },
|
||||
onDelete = { ch -> channelToDelete = ch },
|
||||
onOpenSettings = { showSettings = true },
|
||||
)
|
||||
Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(IrisColors.divider))
|
||||
@@ -705,8 +717,22 @@ fun ChatScreen(controller: IrisController) {
|
||||
showNewChannel = true
|
||||
drawerScope.launch { drawerState.close() }
|
||||
},
|
||||
onSetDefault = { ch -> controller.setDefaultChannel(ch.chatId) },
|
||||
onDelete = { ch -> controller.deleteChannel(ch.chatId) },
|
||||
onRename = { ch ->
|
||||
channelToRename = ch
|
||||
drawerScope.launch { drawerState.close() }
|
||||
},
|
||||
onToggleFavorite = { ch ->
|
||||
controller.setChannelFavorite(ch.chatId, !ch.favorite)
|
||||
drawerScope.launch { drawerState.close() }
|
||||
},
|
||||
onEditIcon = { ch ->
|
||||
channelToEditIcon = ch
|
||||
drawerScope.launch { drawerState.close() }
|
||||
},
|
||||
onDelete = { ch ->
|
||||
channelToDelete = ch
|
||||
drawerScope.launch { drawerState.close() }
|
||||
},
|
||||
onOpenSettings = {
|
||||
showSettings = true
|
||||
drawerScope.launch { drawerState.close() }
|
||||
@@ -780,6 +806,76 @@ fun ChatScreen(controller: IrisController) {
|
||||
)
|
||||
}
|
||||
|
||||
// Channel context menu: rename a channel.
|
||||
channelToRename?.let { ch ->
|
||||
NameDialog(
|
||||
title = "Rename channel",
|
||||
onConfirm = { name ->
|
||||
controller.renameChannel(ch.chatId, name)
|
||||
channelToRename = null
|
||||
},
|
||||
onDismiss = { channelToRename = null },
|
||||
initialName = ch.name,
|
||||
confirmLabel = "Rename",
|
||||
)
|
||||
}
|
||||
|
||||
// Channel context menu: delete a channel (with confirmation).
|
||||
channelToDelete?.let { ch ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { channelToDelete = null },
|
||||
title = { Text("Delete channel?") },
|
||||
text = { Text("Delete \"${ch.name}\"? Its history stays available for search.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
channelToDelete = null
|
||||
controller.deleteChannel(ch.chatId)
|
||||
}) { Text("Delete") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { channelToDelete = null }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Channel context menu: edit the icon (color / image / remove).
|
||||
channelToEditIcon?.let { ch ->
|
||||
ChannelIconDialog(
|
||||
channel = ch,
|
||||
onChooseColor = { color -> controller.setChannelIcon(ch.chatId, ch.icon, color) },
|
||||
onChooseImage = {
|
||||
iconImageTarget = ch
|
||||
channelToEditIcon = null
|
||||
showIconImagePicker = true
|
||||
},
|
||||
onRemove = {
|
||||
controller.setChannelIcon(ch.chatId, null, null)
|
||||
channelToEditIcon = null
|
||||
},
|
||||
onDismiss = { channelToEditIcon = null },
|
||||
)
|
||||
}
|
||||
|
||||
// Channel icon: full-screen image picker (platform picker can't be embedded
|
||||
// in a dialog). The picked image is downscaled + base64-encoded, then set.
|
||||
if (showIconImagePicker) {
|
||||
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.5f))) {
|
||||
ImageFilePicker(onPicked = { picked ->
|
||||
showIconImagePicker = false
|
||||
val target = iconImageTarget
|
||||
iconImageTarget = null
|
||||
if (picked != null && target != null) {
|
||||
drawerScope.launch {
|
||||
val base64 = encodeImageAsBase64(picked.path, 256)
|
||||
if (base64 != null) {
|
||||
controller.setChannelIcon(target.chatId, base64, target.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// M6: command palette (Ctrl/Cmd+K)
|
||||
if (showPalette) {
|
||||
CommandPalette(
|
||||
@@ -872,9 +968,10 @@ private fun SystemMessage(msg: MessageItem) {
|
||||
}
|
||||
}
|
||||
|
||||
/** M7: header title pill — letter avatar + channel name + "Bot" subtitle. */
|
||||
/** M7: header title pill — channel avatar + channel name + "Bot" subtitle. */
|
||||
@Composable
|
||||
private fun TitlePill(channelName: String, modifier: Modifier = Modifier) {
|
||||
private fun TitlePill(channel: ChannelInfo?, modifier: Modifier = Modifier) {
|
||||
val channelName = channel?.name ?: "Iris"
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
@@ -882,7 +979,8 @@ private fun TitlePill(channelName: String, modifier: Modifier = Modifier) {
|
||||
.padding(start = 8.dp, end = 12.dp, top = 4.dp, bottom = 4.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
LetterAvatar(channelName, 34.dp, IrisColors.primary)
|
||||
if (channel != null) ChannelAvatar(channel, 34.dp)
|
||||
else LetterAvatar(channelName, 34.dp, IrisColors.primary)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column {
|
||||
Text(
|
||||
@@ -936,14 +1034,174 @@ private fun LetterAvatar(name: String, size: Dp, color: Color) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Left drawer: channel list + create / set-default / delete / settings. */
|
||||
/** Parse a "#RRGGBB" (or "#AARRGGBB") hex string into a [Color]. */
|
||||
private fun parseHexColor(hex: String): Color {
|
||||
val cleaned = hex.removePrefix("#")
|
||||
val withAlpha = if (cleaned.length == 6) "FF$cleaned" else cleaned
|
||||
return Color(withAlpha.toLongOrNull(16) ?: 0xFF000000L)
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel avatar: a custom image icon when set, otherwise the letter avatar
|
||||
* tinted with the custom color (or the auto-generated name-hash color).
|
||||
*/
|
||||
@Composable
|
||||
private fun ChannelAvatar(channel: ChannelInfo, size: Dp) {
|
||||
if (channel.icon != null) {
|
||||
IconImage(channel.icon, size)
|
||||
} else {
|
||||
val color = channel.color?.let { parseHexColor(it) } ?: avatarColor(channel.name)
|
||||
LetterAvatar(channel.name, size, color)
|
||||
}
|
||||
}
|
||||
|
||||
/** Decodes a base64 channel-icon image and shows it (circular crop). */
|
||||
@Composable
|
||||
private fun IconImage(base64: String, size: Dp) {
|
||||
var bitmap by remember(base64) { mutableStateOf<ImageBitmap?>(null) }
|
||||
LaunchedEffect(base64) {
|
||||
bitmap = decodeBase64Image(base64)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(IrisColors.panel),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
bitmap?.let {
|
||||
Image(
|
||||
bitmap = it,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().clip(CircleShape),
|
||||
)
|
||||
} ?: Text("?", color = Color.White, fontSize = (size.value * 0.45f).sp)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Long-press / right-click context menu for a channel row: rename, favorite
|
||||
* (state-aware), icon (state-aware), delete.
|
||||
*/
|
||||
@Composable
|
||||
private fun ChannelContextMenu(
|
||||
expanded: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
ch: ChannelInfo,
|
||||
onRename: () -> Unit,
|
||||
onToggleFavorite: () -> Unit,
|
||||
onEditIcon: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val hasIcon = ch.icon != null || ch.color != null
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
DropdownMenuItem(text = { Text("Rename") }, onClick = { onDismiss(); onRename() })
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (ch.favorite) "Unfavorite" else "Favorite") },
|
||||
onClick = { onDismiss(); onToggleFavorite() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (hasIcon) "Change icon" else "Add icon") },
|
||||
onClick = { onDismiss(); onEditIcon() },
|
||||
)
|
||||
DropdownMenuItem(text = { Text("Delete") }, onClick = { onDismiss(); onDelete() })
|
||||
}
|
||||
}
|
||||
|
||||
/** Preset avatar colors for the channel-icon color picker. */
|
||||
private val AVATAR_COLORS = listOf(
|
||||
"#E57373", "#F06292", "#BA68C8", "#9575CD", "#7986CB",
|
||||
"#64B5F6", "#4FC3F7", "#4DD0E1", "#4DB6AC", "#81C784",
|
||||
"#AED581", "#FFD54F", "#FFB74D", "#FF8A65", "#A1887F",
|
||||
"#90A4AE",
|
||||
)
|
||||
|
||||
/** A small grid of preset color swatches. */
|
||||
@Composable
|
||||
private fun ColorPickerGrid(onPick: (String) -> Unit) {
|
||||
Column {
|
||||
for (rowStart in AVATAR_COLORS.indices step 5) {
|
||||
Row(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
AVATAR_COLORS.subList(rowStart, minOf(rowStart + 5, AVATAR_COLORS.size)).forEach { color ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.padding(3.dp)
|
||||
.clip(CircleShape)
|
||||
.background(parseHexColor(color))
|
||||
.clickable { onPick(color) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel-icon editor: preview + choose color / choose image / remove. The
|
||||
* image pick is driven by the caller (a full-screen picker overlay) because
|
||||
* the platform picker can't be embedded in a dialog.
|
||||
*/
|
||||
@Composable
|
||||
private fun ChannelIconDialog(
|
||||
channel: ChannelInfo,
|
||||
onChooseColor: (String) -> Unit,
|
||||
onChooseImage: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var showColorPicker by remember { mutableStateOf(false) }
|
||||
val hasIcon = channel.icon != null || channel.color != null
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(if (showColorPicker) "Choose color" else "Channel icon") },
|
||||
text = {
|
||||
if (showColorPicker) {
|
||||
Column {
|
||||
ColorPickerGrid(onPick = { color -> onChooseColor(color) })
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedButton(onClick = { showColorPicker = false }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Back")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
ChannelAvatar(channel, 56.dp)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(onClick = { showColorPicker = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Choose color")
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(onClick = onChooseImage, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Choose image")
|
||||
}
|
||||
if (hasIcon) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedButton(onClick = onRemove, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Remove icon")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Done") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Left drawer: channel list + create / settings. Row long-press → context menu. */
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ChannelDrawer(
|
||||
channels: List<ChannelInfo>,
|
||||
currentChatId: String,
|
||||
onOpen: (ChannelInfo) -> Unit,
|
||||
onNewChannel: () -> Unit,
|
||||
onSetDefault: (ChannelInfo) -> Unit,
|
||||
onRename: (ChannelInfo) -> Unit,
|
||||
onToggleFavorite: (ChannelInfo) -> Unit,
|
||||
onEditIcon: (ChannelInfo) -> Unit,
|
||||
onDelete: (ChannelInfo) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
) {
|
||||
@@ -959,32 +1217,36 @@ private fun ChannelDrawer(
|
||||
) {
|
||||
items(channels, key = { it.chatId }) { ch ->
|
||||
val isCurrent = ch.chatId == currentChatId
|
||||
Row(
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (isCurrent) IrisColors.chipSelected else Color.Transparent)
|
||||
.clickable { onOpen(ch) }
|
||||
.combinedClickable(onClick = { onOpen(ch) }, onLongClick = { menuOpen = true })
|
||||
.rightClick { menuOpen = true }
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
LetterAvatar(ch.name, 28.dp, avatarColor(ch.name))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ChannelAvatar(ch, 28.dp)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
ch.name + if (ch.isDefault) " ★" else "",
|
||||
ch.name + if (ch.isDefault || ch.favorite) " ★" else "",
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (!ch.isDefault) {
|
||||
TextButton(onClick = { onSetDefault(ch) }) {
|
||||
Text("★", fontSize = 12.sp)
|
||||
}
|
||||
TextButton(onClick = { onDelete(ch) }) {
|
||||
Text("✕", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
ChannelContextMenu(
|
||||
expanded = menuOpen,
|
||||
onDismiss = { menuOpen = false },
|
||||
ch = ch,
|
||||
onRename = { onRename(ch) },
|
||||
onToggleFavorite = { onToggleFavorite(ch) },
|
||||
onEditIcon = { onEditIcon(ch) },
|
||||
onDelete = { onDelete(ch) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1000,8 +1262,10 @@ private fun ChannelDrawer(
|
||||
|
||||
/**
|
||||
* M6: persistent channel rail (two-pane default, docs/11 §11.3). Keyboard:
|
||||
* ↑/↓ move the selection, Enter opens (when the rail has focus).
|
||||
* ↑/↓ move the selection, Enter opens (when the rail has focus). Row
|
||||
* long-press / right-click → context menu.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ChannelRail(
|
||||
channels: List<ChannelInfo>,
|
||||
@@ -1011,7 +1275,9 @@ private fun ChannelRail(
|
||||
focusRequester: FocusRequester,
|
||||
onOpen: (ChannelInfo) -> Unit,
|
||||
onNewChannel: () -> Unit,
|
||||
onSetDefault: (ChannelInfo) -> Unit,
|
||||
onRename: (ChannelInfo) -> Unit,
|
||||
onToggleFavorite: (ChannelInfo) -> Unit,
|
||||
onEditIcon: (ChannelInfo) -> Unit,
|
||||
onDelete: (ChannelInfo) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
) {
|
||||
@@ -1055,7 +1321,8 @@ private fun ChannelRail(
|
||||
items(channels, key = { it.chatId }) { ch ->
|
||||
val isCurrent = ch.chatId == currentChatId
|
||||
val isSelected = ch.chatId == channels.getOrNull(selectedIndex)?.chatId
|
||||
Row(
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
@@ -1066,10 +1333,11 @@ private fun ChannelRail(
|
||||
else -> Color.Transparent
|
||||
},
|
||||
)
|
||||
.clickable { onOpen(ch) }
|
||||
.combinedClickable(onClick = { onOpen(ch) }, onLongClick = { menuOpen = true })
|
||||
.rightClick { menuOpen = true }
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// M7: accent bar marks the active channel.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -1079,24 +1347,26 @@ private fun ChannelRail(
|
||||
.background(if (isCurrent) IrisColors.primary else Color.Transparent),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
LetterAvatar(ch.name, 28.dp, avatarColor(ch.name))
|
||||
ChannelAvatar(ch, 28.dp)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
ch.name + if (ch.isDefault) " ★" else "",
|
||||
ch.name + if (ch.isDefault || ch.favorite) " ★" else "",
|
||||
fontSize = 14.sp,
|
||||
color = if (isCurrent) IrisColors.primary else IrisColors.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (!ch.isDefault) {
|
||||
TextButton(onClick = { onSetDefault(ch) }) {
|
||||
Text("★", fontSize = 12.sp)
|
||||
}
|
||||
TextButton(onClick = { onDelete(ch) }) {
|
||||
Text("✕", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
ChannelContextMenu(
|
||||
expanded = menuOpen,
|
||||
onDismiss = { menuOpen = false },
|
||||
ch = ch,
|
||||
onRename = { onRename(ch) },
|
||||
onToggleFavorite = { onToggleFavorite(ch) },
|
||||
onEditIcon = { onEditIcon(ch) },
|
||||
onDelete = { onDelete(ch) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +180,34 @@ actual suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? = w
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val img = ImageIO.read(File(path)) ?: return@withContext null
|
||||
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
|
||||
val sw = (img.width * scale).toInt().coerceAtLeast(1)
|
||||
val sh = (img.height * scale).toInt().coerceAtLeast(1)
|
||||
val scaled = BufferedImage(sw, sh, BufferedImage.TYPE_INT_ARGB)
|
||||
val g = scaled.createGraphics()
|
||||
g.drawImage(img, 0, 0, sw, sh, null)
|
||||
g.dispose()
|
||||
val baos = ByteArrayOutputStream()
|
||||
ImageIO.write(scaled, "png", baos)
|
||||
java.util.Base64.getEncoder().encodeToString(baos.toByteArray())
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val bytes = java.util.Base64.getDecoder().decode(base64)
|
||||
val img = ImageIO.read(java.io.ByteArrayInputStream(bytes)) ?: return@withContext null
|
||||
img.toComposeImageBitmap()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* M6: inline media playback backed by mpv (docs/11 §11.2).
|
||||
*
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"notification": { "payload": { "kind": { "type": "string", "enum": ["channel_renamed", "channel_created", "channel_deleted", "cron", "approval", "clarify", "generic"] }, "title": { "type": "string" }, "body": { "type": "string" }, "ts": { "type": "integer" } } },
|
||||
"channel.list": { "description": "Full channel directory (response to a channel.list request).", "payload": { "channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } } } },
|
||||
"channel.created": { "description": "May carry auto:true for a thread the gateway minted itself for an incoming message (auto-threading); the name is a derived title, upgraded by a follow-up channel.renamed.", "payload": { "$ref": "#/definitions/channel" } },
|
||||
"channel.renamed": { "description": "Also the response to channel.set_default (carries the full entry incl. is_default).", "payload": { "$ref": "#/definitions/channel" } },
|
||||
"channel.renamed": { "description": "Also the response to channel.set_default / channel.favorite / channel.icon (carries the full entry incl. is_default, favorite, icon, color).", "payload": { "$ref": "#/definitions/channel" } },
|
||||
"channel.deleted": { "payload": { "chat_id": { "type": "string" } } },
|
||||
"search.results": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "hits": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "chat_id": {"type":"string"}, "thread_id": {"type":["string","null"]}, "role": {"type":"string"}, "snippet": {"type":"string"}, "ts": {"type":"integer"} } } } } },
|
||||
"media.offer": { "description": "Agent-sent media available; app pulls bytes.", "payload": { "$ref": "#/definitions/media_ref" } },
|
||||
@@ -72,7 +72,9 @@
|
||||
"media.pull": { "payload": { "media_id": { "type": "string" } } },
|
||||
"channel.create": { "payload": { "name": { "type": "string" }, "kind": { "type": "string", "enum": ["channel", "thread"] }, "parent_chat_id": { "type": ["string", "null"] } } },
|
||||
"channel.rename": { "payload": { "name": { "type": "string" } } },
|
||||
"channel.set_default": { "payload": {} },
|
||||
"channel.set_default": { "payload": {} },
|
||||
"channel.favorite": { "description": "Toggle the cosmetic favorite flag (sorts the channel to the top of the list). Answered by a channel.renamed carrying the full entry.", "payload": { "on": { "type": "boolean" } } },
|
||||
"channel.icon": { "description": "Set the channel's cosmetic icon (base64 image) and/or avatar color. Omit / null a field to clear it. Answered by a channel.renamed carrying the full entry.", "payload": { "icon": { "type": ["string", "null"], "description": "base64-encoded image (PNG/JPEG), max ~512 KiB." }, "color": { "type": ["string", "null"], "description": "#RRGGBB avatar color override." } } },
|
||||
"channel.delete": { "payload": {} },
|
||||
"channel.list": { "description": "Request the full channel directory; answered by the server_to_app channel.list frame.", "payload": {} },
|
||||
"search": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "chat_id": { "type": "string" }, "thread_id": { "type": "string" }, "limit": { "type": "integer", "description": "Optional; server default 20." } } },
|
||||
@@ -85,7 +87,7 @@
|
||||
},
|
||||
"definitions": {
|
||||
"kind": { "type": "string", "enum": ["image", "audio", "video", "document", "voice"] },
|
||||
"channel": { "type": "object", "properties": { "chat_id": {"type":"string"}, "name": {"type":"string"}, "kind": {"type":"string","enum":["default","channel","thread"]}, "parent_chat_id": {"type":["string","null"]}, "is_default": {"type":"boolean"}, "archived": {"type":"boolean"}, "auto": {"type":"boolean","description":"Optional; true on channel.created for a gateway-minted auto-thread."} } },
|
||||
"channel": { "type": "object", "properties": { "chat_id": {"type":"string"}, "name": {"type":"string"}, "kind": {"type":"string","enum":["default","channel","thread"]}, "parent_chat_id": {"type":["string","null"]}, "is_default": {"type":"boolean"}, "archived": {"type":"boolean"}, "auto": {"type":"boolean","description":"Optional; true on channel.created for a gateway-minted auto-thread."}, "favorite": {"type":"boolean","description":"Optional; cosmetic favorite flag (sorts to the top of the list)."}, "icon": {"type":["string","null"],"description":"Optional; cosmetic icon, a base64-encoded image (PNG/JPEG). Absent/null = auto-generated letter avatar."}, "color": {"type":["string","null"],"description":"Optional; cosmetic avatar color override (#RRGGBB). Absent/null = auto-generated name-hash color."} } },
|
||||
"media_ref": { "type": "object", "properties": { "media_id": {"type":"string"}, "kind": { "$ref": "#/definitions/kind" }, "mime": {"type":"string"}, "size": {"type":"integer"}, "filename": {"type":"string"}, "message_id": {"type":"string","description":"Optional; set on media.offer to associate the offer with the assistant message it belongs to."} } }
|
||||
},
|
||||
"x-planned-frames": [
|
||||
|
||||
@@ -2100,6 +2100,59 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
resp.id = frame.id
|
||||
await self._ws_server.broadcast(resp)
|
||||
|
||||
async def on_channel_favorite(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, "channel.favorite requires chat_id", id=frame.id),
|
||||
)
|
||||
return
|
||||
on = bool(frame.payload.get("on"))
|
||||
entry = self._channels.set_favorite(chat_id, on)
|
||||
if entry is None:
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
||||
)
|
||||
return
|
||||
# Reuse the renamed event shape: it carries the full entry (incl. the
|
||||
# new favorite flag) so every device reconciles the change.
|
||||
resp = protocol.channel_renamed(entry)
|
||||
resp.id = frame.id
|
||||
await self._ws_server.broadcast(resp)
|
||||
|
||||
async def on_channel_icon(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, "channel.icon requires chat_id", id=frame.id),
|
||||
)
|
||||
return
|
||||
payload = frame.payload
|
||||
icon = payload.get("icon")
|
||||
icon = icon if isinstance(icon, str) and icon else None
|
||||
color = payload.get("color")
|
||||
color = color if isinstance(color, str) and color else None
|
||||
# Guard against a runaway base64 blob (a channel icon is small).
|
||||
if icon is not None and len(icon) > 512 * 1024:
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_UNSUPPORTED, "channel icon too large", id=frame.id),
|
||||
)
|
||||
return
|
||||
entry = self._channels.set_icon(chat_id, icon, color)
|
||||
if entry is None:
|
||||
await self._ws_server.send_to(
|
||||
device_id,
|
||||
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
||||
)
|
||||
return
|
||||
resp = protocol.channel_renamed(entry)
|
||||
resp.id = frame.id
|
||||
await self._ws_server.broadcast(resp)
|
||||
|
||||
async def on_channel_delete(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
|
||||
@@ -64,10 +64,25 @@ class ChannelDirectory:
|
||||
parent_chat_id TEXT,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
created REAL NOT NULL DEFAULT 0
|
||||
created REAL NOT NULL DEFAULT 0,
|
||||
favorite INTEGER NOT NULL DEFAULT 0,
|
||||
icon TEXT,
|
||||
color TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
# Migrate existing DBs: add the cosmetic columns if missing.
|
||||
existing = {
|
||||
row[1] for row in self._conn.execute("PRAGMA table_info(channels)")
|
||||
}
|
||||
if "favorite" not in existing:
|
||||
self._conn.execute(
|
||||
"ALTER TABLE channels ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
if "icon" not in existing:
|
||||
self._conn.execute("ALTER TABLE channels ADD COLUMN icon TEXT")
|
||||
if "color" not in existing:
|
||||
self._conn.execute("ALTER TABLE channels ADD COLUMN color TEXT")
|
||||
self._conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS counters (
|
||||
@@ -206,6 +221,35 @@ class ChannelDirectory:
|
||||
self._conn.commit()
|
||||
return self.get(chat_id)
|
||||
|
||||
def set_favorite(self, chat_id: str, on: bool) -> Optional[Dict[str, Any]]:
|
||||
"""Toggle the cosmetic favorite flag (sorts to the top of the list)."""
|
||||
with self._lock:
|
||||
cur = self._conn.execute(
|
||||
"UPDATE channels SET favorite = ? WHERE chat_id = ? AND archived = 0",
|
||||
(1 if on else 0, chat_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
if cur.rowcount == 0:
|
||||
return None
|
||||
return self.get(chat_id)
|
||||
|
||||
def set_icon(self, chat_id: str, icon: Optional[str], color: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
"""Set the channel's cosmetic icon (base64 image) and/or avatar color.
|
||||
|
||||
``icon`` is a base64-encoded image (or ``None`` to clear it); ``color``
|
||||
is a ``#RRGGBB`` hex string (or ``None`` to clear it). Both are purely
|
||||
cosmetic and independent of the name / default flag.
|
||||
"""
|
||||
with self._lock:
|
||||
cur = self._conn.execute(
|
||||
"UPDATE channels SET icon = ?, color = ? WHERE chat_id = ? AND archived = 0",
|
||||
(icon, color, chat_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
if cur.rowcount == 0:
|
||||
return None
|
||||
return self.get(chat_id)
|
||||
|
||||
def delete(self, chat_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Soft-delete (archive) a channel. History stays for search.
|
||||
|
||||
@@ -234,11 +278,11 @@ class ChannelDirectory:
|
||||
return _row_to_entry(row) if row else None
|
||||
|
||||
def list(self, include_archived: bool = False) -> List[Dict[str, Any]]:
|
||||
"""Directory listing. Default channel first, then creation order."""
|
||||
"""Directory listing. Default first, then favorites, then creation order."""
|
||||
sql = "SELECT * FROM channels"
|
||||
if not include_archived:
|
||||
sql += " WHERE archived = 0"
|
||||
sql += " ORDER BY is_default DESC, created ASC"
|
||||
sql += " ORDER BY is_default DESC, favorite DESC, created ASC"
|
||||
with self._lock:
|
||||
rows = self._conn.execute(sql).fetchall()
|
||||
return [_row_to_entry(r) for r in rows]
|
||||
@@ -318,6 +362,9 @@ def _row_to_entry(row: sqlite3.Row) -> Dict[str, Any]:
|
||||
"is_default": bool(row["is_default"]),
|
||||
"archived": bool(row["archived"]),
|
||||
"created": row["created"],
|
||||
"favorite": bool(row["favorite"]),
|
||||
"icon": row["icon"],
|
||||
"color": row["color"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ TYPE_COMMENTARY = "commentary"
|
||||
TYPE_CHANNEL_CREATE = "channel.create"
|
||||
TYPE_CHANNEL_RENAME = "channel.rename"
|
||||
TYPE_CHANNEL_SET_DEFAULT = "channel.set_default"
|
||||
TYPE_CHANNEL_FAVORITE = "channel.favorite"
|
||||
TYPE_CHANNEL_ICON = "channel.icon"
|
||||
TYPE_CHANNEL_DELETE = "channel.delete"
|
||||
TYPE_CHANNEL_CREATED = "channel.created"
|
||||
TYPE_CHANNEL_RENAMED = "channel.renamed"
|
||||
@@ -434,6 +436,12 @@ def _channel_payload(entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
payload["is_default"] = True
|
||||
if entry.get("archived"):
|
||||
payload["archived"] = True
|
||||
if entry.get("favorite"):
|
||||
payload["favorite"] = True
|
||||
if entry.get("icon"):
|
||||
payload["icon"] = entry["icon"]
|
||||
if entry.get("color"):
|
||||
payload["color"] = entry["color"]
|
||||
return payload
|
||||
|
||||
|
||||
|
||||
@@ -370,6 +370,10 @@ class WsServer:
|
||||
await self._adapter.on_channel_rename(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_SET_DEFAULT:
|
||||
await self._adapter.on_channel_set_default(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_FAVORITE:
|
||||
await self._adapter.on_channel_favorite(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_ICON:
|
||||
await self._adapter.on_channel_icon(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_DELETE:
|
||||
await self._adapter.on_channel_delete(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_LIST:
|
||||
|
||||
Reference in new issue
Block a user