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 0d99ee5cf5
commit b681e547e7
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 @Composable
actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) { actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) {
val path = media.localPath val path = media.localPath
@@ -54,6 +54,7 @@ class ChannelStore {
private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> = private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> =
list.sortedWith( list.sortedWith(
compareByDescending<ChannelInfo> { it.isDefault } compareByDescending<ChannelInfo> { it.isDefault }
.thenByDescending { it.favorite }
.thenBy { it.kind != "thread" } .thenBy { it.kind != "thread" }
.thenBy { it.name.lowercase() }, .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. */ /** 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? 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). */ /** Inline audio/video player (ExoPlayer on Android; placeholder on desktop). */
@Composable @Composable
expect fun MediaPlayerView(media: MediaItem, modifier: Modifier) 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_CREATE = "channel.create"
const val TYPE_CHANNEL_RENAME = "channel.rename" const val TYPE_CHANNEL_RENAME = "channel.rename"
const val TYPE_CHANNEL_SET_DEFAULT = "channel.set_default" 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_DELETE = "channel.delete"
const val TYPE_CHANNEL_CREATED = "channel.created" const val TYPE_CHANNEL_CREATED = "channel.created"
const val TYPE_CHANNEL_RENAMED = "channel.renamed" const val TYPE_CHANNEL_RENAMED = "channel.renamed"
@@ -158,6 +160,14 @@ data class ChannelInfo(
/** Gateway minted this thread for an incoming message (auto-threading); /** Gateway minted this thread for an incoming message (auto-threading);
* the name is a derived title, upgraded by the LLM via channel.renamed. */ * the name is a derived title, upgraded by the LLM via channel.renamed. */
val auto: Boolean = false, 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 @Serializable
@@ -326,6 +336,17 @@ data class ChannelCreatePayload(
@Serializable @Serializable
data class ChannelRenamePayload(val name: String) 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 @Serializable
data class ChannelListPayload(val channels: List<ChannelInfo> = emptyList()) 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 = fun channelSetDefaultFrame(id: Int, chatId: String): Frame =
Frame(id = id, type = TYPE_CHANNEL_SET_DEFAULT, chatId = chatId) 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 = fun channelDeleteFrame(id: Int, chatId: String): Frame =
Frame(id = id, type = TYPE_CHANNEL_DELETE, chatId = chatId) 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.TypingPayload
import iris.protocol.channelCreateFrame import iris.protocol.channelCreateFrame
import iris.protocol.channelDeleteFrame import iris.protocol.channelDeleteFrame
import iris.protocol.channelFavoriteFrame
import iris.protocol.channelIconFrame
import iris.protocol.channelListFrame import iris.protocol.channelListFrame
import iris.protocol.channelRenameFrame import iris.protocol.channelRenameFrame
import iris.protocol.channelSetDefaultFrame import iris.protocol.channelSetDefaultFrame
@@ -528,6 +530,17 @@ class IrisController(
client.sendFrame(channelSetDefaultFrame(0, chatId)) 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) { fun deleteChannel(chatId: String) {
client.sendFrame(channelDeleteFrame(0, chatId)) client.sendFrame(channelDeleteFrame(0, chatId))
} }
@@ -104,8 +104,12 @@ import iris.data.MessageItem
import iris.data.MsgStatus import iris.data.MsgStatus
import iris.data.ToolItem import iris.data.ToolItem
import iris.net.GatewayClient import iris.net.GatewayClient
import iris.platform.ImageFilePicker
import iris.platform.MediaFilePicker import iris.platform.MediaFilePicker
import iris.platform.MediaPlayerView import iris.platform.MediaPlayerView
import iris.platform.PickedFile
import iris.platform.decodeBase64Image
import iris.platform.encodeImageAsBase64
import iris.platform.isDesktop import iris.platform.isDesktop
import iris.platform.loadMediaImage import iris.platform.loadMediaImage
import iris.platform.openDocument import iris.platform.openDocument
@@ -175,6 +179,13 @@ fun ChatScreen(controller: IrisController) {
var showNewThread by remember { mutableStateOf(false) } var showNewThread by remember { mutableStateOf(false) }
var showPicker by remember { mutableStateOf(false) } var showPicker by remember { mutableStateOf(false) }
var showSettings 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. // Status bubble tap → transient toast with the current connection status.
var toastMessage by remember { mutableStateOf<String?>(null) } var toastMessage by remember { mutableStateOf<String?>(null) }
LaunchedEffect(toastMessage) { LaunchedEffect(toastMessage) {
@@ -308,7 +319,6 @@ fun ChatScreen(controller: IrisController) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Header: title pill on the left, status bubble + search on the // Header: title pill on the left, status bubble + search on the
// right, single row on both wide and narrow layouts. // right, single row on both wide and narrow layouts.
val channelName = currentChannel?.name ?: "Iris"
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -320,7 +330,7 @@ fun ChatScreen(controller: IrisController) {
Text("☰", fontSize = 18.sp) Text("☰", fontSize = 18.sp)
} }
} }
TitlePill(channelName, Modifier.weight(1f)) TitlePill(currentChannel, Modifier.weight(1f))
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
StatusBubble(state) { toastMessage = statusToastText(state) } StatusBubble(state) { toastMessage = statusToastText(state) }
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
@@ -669,8 +679,10 @@ fun ChatScreen(controller: IrisController) {
focusRequester = railFocusRequester, focusRequester = railFocusRequester,
onOpen = { ch -> controller.openChannel(ch.chatId) }, onOpen = { ch -> controller.openChannel(ch.chatId) },
onNewChannel = { showNewChannel = true }, onNewChannel = { showNewChannel = true },
onSetDefault = { ch -> controller.setDefaultChannel(ch.chatId) }, onRename = { ch -> channelToRename = ch },
onDelete = { ch -> controller.deleteChannel(ch.chatId) }, onToggleFavorite = { ch -> controller.setChannelFavorite(ch.chatId, !ch.favorite) },
onEditIcon = { ch -> channelToEditIcon = ch },
onDelete = { ch -> channelToDelete = ch },
onOpenSettings = { showSettings = true }, onOpenSettings = { showSettings = true },
) )
Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(IrisColors.divider)) Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(IrisColors.divider))
@@ -705,8 +717,22 @@ fun ChatScreen(controller: IrisController) {
showNewChannel = true showNewChannel = true
drawerScope.launch { drawerState.close() } drawerScope.launch { drawerState.close() }
}, },
onSetDefault = { ch -> controller.setDefaultChannel(ch.chatId) }, onRename = { ch ->
onDelete = { ch -> controller.deleteChannel(ch.chatId) }, 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 = { onOpenSettings = {
showSettings = true showSettings = true
drawerScope.launch { drawerState.close() } 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) // M6: command palette (Ctrl/Cmd+K)
if (showPalette) { if (showPalette) {
CommandPalette( 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 @Composable
private fun TitlePill(channelName: String, modifier: Modifier = Modifier) { private fun TitlePill(channel: ChannelInfo?, modifier: Modifier = Modifier) {
val channelName = channel?.name ?: "Iris"
Box( Box(
modifier = modifier modifier = modifier
.clip(RoundedCornerShape(16.dp)) .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), .padding(start = 8.dp, end = 12.dp, top = 4.dp, bottom = 4.dp),
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { 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)) Spacer(modifier = Modifier.width(10.dp))
Column { Column {
Text( 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 @Composable
private fun ChannelDrawer( private fun ChannelDrawer(
channels: List<ChannelInfo>, channels: List<ChannelInfo>,
currentChatId: String, currentChatId: String,
onOpen: (ChannelInfo) -> Unit, onOpen: (ChannelInfo) -> Unit,
onNewChannel: () -> Unit, onNewChannel: () -> Unit,
onSetDefault: (ChannelInfo) -> Unit, onRename: (ChannelInfo) -> Unit,
onToggleFavorite: (ChannelInfo) -> Unit,
onEditIcon: (ChannelInfo) -> Unit,
onDelete: (ChannelInfo) -> Unit, onDelete: (ChannelInfo) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
) { ) {
@@ -959,32 +1217,36 @@ private fun ChannelDrawer(
) { ) {
items(channels, key = { it.chatId }) { ch -> items(channels, key = { it.chatId }) { ch ->
val isCurrent = ch.chatId == currentChatId val isCurrent = ch.chatId == currentChatId
Row( var menuOpen by remember { mutableStateOf(false) }
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(if (isCurrent) IrisColors.chipSelected else Color.Transparent) .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), .padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
LetterAvatar(ch.name, 28.dp, avatarColor(ch.name)) Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.width(8.dp)) ChannelAvatar(ch, 28.dp)
Text( Spacer(modifier = Modifier.width(8.dp))
ch.name + if (ch.isDefault) " ★" else "", Text(
fontSize = 14.sp, ch.name + if (ch.isDefault || ch.favorite) " ★" else "",
maxLines = 1, fontSize = 14.sp,
overflow = TextOverflow.Ellipsis, maxLines = 1,
modifier = Modifier.weight(1f), 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: * 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 @Composable
private fun ChannelRail( private fun ChannelRail(
channels: List<ChannelInfo>, channels: List<ChannelInfo>,
@@ -1011,7 +1275,9 @@ private fun ChannelRail(
focusRequester: FocusRequester, focusRequester: FocusRequester,
onOpen: (ChannelInfo) -> Unit, onOpen: (ChannelInfo) -> Unit,
onNewChannel: () -> Unit, onNewChannel: () -> Unit,
onSetDefault: (ChannelInfo) -> Unit, onRename: (ChannelInfo) -> Unit,
onToggleFavorite: (ChannelInfo) -> Unit,
onEditIcon: (ChannelInfo) -> Unit,
onDelete: (ChannelInfo) -> Unit, onDelete: (ChannelInfo) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
) { ) {
@@ -1055,7 +1321,8 @@ private fun ChannelRail(
items(channels, key = { it.chatId }) { ch -> items(channels, key = { it.chatId }) { ch ->
val isCurrent = ch.chatId == currentChatId val isCurrent = ch.chatId == currentChatId
val isSelected = ch.chatId == channels.getOrNull(selectedIndex)?.chatId val isSelected = ch.chatId == channels.getOrNull(selectedIndex)?.chatId
Row( var menuOpen by remember { mutableStateOf(false) }
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
@@ -1066,37 +1333,40 @@ private fun ChannelRail(
else -> Color.Transparent else -> Color.Transparent
}, },
) )
.clickable { onOpen(ch) } .combinedClickable(onClick = { onOpen(ch) }, onLongClick = { menuOpen = true })
.rightClick { menuOpen = true }
.padding(horizontal = 10.dp, vertical = 6.dp), .padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
// M7: accent bar marks the active channel. Row(verticalAlignment = Alignment.CenterVertically) {
Box( // M7: accent bar marks the active channel.
modifier = Modifier Box(
.width(3.dp) modifier = Modifier
.height(22.dp) .width(3.dp)
.clip(RoundedCornerShape(2.dp)) .height(22.dp)
.background(if (isCurrent) IrisColors.primary else Color.Transparent), .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))
Spacer(modifier = Modifier.width(8.dp)) ChannelAvatar(ch, 28.dp)
Text( Spacer(modifier = Modifier.width(8.dp))
ch.name + if (ch.isDefault) " ★" else "", Text(
fontSize = 14.sp, ch.name + if (ch.isDefault || ch.favorite) " ★" else "",
color = if (isCurrent) IrisColors.primary else IrisColors.onSurface, fontSize = 14.sp,
maxLines = 1, color = if (isCurrent) IrisColors.primary else IrisColors.onSurface,
overflow = TextOverflow.Ellipsis, maxLines = 1,
modifier = Modifier.weight(1f), 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). * M6: inline media playback backed by mpv (docs/11 §11.2).
* *
+6 -4
View File
@@ -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" } } }, "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.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.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" } } }, "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"} } } } } }, "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" } }, "media.offer": { "description": "Agent-sent media available; app pulls bytes.", "payload": { "$ref": "#/definitions/media_ref" } },
@@ -72,8 +72,10 @@
"media.pull": { "payload": { "media_id": { "type": "string" } } }, "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.create": { "payload": { "name": { "type": "string" }, "kind": { "type": "string", "enum": ["channel", "thread"] }, "parent_chat_id": { "type": ["string", "null"] } } },
"channel.rename": { "payload": { "name": { "type": "string" } } }, "channel.rename": { "payload": { "name": { "type": "string" } } },
"channel.set_default": { "payload": {} }, "channel.set_default": { "payload": {} },
"channel.delete": { "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": {} }, "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." } } }, "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." } } },
"sync": { "description": "Reconnect catch-up; replays undelivered outbox frames only (not full history).", "payload": { "cursor": { "type": "integer" } } }, "sync": { "description": "Reconnect catch-up; replays undelivered outbox frames only (not full history).", "payload": { "cursor": { "type": "integer" } } },
@@ -85,7 +87,7 @@
}, },
"definitions": { "definitions": {
"kind": { "type": "string", "enum": ["image", "audio", "video", "document", "voice"] }, "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."} } } "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": [ "x-planned-frames": [
+53
View File
@@ -2100,6 +2100,59 @@ class AndroidAdapter(BasePlatformAdapter):
resp.id = frame.id resp.id = frame.id
await self._ws_server.broadcast(resp) 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: async def on_channel_delete(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id") chat_id = frame.chat_id or frame.payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip(): if not isinstance(chat_id, str) or not chat_id.strip():
+50 -3
View File
@@ -64,10 +64,25 @@ class ChannelDirectory:
parent_chat_id TEXT, parent_chat_id TEXT,
is_default INTEGER NOT NULL DEFAULT 0, is_default INTEGER NOT NULL DEFAULT 0,
archived 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( self._conn.execute(
""" """
CREATE TABLE IF NOT EXISTS counters ( CREATE TABLE IF NOT EXISTS counters (
@@ -206,6 +221,35 @@ class ChannelDirectory:
self._conn.commit() self._conn.commit()
return self.get(chat_id) 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]]: def delete(self, chat_id: str) -> Optional[Dict[str, Any]]:
"""Soft-delete (archive) a channel. History stays for search. """Soft-delete (archive) a channel. History stays for search.
@@ -234,11 +278,11 @@ class ChannelDirectory:
return _row_to_entry(row) if row else None return _row_to_entry(row) if row else None
def list(self, include_archived: bool = False) -> List[Dict[str, Any]]: 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" sql = "SELECT * FROM channels"
if not include_archived: if not include_archived:
sql += " WHERE archived = 0" 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: with self._lock:
rows = self._conn.execute(sql).fetchall() rows = self._conn.execute(sql).fetchall()
return [_row_to_entry(r) for r in rows] 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"]), "is_default": bool(row["is_default"]),
"archived": bool(row["archived"]), "archived": bool(row["archived"]),
"created": row["created"], "created": row["created"],
"favorite": bool(row["favorite"]),
"icon": row["icon"],
"color": row["color"],
} }
+8
View File
@@ -58,6 +58,8 @@ TYPE_COMMENTARY = "commentary"
TYPE_CHANNEL_CREATE = "channel.create" TYPE_CHANNEL_CREATE = "channel.create"
TYPE_CHANNEL_RENAME = "channel.rename" TYPE_CHANNEL_RENAME = "channel.rename"
TYPE_CHANNEL_SET_DEFAULT = "channel.set_default" TYPE_CHANNEL_SET_DEFAULT = "channel.set_default"
TYPE_CHANNEL_FAVORITE = "channel.favorite"
TYPE_CHANNEL_ICON = "channel.icon"
TYPE_CHANNEL_DELETE = "channel.delete" TYPE_CHANNEL_DELETE = "channel.delete"
TYPE_CHANNEL_CREATED = "channel.created" TYPE_CHANNEL_CREATED = "channel.created"
TYPE_CHANNEL_RENAMED = "channel.renamed" TYPE_CHANNEL_RENAMED = "channel.renamed"
@@ -434,6 +436,12 @@ def _channel_payload(entry: Dict[str, Any]) -> Dict[str, Any]:
payload["is_default"] = True payload["is_default"] = True
if entry.get("archived"): if entry.get("archived"):
payload["archived"] = True 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 return payload
+4
View File
@@ -370,6 +370,10 @@ class WsServer:
await self._adapter.on_channel_rename(frame, device_id) await self._adapter.on_channel_rename(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_SET_DEFAULT: elif frame.type == protocol.TYPE_CHANNEL_SET_DEFAULT:
await self._adapter.on_channel_set_default(frame, device_id) 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: elif frame.type == protocol.TYPE_CHANNEL_DELETE:
await self._adapter.on_channel_delete(frame, device_id) await self._adapter.on_channel_delete(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_LIST: elif frame.type == protocol.TYPE_CHANNEL_LIST: