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:
ARIA committed 2026-08-20 20:55:24 +02:00
1 parent 079ef664a2
commit 49b7a3f577
12 files changed
+574 -68

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))
Spacer(modifier = Modifier.width(8.dp))
Text(
ch.name + if (ch.isDefault) " ★" 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)
}
Row(verticalAlignment = Alignment.CenterVertically) {
ChannelAvatar(ch, 28.dp)
Spacer(modifier = Modifier.width(8.dp))
Text(
ch.name + if (ch.isDefault || ch.favorite) " ★" else "",
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
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,37 +1333,40 @@ 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,
) {
// M7: accent bar marks the active channel.
Box(
modifier = Modifier
.width(3.dp)
.height(22.dp)
.clip(RoundedCornerShape(2.dp))
.background(if (isCurrent) IrisColors.primary else Color.Transparent),
)
Spacer(modifier = Modifier.width(8.dp))
LetterAvatar(ch.name, 28.dp, avatarColor(ch.name))
Spacer(modifier = Modifier.width(8.dp))
Text(
ch.name + if (ch.isDefault) " ★" 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)
}
Row(verticalAlignment = Alignment.CenterVertically) {
// M7: accent bar marks the active channel.
Box(
modifier = Modifier
.width(3.dp)
.height(22.dp)
.clip(RoundedCornerShape(2.dp))
.background(if (isCurrent) IrisColors.primary else Color.Transparent),
)
Spacer(modifier = Modifier.width(8.dp))
ChannelAvatar(ch, 28.dp)
Spacer(modifier = Modifier.width(8.dp))
Text(
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),
)
}
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).
*