diff --git a/app/shared/src/androidMain/kotlin/iris/platform/AndroidMedia.kt b/app/shared/src/androidMain/kotlin/iris/platform/AndroidMedia.kt index 81c3695..4471cfa 100644 --- a/app/shared/src/androidMain/kotlin/iris/platform/AndroidMedia.kt +++ b/app/shared/src/androidMain/kotlin/iris/platform/AndroidMedia.kt @@ -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 diff --git a/app/shared/src/commonMain/kotlin/iris/data/ChannelStore.kt b/app/shared/src/commonMain/kotlin/iris/data/ChannelStore.kt index 7338400..a12b040 100644 --- a/app/shared/src/commonMain/kotlin/iris/data/ChannelStore.kt +++ b/app/shared/src/commonMain/kotlin/iris/data/ChannelStore.kt @@ -54,6 +54,7 @@ class ChannelStore { private fun sorted(list: List): List = list.sortedWith( compareByDescending { it.isDefault } + .thenByDescending { it.favorite } .thenBy { it.kind != "thread" } .thenBy { it.name.lowercase() }, ) diff --git a/app/shared/src/commonMain/kotlin/iris/platform/PlatformMedia.kt b/app/shared/src/commonMain/kotlin/iris/platform/PlatformMedia.kt index 5abfec4..654b3a1 100644 --- a/app/shared/src/commonMain/kotlin/iris/platform/PlatformMedia.kt +++ b/app/shared/src/commonMain/kotlin/iris/platform/PlatformMedia.kt @@ -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) diff --git a/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt b/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt index c7920ed..96b4864 100644 --- a/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt +++ b/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt @@ -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 = 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) diff --git a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt index 0ba3605..f81c3c3 100644 --- a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt +++ b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt @@ -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)) } diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt index d91d833..a20f40d 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -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(null) } + var channelToDelete by remember { mutableStateOf(null) } + var channelToEditIcon by remember { mutableStateOf(null) } + var iconImageTarget by remember { mutableStateOf(null) } + var showIconImagePicker by remember { mutableStateOf(false) } // Status bubble tap → transient toast with the current connection status. var toastMessage by remember { mutableStateOf(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(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, 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, @@ -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) }, + ) } } } diff --git a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopMedia.kt b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopMedia.kt index c4ed3eb..5509e72 100644 --- a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopMedia.kt +++ b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopMedia.kt @@ -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). * diff --git a/docs/protocol/frames.schema.json b/docs/protocol/frames.schema.json index 7162d4f..10d4133 100644 --- a/docs/protocol/frames.schema.json +++ b/docs/protocol/frames.schema.json @@ -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,8 +72,10 @@ "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.delete": { "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." } } }, "sync": { "description": "Reconnect catch-up; replays undelivered outbox frames only (not full history).", "payload": { "cursor": { "type": "integer" } } }, @@ -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": [ diff --git a/gateway-plugin/adapter.py b/gateway-plugin/adapter.py index 3b29e7e..abb960f 100644 --- a/gateway-plugin/adapter.py +++ b/gateway-plugin/adapter.py @@ -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(): diff --git a/gateway-plugin/channels.py b/gateway-plugin/channels.py index 956a84c..452a0a1 100644 --- a/gateway-plugin/channels.py +++ b/gateway-plugin/channels.py @@ -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"], } diff --git a/gateway-plugin/protocol.py b/gateway-plugin/protocol.py index 2e79c9b..b9507c9 100644 --- a/gateway-plugin/protocol.py +++ b/gateway-plugin/protocol.py @@ -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 diff --git a/gateway-plugin/ws_server.py b/gateway-plugin/ws_server.py index 4e5347a..826d90a 100644 --- a/gateway-plugin/ws_server.py +++ b/gateway-plugin/ws_server.py @@ -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: