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.
This commit is contained in:
1 parent
9c50f2dbc1
commit
6458c3183c
6 files changed
+275
-3
No files matched your search
@@ -14,7 +14,14 @@ object AppBridge {
|
|||||||
@Volatile
|
@Volatile
|
||||||
var controller: IrisController? = null
|
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
|
@Volatile
|
||||||
var foreground: Boolean = false
|
var foreground: Boolean = false
|
||||||
|
set(value) {
|
||||||
|
if (field != value) {
|
||||||
|
field = value
|
||||||
|
controller?.setForeground(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -149,6 +149,13 @@ class ChatStore {
|
|||||||
private val _todos = MutableStateFlow<Map<String, List<TodoItem>>>(emptyMap())
|
private val _todos = MutableStateFlow<Map<String, List<TodoItem>>>(emptyMap())
|
||||||
val todos: StateFlow<Map<String, List<TodoItem>>> = _todos.asStateFlow()
|
val todos: StateFlow<Map<String, List<TodoItem>>> = _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<Map<String, Int>>(emptyMap())
|
||||||
|
val unread: StateFlow<Map<String, Int>> = _unread.asStateFlow()
|
||||||
|
|
||||||
private var localSeq = 0
|
private var localSeq = 0
|
||||||
|
|
||||||
/** When false, `message.start`/`message.update` frames are ignored and each
|
/** When false, `message.start`/`message.update` frames are ignored and each
|
||||||
@@ -694,6 +701,24 @@ class ChatStore {
|
|||||||
if (changed) _lanes.value = map
|
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). */
|
/** M5: mark the user message [messageId] as read (read.receipt). */
|
||||||
fun markRead(messageId: String) {
|
fun markRead(messageId: String) {
|
||||||
val map = _lanes.value.toMutableMap()
|
val map = _lanes.value.toMutableMap()
|
||||||
@@ -904,5 +929,6 @@ class ChatStore {
|
|||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
_lanes.value = emptyMap()
|
_lanes.value = emptyMap()
|
||||||
|
_unread.value = emptyMap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,6 +152,42 @@ class IrisController(
|
|||||||
private val _gatewayStatus = MutableStateFlow<String?>(null)
|
private val _gatewayStatus = MutableStateFlow<String?>(null)
|
||||||
val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow()
|
val gatewayStatus: StateFlow<String?> = _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<Boolean> = _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
|
// Latches the restart pair: set when the "restarting" notice is posted
|
||||||
// (on the gateway's status{restarting} frame), consumed by the matching
|
// (on the gateway's status{restarting} frame), consumed by the matching
|
||||||
// "online" notice on the next reconnect. A plain network drop never sets
|
// "online" notice on the next reconnect. A plain network drop never sets
|
||||||
@@ -465,6 +501,15 @@ class IrisController(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
chat.currentLane.debounce(CACHE_SAVE_DEBOUNCE_MS).collect { chatDb.metaPut(META_LAST_LANE, it) }
|
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 {
|
scope.launch {
|
||||||
client.events.collect { frame ->
|
client.events.collect { frame ->
|
||||||
try {
|
try {
|
||||||
@@ -511,6 +556,12 @@ class IrisController(
|
|||||||
when (frame.type) {
|
when (frame.type) {
|
||||||
TYPE_MESSAGE_STOP -> {
|
TYPE_MESSAGE_STOP -> {
|
||||||
frame.payloadAs<MessageStopPayload>()?.let {
|
frame.payloadAs<MessageStopPayload>()?.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)) {
|
if (!isPushedReplay(frame)) {
|
||||||
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
|
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
|
||||||
}
|
}
|
||||||
@@ -519,11 +570,17 @@ class IrisController(
|
|||||||
|
|
||||||
TYPE_MESSAGE -> {
|
TYPE_MESSAGE -> {
|
||||||
frame.payloadAs<MessagePayload>()?.let {
|
frame.payloadAs<MessagePayload>()?.let {
|
||||||
if (it.role == ROLE_ASSISTANT && !isPushedReplay(frame)) {
|
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)
|
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Unit
|
Unit
|
||||||
|
|||||||
@@ -177,6 +177,22 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
val currentChannel = channels.firstOrNull { it.chatId == currentChatId }
|
val currentChannel = channels.firstOrNull { it.chatId == currentChatId }
|
||||||
val threads = channels.filter { it.kind == "thread" && it.parentChatId == 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<String, Int> =
|
||||||
|
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()
|
val listState = rememberLazyListState()
|
||||||
// "At the bottom" when the list can't scroll further forward, or the last
|
// "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
|
// row's bottom edge is within a small tolerance of the viewport bottom
|
||||||
@@ -203,6 +219,16 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
}.collect { isAtBottom = it }
|
}.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
|
// 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,
|
// row into view (top-aligned), then, if it is taller than the viewport,
|
||||||
// align its bottom with the viewport bottom. A plain scrollToItem(last)
|
// align its bottom with the viewport bottom. A plain scrollToItem(last)
|
||||||
@@ -500,15 +526,57 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
if (showHamburger) {
|
if (showHamburger) {
|
||||||
|
// 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) {
|
HeaderIconButton(onClick = onOpenDrawer) {
|
||||||
Text("☰", fontSize = 18.sp)
|
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
|
val headerClipboard = LocalClipboardManager.current
|
||||||
TitlePill(currentChannel, Modifier.weight(1f)) { id ->
|
TitlePill(currentChannel, Modifier.weight(1f)) { id ->
|
||||||
headerClipboard.setText(AnnotatedString(id))
|
headerClipboard.setText(AnnotatedString(id))
|
||||||
toastMessage = "Copied $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))
|
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))
|
||||||
@@ -1029,6 +1097,7 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
ChannelRail(
|
ChannelRail(
|
||||||
channels = nonThreadChannels,
|
channels = nonThreadChannels,
|
||||||
currentChatId = currentChatId,
|
currentChatId = currentChatId,
|
||||||
|
channelUnread = channelUnread,
|
||||||
selectedIndex = railIndex,
|
selectedIndex = railIndex,
|
||||||
onSelectedIndex = { railIndex = it },
|
onSelectedIndex = { railIndex = it },
|
||||||
focusRequester = railFocusRequester,
|
focusRequester = railFocusRequester,
|
||||||
@@ -1065,6 +1134,7 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
ChannelDrawer(
|
ChannelDrawer(
|
||||||
channels = nonThreadChannels,
|
channels = nonThreadChannels,
|
||||||
currentChatId = currentChatId,
|
currentChatId = currentChatId,
|
||||||
|
channelUnread = channelUnread,
|
||||||
onOpen = { ch ->
|
onOpen = { ch ->
|
||||||
controller.openChannel(ch.chatId)
|
controller.openChannel(ch.chatId)
|
||||||
drawerScope.launch { drawerState.close() }
|
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
|
* Long-press / right-click context menu for a channel row: rename, favorite
|
||||||
* (state-aware), icon (state-aware), automation (state-aware), delete. The
|
* (state-aware), icon (state-aware), automation (state-aware), delete. The
|
||||||
@@ -1686,6 +1781,7 @@ private fun ChannelIconDialog(
|
|||||||
private fun ChannelDrawer(
|
private fun ChannelDrawer(
|
||||||
channels: List<ChannelInfo>,
|
channels: List<ChannelInfo>,
|
||||||
currentChatId: String,
|
currentChatId: String,
|
||||||
|
channelUnread: Map<String, Int>,
|
||||||
onOpen: (ChannelInfo) -> Unit,
|
onOpen: (ChannelInfo) -> Unit,
|
||||||
onNewChannel: () -> Unit,
|
onNewChannel: () -> Unit,
|
||||||
onRename: (ChannelInfo) -> Unit,
|
onRename: (ChannelInfo) -> Unit,
|
||||||
@@ -1729,6 +1825,7 @@ private fun ChannelDrawer(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
ChannelBadges(ch)
|
ChannelBadges(ch)
|
||||||
|
UnreadBadge(channelUnread[ch.chatId] ?: 0)
|
||||||
}
|
}
|
||||||
ChannelContextMenu(
|
ChannelContextMenu(
|
||||||
expanded = menuOpen,
|
expanded = menuOpen,
|
||||||
@@ -1763,6 +1860,7 @@ private fun ChannelDrawer(
|
|||||||
private fun ChannelRail(
|
private fun ChannelRail(
|
||||||
channels: List<ChannelInfo>,
|
channels: List<ChannelInfo>,
|
||||||
currentChatId: String,
|
currentChatId: String,
|
||||||
|
channelUnread: Map<String, Int>,
|
||||||
selectedIndex: Int,
|
selectedIndex: Int,
|
||||||
onSelectedIndex: (Int) -> Unit,
|
onSelectedIndex: (Int) -> Unit,
|
||||||
focusRequester: FocusRequester,
|
focusRequester: FocusRequester,
|
||||||
@@ -1858,6 +1956,7 @@ private fun ChannelRail(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
ChannelBadges(ch)
|
ChannelBadges(ch)
|
||||||
|
UnreadBadge(channelUnread[ch.chatId] ?: 0)
|
||||||
}
|
}
|
||||||
ChannelContextMenu(
|
ChannelContextMenu(
|
||||||
expanded = menuOpen,
|
expanded = menuOpen,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,8 +12,16 @@ typealias NotificationListener =
|
|||||||
(chatId: String?, title: String, body: String, threadId: String?) -> Unit
|
(chatId: String?, title: String, body: String, threadId: String?) -> Unit
|
||||||
|
|
||||||
object DesktopBridge {
|
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
|
@Volatile
|
||||||
var foreground: Boolean = true
|
var foreground: Boolean = true
|
||||||
|
set(value) {
|
||||||
|
if (field != value) {
|
||||||
|
field = value
|
||||||
|
controller?.setForeground(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
var controller: IrisController? = null
|
var controller: IrisController? = null
|
||||||
@@ -24,7 +32,12 @@ object DesktopBridge {
|
|||||||
notificationListeners.add(listener)
|
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) }
|
notificationListeners.toList().forEach { it(chatId, title, body, threadId) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user