From 6458c3183c6bc49b7e28a02bb40f9ab454550189 Mon Sep 17 00:00:00 2001 From: ARIA Date: Sun, 23 Aug 2026 01:34:42 +0200 Subject: [PATCH] feat(app): unread message indicator (channel view, header, hamburger) Tracks per-lane unread counts for finalized assistant messages. A message counts as unread unless the user is actively reading that lane (current lane, app focused, newest content at the bottom of the viewport). - ChatStore: ephemeral per-lane unread map (markUnread/markLaneRead/unreadFor). - IrisController: 'being read' decision on incoming messages; clears the current lane when the app returns to the foreground at the bottom. - Bridges: push foreground changes to the controller. - ChatScreen: per-channel badges in the drawer/rail, an 'N new' pill in the header (tap glides to the latest message), and a red dot on the hamburger (single-pane/mobile only) when another channel has unread. Closes #3. --- .../kotlin/iris/platform/AppBridge.kt | 11 +- .../commonMain/kotlin/iris/data/ChatStore.kt | 26 +++++ .../kotlin/iris/state/IrisController.kt | 61 ++++++++++- .../kotlin/iris/ui/screens/ChatScreen.kt | 103 +++++++++++++++++- .../kotlin/iris/data/ChatStoreUnreadTest.kt | 70 ++++++++++++ .../kotlin/iris/platform/DesktopBridge.kt | 17 ++- 6 files changed, 280 insertions(+), 8 deletions(-) create mode 100644 app/shared/src/commonTest/kotlin/iris/data/ChatStoreUnreadTest.kt diff --git a/app/shared/src/androidMain/kotlin/iris/platform/AppBridge.kt b/app/shared/src/androidMain/kotlin/iris/platform/AppBridge.kt index 18976e3..57724a5 100644 --- a/app/shared/src/androidMain/kotlin/iris/platform/AppBridge.kt +++ b/app/shared/src/androidMain/kotlin/iris/platform/AppBridge.kt @@ -14,7 +14,14 @@ object AppBridge { @Volatile var controller: IrisController? = null - /** True while the launcher activity is resumed (set by MainActivity). */ + /** True while the launcher activity is resumed (set by MainActivity). + * A change is forwarded to the controller (M8: unread clear on focus). */ @Volatile var foreground: Boolean = false -} \ No newline at end of file + set(value) { + if (field != value) { + field = value + controller?.setForeground(value) + } + } +} diff --git a/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt b/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt index de11efb..9a43d71 100644 --- a/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt +++ b/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt @@ -149,6 +149,13 @@ class ChatStore { private val _todos = MutableStateFlow>>(emptyMap()) val todos: StateFlow>> = _todos.asStateFlow() + /** Unread message count per lane (M8: unread indicator). Ephemeral + * (in-memory): a process death resets it, and the `sync` delta re-counts + * genuinely new messages on reconnect. A lane absent from the map has + * no unread messages. */ + private val _unread = MutableStateFlow>(emptyMap()) + val unread: StateFlow> = _unread.asStateFlow() + private var localSeq = 0 /** When false, `message.start`/`message.update` frames are ignored and each @@ -694,6 +701,24 @@ class ChatStore { if (changed) _lanes.value = map } + /** M8: a new message arrived in [lane] that the user hasn't seen — + * increment its unread count. */ + fun markUnread(lane: String) { + val map = _unread.value.toMutableMap() + map[lane] = (map[lane] ?: 0) + 1 + _unread.value = map + } + + /** M8: the user is now viewing [lane]'s newest content — clear its unread + * count. Idempotent (a lane with no unread is a no-op). */ + fun markLaneRead(lane: String) { + val map = _unread.value.toMutableMap() + if (map.remove(lane) != null) _unread.value = map + } + + /** M8: unread count for a single lane (0 when none). */ + fun unreadFor(lane: String): Int = _unread.value[lane] ?: 0 + /** M5: mark the user message [messageId] as read (read.receipt). */ fun markRead(messageId: String) { val map = _lanes.value.toMutableMap() @@ -904,5 +929,6 @@ class ChatStore { fun clear() { _lanes.value = emptyMap() + _unread.value = emptyMap() } } diff --git a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt index 69543f1..e01944f 100644 --- a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt +++ b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt @@ -152,6 +152,42 @@ class IrisController( private val _gatewayStatus = MutableStateFlow(null) val gatewayStatus: StateFlow = _gatewayStatus.asStateFlow() + // ── M8: unread indicator ────────────────────────────────────────────── + + /** True while the current lane's newest content sits at the bottom of the + * viewport (set by the UI from its scroll state). Used to decide whether + * an incoming message in the current lane is "being read" (not unread). + * @Volatile: written on the UI thread, read on the frame-handler + * coroutine (Dispatchers.Default). */ + @Volatile + var currentLaneAtBottom: Boolean = true + + /** App foreground state (M8: clear the current lane's unread when the app + * is focused and the user is at the bottom). Mirrors the platform bridge + * ([isAppForeground]); the bridges push changes via [setForeground]. */ + private val _foreground = MutableStateFlow(isAppForeground()) + val foreground: StateFlow = _foreground.asStateFlow() + + /** Bridge entry point: the platform shell reports a focus change. */ + fun setForeground(fg: Boolean) { + _foreground.value = fg + } + + /** M8: a finalized assistant message arrived in [lane]. Count it as unread + * unless the user is actively reading that lane right now (it is the + * current lane, the app is focused, and the newest content is at the + * bottom of the viewport). */ + private fun noteIncomingAssistantMessage(lane: String) { + val beingRead = lane == chat.currentLane.value && isAppForeground() && currentLaneAtBottom + if (!beingRead) chat.markUnread(lane) + } + + /** M8: the user is now viewing the current lane's newest content — clear + * its unread count. */ + fun markCurrentLaneRead() { + chat.markLaneRead(chat.currentLane.value) + } + // Latches the restart pair: set when the "restarting" notice is posted // (on the gateway's status{restarting} frame), consumed by the matching // "online" notice on the next reconnect. A plain network drop never sets @@ -465,6 +501,15 @@ class IrisController( scope.launch { chat.currentLane.debounce(CACHE_SAVE_DEBOUNCE_MS).collect { chatDb.metaPut(META_LAST_LANE, it) } } + // M8: when the app returns to the foreground and the user is at the + // bottom of the current lane, its newest content is on screen — clear + // any unread that accumulated while backgrounded. (The UI separately + // clears on scroll-to-bottom while already focused.) + scope.launch { + foreground.collect { fg -> + if (fg && currentLaneAtBottom) markCurrentLaneRead() + } + } scope.launch { client.events.collect { frame -> try { @@ -511,6 +556,12 @@ class IrisController( when (frame.type) { TYPE_MESSAGE_STOP -> { frame.payloadAs()?.let { + // M8: a finalized streaming reply is new + // content — count it as unread unless the + // user is reading this lane right now. + frame.chatId?.let { cid -> + noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId)) + } if (!isPushedReplay(frame)) { notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText) } @@ -519,8 +570,14 @@ class IrisController( TYPE_MESSAGE -> { frame.payloadAs()?.let { - if (it.role == ROLE_ASSISTANT && !isPushedReplay(frame)) { - notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text) + if (it.role == ROLE_ASSISTANT) { + // M8: a finalized (non-streaming) reply. + frame.chatId?.let { cid -> + noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId)) + } + if (!isPushedReplay(frame)) { + notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text) + } } } } 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 44034b6..0eff7ba 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -177,6 +177,22 @@ fun ChatScreen(controller: IrisController) { val currentChannel = channels.firstOrNull { it.chatId == currentChatId } val threads = channels.filter { it.kind == "thread" && it.parentChatId == currentChatId } + // M8: unread counts. Per-lane from the store; per-channel aggregated + // (flat lane + all threads) for the drawer/rail badges. + val unreadMap by controller.chat.unread.collectAsState() + val channelUnread: Map = + remember(unreadMap, channels) { + val threadsByParent = channels.filter { it.kind == "thread" }.groupBy { it.parentChatId } + channels.filter { it.kind != "thread" }.associate { ch -> + val flat = unreadMap[ch.chatId] ?: 0 + val threadTotal = + (threadsByParent[ch.chatId].orEmpty()).sumOf { t -> + unreadMap[controller.chat.laneKey(ch.chatId, t.chatId)] ?: 0 + } + ch.chatId to (flat + threadTotal) + } + } + val listState = rememberLazyListState() // "At the bottom" when the list can't scroll further forward, or the last // row's bottom edge is within a small tolerance of the viewport bottom @@ -203,6 +219,16 @@ fun ChatScreen(controller: IrisController) { }.collect { isAtBottom = it } } + // M8: publish the scroll position to the controller (it drives the + // "being read" decision for incoming messages) and clear the current + // lane's unread once its newest content is at the bottom of the viewport + // (the "bottom anchor in viewport" condition). Re-fires on lane change so + // opening a lane marks it read (the view lands on the latest message). + LaunchedEffect(isAtBottom, currentLane) { + controller.currentLaneAtBottom = isAtBottom + if (isAtBottom) controller.markCurrentLaneRead() + } + // Scroll so the newest row's BOTTOM edge is visible: first bring the last // row into view (top-aligned), then, if it is taller than the viewport, // align its bottom with the viewport bottom. A plain scrollToItem(last) @@ -500,8 +526,28 @@ fun ChatScreen(controller: IrisController) { verticalAlignment = Alignment.CenterVertically, ) { if (showHamburger) { - HeaderIconButton(onClick = onOpenDrawer) { - Text("☰", fontSize = 18.sp) + // M8: red dot when another channel has unread messages. In + // single-pane (mobile) the channel rail isn't visible, so + // the drawer's per-channel badges aren't enough — this + // flags "something happened elsewhere." Desktop always + // shows the rail (showHamburger = false), so no dot there. + val otherChannelsUnread = + channelUnread.entries.sumOf { (chId, c) -> if (chId != currentChatId) c else 0 } + Box { + HeaderIconButton(onClick = onOpenDrawer) { + Text("☰", fontSize = 18.sp) + } + if (otherChannelsUnread > 0) { + Box( + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(top = 5.dp, end = 5.dp) + .size(9.dp) + .clip(CircleShape) + .background(IrisColors.statusRed), + ) + } } } val headerClipboard = LocalClipboardManager.current @@ -509,6 +555,28 @@ fun ChatScreen(controller: IrisController) { headerClipboard.setText(AnnotatedString(id)) toastMessage = "Copied $id" } + // M8: unread indicator for the current lane. Appears when new + // messages arrived while the user was scrolled up (or in + // another lane); a tap glides back to the latest message. + val currentLaneUnread = unreadMap[currentLane] ?: 0 + if (currentLaneUnread > 0) { + Spacer(modifier = Modifier.width(6.dp)) + Box( + modifier = + Modifier + .clip(RoundedCornerShape(10.dp)) + .background(IrisColors.primary) + .clickable { drawerScope.launch { scrollToBottom(animate = true) } } + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Text( + "$currentLaneUnread new", + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } Spacer(modifier = Modifier.width(6.dp)) StatusBubble(state) { toastMessage = statusToastText(state) } Spacer(modifier = Modifier.width(4.dp)) @@ -1029,6 +1097,7 @@ fun ChatScreen(controller: IrisController) { ChannelRail( channels = nonThreadChannels, currentChatId = currentChatId, + channelUnread = channelUnread, selectedIndex = railIndex, onSelectedIndex = { railIndex = it }, focusRequester = railFocusRequester, @@ -1065,6 +1134,7 @@ fun ChatScreen(controller: IrisController) { ChannelDrawer( channels = nonThreadChannels, currentChatId = currentChatId, + channelUnread = channelUnread, onOpen = { ch -> controller.openChannel(ch.chatId) drawerScope.launch { drawerState.close() } @@ -1531,6 +1601,31 @@ private fun ChannelBadges(ch: ChannelInfo) { } } +/** + * M8: unread-count badge for a channel row (drawer / rail). Hidden when the + * channel has no unread messages. Caps at "99+" so a long backlog doesn't + * blow out the row. + */ +@Composable +private fun UnreadBadge(count: Int) { + if (count <= 0) return + Box( + modifier = + Modifier + .padding(start = 6.dp) + .clip(RoundedCornerShape(8.dp)) + .background(IrisColors.primary) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + if (count > 99) "99+" else count.toString(), + color = Color.White, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} + /** * Long-press / right-click context menu for a channel row: rename, favorite * (state-aware), icon (state-aware), automation (state-aware), delete. The @@ -1686,6 +1781,7 @@ private fun ChannelIconDialog( private fun ChannelDrawer( channels: List, currentChatId: String, + channelUnread: Map, onOpen: (ChannelInfo) -> Unit, onNewChannel: () -> Unit, onRename: (ChannelInfo) -> Unit, @@ -1729,6 +1825,7 @@ private fun ChannelDrawer( modifier = Modifier.weight(1f), ) ChannelBadges(ch) + UnreadBadge(channelUnread[ch.chatId] ?: 0) } ChannelContextMenu( expanded = menuOpen, @@ -1763,6 +1860,7 @@ private fun ChannelDrawer( private fun ChannelRail( channels: List, currentChatId: String, + channelUnread: Map, selectedIndex: Int, onSelectedIndex: (Int) -> Unit, focusRequester: FocusRequester, @@ -1858,6 +1956,7 @@ private fun ChannelRail( modifier = Modifier.weight(1f), ) ChannelBadges(ch) + UnreadBadge(channelUnread[ch.chatId] ?: 0) } ChannelContextMenu( expanded = menuOpen, diff --git a/app/shared/src/commonTest/kotlin/iris/data/ChatStoreUnreadTest.kt b/app/shared/src/commonTest/kotlin/iris/data/ChatStoreUnreadTest.kt new file mode 100644 index 0000000..0298482 --- /dev/null +++ b/app/shared/src/commonTest/kotlin/iris/data/ChatStoreUnreadTest.kt @@ -0,0 +1,70 @@ +package iris.data + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * M8: per-lane unread tracking (the data behind the channel-view / header + * unread indicator). The increment/clear *policy* (which incoming message is + * "being read") lives in IrisController; here we cover the store's count + * semantics that the UI and controller build on. + */ +class ChatStoreUnreadTest { + @Test + fun markUnreadIncrementsPerLane() { + val store = ChatStore() + assertEquals(0, store.unreadFor("default")) + store.markUnread("default") + store.markUnread("default") + store.markUnread("chan_7") + assertEquals(2, store.unreadFor("default")) + assertEquals(1, store.unreadFor("chan_7")) + // A lane that never had a message stays at 0. + assertEquals(0, store.unreadFor("chan_9")) + } + + @Test + fun markLaneReadClearsOnlyThatLane() { + val store = ChatStore() + store.markUnread("default") + store.markUnread("chan_7") + store.markLaneRead("default") + assertEquals(0, store.unreadFor("default")) + // Other lanes are untouched. + assertEquals(1, store.unreadFor("chan_7")) + } + + @Test + fun markLaneReadIsIdempotent() { + val store = ChatStore() + store.markLaneRead("default") // no unread -> no-op + assertEquals(0, store.unreadFor("default")) + store.markUnread("default") + store.markLaneRead("default") + store.markLaneRead("default") // clearing twice is safe + assertEquals(0, store.unreadFor("default")) + } + + @Test + fun unreadMapReflectsCounts() { + val store = ChatStore() + assertNull(store.unread.value["default"]) + store.markUnread("default") + assertEquals(1, store.unread.value["default"]) + store.markLaneRead("default") + // A cleared lane is removed from the map (absent == 0 unread). + assertNull(store.unread.value["default"]) + } + + @Test + fun clearWipesUnread() { + val store = ChatStore() + store.markUnread("default") + store.markUnread("chan_7") + store.clear() + assertEquals(0, store.unreadFor("default")) + assertEquals(0, store.unreadFor("chan_7")) + assertEquals(emptyMap(), store.unread.value) + } +} diff --git a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopBridge.kt b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopBridge.kt index a420b83..21eadd7 100644 --- a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopBridge.kt +++ b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopBridge.kt @@ -12,8 +12,16 @@ typealias NotificationListener = (chatId: String?, title: String, body: String, threadId: String?) -> Unit object DesktopBridge { + /** True while the window has focus (set by the shell). A change is + * forwarded to the controller (M8: unread clear on focus). */ @Volatile var foreground: Boolean = true + set(value) { + if (field != value) { + field = value + controller?.setForeground(value) + } + } @Volatile var controller: IrisController? = null @@ -24,7 +32,12 @@ object DesktopBridge { notificationListeners.add(listener) } - fun notifyListeners(chatId: String?, title: String, body: String, threadId: String?) { + fun notifyListeners( + chatId: String?, + title: String, + body: String, + threadId: String?, + ) { notificationListeners.toList().forEach { it(chatId, title, body, threadId) } } -} \ No newline at end of file +}