Channel context menu: long-press/right-click a row → rename, favorite/unfavorite, add/change/remove icon (color or image), delete; removed star/delete row buttons; favorite + icon/color synced across devices
This commit is contained in:
1 parent
079ef664a2
commit
49b7a3f577
12 files changed
+574
-68
No files matched your search
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user