fix(app): don't re-show notification banners on app reopen
The persisted sync cursor only advanced on sync.done, so on every restart the event stream re-delivered all frames consumed since the last sync.done - and replayed notification frames re-showed their banner (e.g. 'thread deleted' again after close/reopen). Track the consumed outbox high-water cursor in the controller, persist it (debounced + on backgrounding), and skip notification frames whose cursor is at/below the mark (also covers the duplicate delivery of SSE catch-up + explicit sync replay on reconnect).
This commit is contained in:
1 parent
6458c3183c
commit
abbed438ec
1 file changed
+58
-4
@@ -82,6 +82,7 @@ import iris.ui.theme.UserTheme
|
|||||||
import iris.util.IrisLog
|
import iris.util.IrisLog
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -209,6 +210,37 @@ class IrisController(
|
|||||||
* via push (live frames carry no cursor and are never suppressed). */
|
* via push (live frames carry no cursor and are never suppressed). */
|
||||||
private fun isPushedReplay(frame: iris.protocol.Frame): Boolean = frame.cursor?.let { it <= lastPushedCursor } ?: false
|
private fun isPushedReplay(frame: iris.protocol.Frame): Boolean = frame.cursor?.let { it <= lastPushedCursor } ?: false
|
||||||
|
|
||||||
|
/** M5: highest outbox cursor this device has CONSUMED (applied from the
|
||||||
|
* event stream). The stream resumes from [store.syncCursor] on every
|
||||||
|
* (re)connect, so this high-water mark is persisted there (debounced +
|
||||||
|
* on backgrounding): without it, a restart re-delivers every frame
|
||||||
|
* consumed since the last `sync.done` — and replayed `notification`
|
||||||
|
* frames re-showed their banner on every app reopen (docs/08 §8.4).
|
||||||
|
* @Volatile: written on the frame-handler coroutine, read on the
|
||||||
|
* foreground/state collectors. */
|
||||||
|
@Volatile
|
||||||
|
private var consumedCursor: Long = store.syncCursor
|
||||||
|
|
||||||
|
/** Debounced persist of [consumedCursor] (one store write per burst,
|
||||||
|
* not per frame). */
|
||||||
|
private var cursorSaveJob: Job? = null
|
||||||
|
|
||||||
|
private fun persistCursorDebounced() {
|
||||||
|
cursorSaveJob?.cancel()
|
||||||
|
cursorSaveJob =
|
||||||
|
scope.launch {
|
||||||
|
delay(CACHE_SAVE_DEBOUNCE_MS)
|
||||||
|
// maxOf: sync.done may have advanced the store past us.
|
||||||
|
store.syncCursor = maxOf(store.syncCursor, consumedCursor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flush [consumedCursor] to the store now (app backgrounding / exit). */
|
||||||
|
private fun persistCursorNow() {
|
||||||
|
cursorSaveJob?.cancel()
|
||||||
|
store.syncCursor = maxOf(store.syncCursor, consumedCursor)
|
||||||
|
}
|
||||||
|
|
||||||
// ── M3: threads toggle (per-app for now; per-channel lands later) ─────
|
// ── M3: threads toggle (per-app for now; per-channel lands later) ─────
|
||||||
// Persisted (Settings → "Threads").
|
// Persisted (Settings → "Threads").
|
||||||
private val _threadsEnabled = MutableStateFlow(store.threadsEnabled)
|
private val _threadsEnabled = MutableStateFlow(store.threadsEnabled)
|
||||||
@@ -507,12 +539,23 @@ class IrisController(
|
|||||||
// clears on scroll-to-bottom while already focused.)
|
// clears on scroll-to-bottom while already focused.)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
foreground.collect { fg ->
|
foreground.collect { fg ->
|
||||||
|
if (!fg) persistCursorNow()
|
||||||
if (fg && currentLaneAtBottom) markCurrentLaneRead()
|
if (fg && currentLaneAtBottom) markCurrentLaneRead()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
client.events.collect { frame ->
|
client.events.collect { frame ->
|
||||||
try {
|
try {
|
||||||
|
// Consume the frame's outbox cursor BEFORE dispatching:
|
||||||
|
// a frame that arrives a second time (SSE catch-up and
|
||||||
|
// the explicit sync replay both carry the cursor) must
|
||||||
|
// not re-notify — only its first consumption may.
|
||||||
|
val cursor = frame.cursor
|
||||||
|
val alreadyConsumed = cursor != null && cursor <= consumedCursor
|
||||||
|
if (cursor != null && cursor > consumedCursor) {
|
||||||
|
consumedCursor = cursor
|
||||||
|
persistCursorDebounced()
|
||||||
|
}
|
||||||
when (frame.type) {
|
when (frame.type) {
|
||||||
TYPE_TOOL_START,
|
TYPE_TOOL_START,
|
||||||
TYPE_TOOL_PROGRESS,
|
TYPE_TOOL_PROGRESS,
|
||||||
@@ -562,7 +605,7 @@ class IrisController(
|
|||||||
frame.chatId?.let { cid ->
|
frame.chatId?.let { cid ->
|
||||||
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId))
|
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId))
|
||||||
}
|
}
|
||||||
if (!isPushedReplay(frame)) {
|
if (!alreadyConsumed && !isPushedReplay(frame)) {
|
||||||
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
|
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -575,7 +618,7 @@ class IrisController(
|
|||||||
frame.chatId?.let { cid ->
|
frame.chatId?.let { cid ->
|
||||||
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId))
|
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId))
|
||||||
}
|
}
|
||||||
if (!isPushedReplay(frame)) {
|
if (!alreadyConsumed && !isPushedReplay(frame)) {
|
||||||
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
|
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -649,8 +692,13 @@ class IrisController(
|
|||||||
|
|
||||||
TYPE_SYNC_DONE -> {
|
TYPE_SYNC_DONE -> {
|
||||||
// Replayed frames already flowed through [events]; the
|
// Replayed frames already flowed through [events]; the
|
||||||
// cursor is authoritative server-side (outbox).
|
// cursor is authoritative server-side (outbox). Keep the
|
||||||
frame.payloadAs<SyncDonePayload>()?.let { store.syncCursor = it.cursor }
|
// consumed high-water mark in step (it may be ahead of
|
||||||
|
// us — live frames consumed after the replay started).
|
||||||
|
frame.payloadAs<SyncDonePayload>()?.let {
|
||||||
|
consumedCursor = maxOf(consumedCursor, it.cursor)
|
||||||
|
store.syncCursor = maxOf(store.syncCursor, it.cursor)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TYPE_HISTORY -> {
|
TYPE_HISTORY -> {
|
||||||
@@ -689,6 +737,11 @@ class IrisController(
|
|||||||
|
|
||||||
TYPE_NOTIFICATION -> {
|
TYPE_NOTIFICATION -> {
|
||||||
frame.payloadAs<NotificationPayload>()?.let { p ->
|
frame.payloadAs<NotificationPayload>()?.let { p ->
|
||||||
|
// alreadyConsumed: this frame was applied earlier
|
||||||
|
// (its cursor is at/below the high-water mark) —
|
||||||
|
// a duplicate delivery (restart catch-up or the
|
||||||
|
// sync replay) must not re-show the banner.
|
||||||
|
if (!alreadyConsumed) {
|
||||||
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
|
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
|
||||||
// M5: WS is live but the app is backgrounded — the
|
// M5: WS is live but the app is backgrounded — the
|
||||||
// in-app banner is invisible, so mirror to a system
|
// in-app banner is invisible, so mirror to a system
|
||||||
@@ -701,6 +754,7 @@ class IrisController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TYPE_TYPING -> {
|
TYPE_TYPING -> {
|
||||||
frame.payloadAs<TypingPayload>()?.let { _typing.value = it.on }
|
frame.payloadAs<TypingPayload>()?.let { _typing.value = it.on }
|
||||||
|
|||||||
Reference in new issue
Block a user