feat(app): unread message indicator (channel view, header, hamburger)
CI / Gateway plugin tests (push) Successful in 6m18s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m57s

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.
This commit is contained in:
ARIA committed 2026-08-23 01:34:42 +02:00
1 parent 9c50f2dbc1
commit 6458c3183c
6 files changed
+280 -8

No files matched your search

@@ -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)
}
}