Native unread sync: iOS + Mac adopt the read watermark (#426)
* server: per-user read watermark — read_marks table, POST /api/threads/{id}/read, per-user WS fan-out
Forward-only last_read_at per (user, thread), returned in /api/threads;
advancing it broadcasts a "read" frame to that user's own connections only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: unread dots ride the synced read watermark
Seed dots from last_read_at on load (so they survive reloads), advance the
mark on open / live reading / refocus, and clear dots on the "read" frame
from the user's other devices.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* web: seeding a dot never clears one a silent app event follows
Recomputing purely from the last message let a silent event (todo.done)
clear a dot an earlier notifying event had lit; clear only when the
watermark covers last_at or the last message is our own.
* native: unread dots ride the synced read watermark (iOS + Mac)
refresh() folds the server's per-user last_read_at forward into the local
ReadState watermark; markRead POSTs the mark (deduped against the last known
server value); the Mac's live sidebar clears dots in place on "read" frames.
Mark-as-unread becomes a device-local flag beside the watermark, since the
forward-only server fold would immediately erase a wound-back timestamp.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+128
−7
ios/LChatCore/Sources/LChatCore/ChatSocket.swift+1 −0ios/LChatCore/Sources/LChatCore/LChatAPI.swift+7 −0ios/LChatCore/Sources/LChatCore/Models.swift+1 −0ios/LChatCore/Sources/LChatCore/ThreadStore.swift+74 −7ios/LChatCore/Tests/LChatCoreTests/ReadSyncTests.swift+39 −0ios/LChatMac/NativeChatView.swift+6 −0
diff --git a/ios/LChatCore/Sources/LChatCore/ChatSocket.swift b/ios/LChatCore/Sources/LChatCore/ChatSocket.swift| index 95fe6a7..8726425 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ChatSocket.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ChatSocket.swift |
| @@ -19,6 +19,7 @@ public final class ChatSocket: NSObject, URLSessionWebSocketDelegate { |
| public let ephemeral: Bool? // relay-only traffic (presence) — never stored |
| public let message_id: Int64? // "reaction" frames |
| public let reactions: [Reaction]? // full per-emoji tally after a (un)react |
| + public let last_read_at: Int64? // "read" frames: our watermark, moved on another device |
| } |
| /// Delivered on the main thread. |
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift| index f6f9c9c..6d58f36 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift |
| @@ -128,6 +128,13 @@ public struct LChatAPI { |
| await postJSON("/api/threads/\(thread)/hidden", ["token": token, "hidden": hidden]) |
| } |
| + /// Advance this user's read watermark for a thread — per-user, synced across |
| + /// their devices ("read here, read everywhere"). Forward-only server-side, so |
| + /// a stale write from a lagging device never regresses it. |
| + public func setRead(thread: String, lastReadAt: Int64) async { |
| + await postJSON("/api/threads/\(thread)/read", ["token": token, "last_read_at": lastReadAt]) |
| + } |
| + |
| /// The OpenGraph card for a posted URL — the ONE deliberate E2E exception |
| /// (unfurl.go): the server fetches the page to build the card, so it sees |
| /// the URL (never message text). Returns nil when the fetch failed or the |
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift| index 22c30d8..212a03d 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Models.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Models.swift |
| @@ -65,6 +65,7 @@ public struct ServerThread: Codable { |
| public var avatar: String? |
| public var pinned: Bool? |
| public var hidden: Bool? // this user "closed" the chat (per-user, server-synced) |
| + public var last_read_at: Int64? // this user's read watermark (per-user, server-synced) |
| } |
| public struct RosterEntry: Codable, Equatable { |
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift| index 0ae2c88..39cb350 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| @@ -151,6 +151,15 @@ public final class ThreadStore: ObservableObject { |
| preview = blurb |
| } |
| let lastAt = t.last_at ?? 0 |
| + // Fold the server's read watermark (advanced by ANY of this user's |
| + // devices) into the local one — reading a chat on the phone clears |
| + // the dot here too. Forward-only, like the server: a stale local |
| + // value advances, a newer one (this device read something the |
| + // server hasn't confirmed yet) stands. |
| + serverReadMarks[t.id] = max(serverReadMarks[t.id] ?? 0, t.last_read_at ?? 0) |
| + if let sm = t.last_read_at, sm > ReadState.lastSeen(instance: instance.id, thread: t.id) { |
| + ReadState.setLastSeen(instance: instance.id, thread: t.id, at: sm) |
| + } |
| // Park the key + decrypted title for the notification extension: a |
| // push's ciphertext is decrypted on-device against this store. |
| SharedThreadKeys.save(threadID: t.id, entry: .init( |
| @@ -162,7 +171,9 @@ public final class ThreadStore: ObservableObject { |
| title: threadTitle(t, key: key), |
| preview: preview, |
| lastAt: lastAt, |
| - unread: !lastMine && lastAt > ReadState.lastSeen(instance: instance.id, thread: t.id), |
| + unread: Self.isUnread(lastAt: lastAt, lastMine: lastMine, |
| + seen: ReadState.lastSeen(instance: instance.id, thread: t.id), |
| + manualUnreadAt: ReadState.manualUnreadAt(instance: instance.id, thread: t.id)), |
| pinned: t.pinned ?? false, |
| hidden: t.hidden ?? false, |
| members: t.members ?? [], |
| @@ -266,22 +277,57 @@ public final class ThreadStore: ObservableObject { |
| /// has no per-thread notification-centre entries yet. |
| public static var onMarkRead: ((String) -> Void)? |
| - /// Clear a thread's unread state once it's opened. |
| + // The server's last-known read watermark per thread, so markRead only POSTs |
| + // when it actually advances the synced mark (opening an already-read chat, |
| + // or a refresh-tail re-mark, stays a local no-op). |
| + private var serverReadMarks: [String: Int64] = [:] |
| + |
| + // The one unread rule, shared by refresh() and applyReadFrame(): someone |
| + // else's message newer than the watermark — or an explicit device-local |
| + // mark-as-unread of exactly the current newest message (a newer arrival or |
| + // the watermark passing it retires the manual flag naturally). |
| + nonisolated static func isUnread(lastAt: Int64, lastMine: Bool, seen: Int64, manualUnreadAt: Int64) -> Bool { |
| + !lastMine && (lastAt > seen || manualUnreadAt == lastAt) |
| + } |
| + |
| + /// Clear a thread's unread state once it's opened — locally, and via the |
| + /// server to the user's other devices ("read here, read everywhere"). |
| public func markRead(_ thread: ChatThread) { |
| + ReadState.setManualUnread(instance: instance.id, thread: thread.id, at: 0) |
| ReadState.setLastSeen(instance: instance.id, thread: thread.id, at: thread.lastAt) |
| if let i = threads.firstIndex(where: { $0.id == thread.id }) { threads[i].unread = false } |
| Self.onMarkRead?(thread.id) |
| + if thread.lastAt > (serverReadMarks[thread.id] ?? 0) { |
| + serverReadMarks[thread.id] = thread.lastAt |
| + let (tid, at) = (thread.id, thread.lastAt) |
| + Task { await api?.setRead(thread: tid, lastReadAt: at) } |
| + } |
| } |
| - /// markRead's toggle partner: wind lastSeen back to just before the newest |
| - /// message so the thread reads as unread again. (If the newest message is |
| - /// your own, a refresh recomputes it read — "unread" means someone else's |
| - /// message awaits.) |
| + /// markRead's toggle partner: flag the newest message as deliberately |
| + /// unread again. Device-local by design — the server mark is forward-only |
| + /// (and the web has no mark-unread), so the flag lives beside the watermark |
| + /// rather than in it and survives the server fold on refresh. |
| public func markUnread(_ thread: ChatThread) { |
| - ReadState.setLastSeen(instance: instance.id, thread: thread.id, at: max(0, thread.lastAt - 1)) |
| + ReadState.setManualUnread(instance: instance.id, thread: thread.id, at: thread.lastAt) |
| if let i = threads.firstIndex(where: { $0.id == thread.id }) { threads[i].unread = true } |
| } |
| + /// A "read" frame — this user's watermark advanced on another of their |
| + /// devices (the server routes these to the owner's connections only). Fold |
| + /// it in and clear the row's dot if the mark covers the newest message. |
| + public func applyReadFrame(threadID: String, lastReadAt: Int64) { |
| + serverReadMarks[threadID] = max(serverReadMarks[threadID] ?? 0, lastReadAt) |
| + if lastReadAt > ReadState.lastSeen(instance: instance.id, thread: threadID) { |
| + ReadState.setLastSeen(instance: instance.id, thread: threadID, at: lastReadAt) |
| + } |
| + guard let i = threads.firstIndex(where: { $0.id == threadID }), |
| + threads[i].unread, threads[i].lastAt <= lastReadAt else { return } |
| + // Reading it anywhere trumps a manual unread left on this device. |
| + ReadState.setManualUnread(instance: instance.id, thread: threadID, at: 0) |
| + threads[i].unread = false |
| + } |
| + |
| // Built-in mini-app display titles, for app-threads created before the title was |
| // stored in name_blob (newer ones carry their own). Keep in sync with each app's |
| // `title` in web/static/apps/*.js. |
| @@ -480,6 +526,8 @@ public final class ThreadStore: ObservableObject { |
| // Per-thread read state (last-seen message time). Just timestamps — no secrets — |
| // so UserDefaults is fine (the keys + private material stay in the Keychain). |
| // Public: the iOS screen reads the pre-tap watermark to anchor unreads. |
| +// The local value is a floor: refresh() folds the server's synced watermark |
| +// forward into it, so reads on the user's other devices land here too. |
| public enum ReadState { |
| private static func key(_ instance: String, _ thread: String) -> String { |
| "lchat.seen|\(instance)|\(thread)" |
| @@ -490,4 +538,23 @@ public enum ReadState { |
| public static func setLastSeen(instance: String, thread: String, at: Int64) { |
| UserDefaults.standard.set(Int(at), forKey: key(instance, thread)) |
| } |
| + |
| + // A deliberate device-local "mark as unread": the lastAt of the message it |
| + // was applied to (0 = none). Kept beside the watermark, not inside it — the |
| + // server fold is forward-only and would immediately erase a wound-back |
| + // timestamp. Retires itself once anything newer arrives (the stored value |
| + // stops matching the thread's lastAt). |
| + private static func unreadKey(_ instance: String, _ thread: String) -> String { |
| + "lchat.unread|\(instance)|\(thread)" |
| + } |
| + public static func manualUnreadAt(instance: String, thread: String) -> Int64 { |
| + Int64(UserDefaults.standard.integer(forKey: unreadKey(instance, thread))) |
| + } |
| + public static func setManualUnread(instance: String, thread: String, at: Int64) { |
| + if at <= 0 { |
| + UserDefaults.standard.removeObject(forKey: unreadKey(instance, thread)) |
| + } else { |
| + UserDefaults.standard.set(Int(at), forKey: unreadKey(instance, thread)) |
| + } |
| + } |
| } |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ReadSyncTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ReadSyncTests.swift| new file mode 100644 |
| index 0000000..84573de |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/ReadSyncTests.swift |
| @@ -0,0 +1,39 @@ |
| +import XCTest |
| +@testable import LChatCore |
| + |
| +// The unread rule shared by refresh() and applyReadFrame(), and the ReadState |
| +// storage behind it. The watermark half must match the web's seeding in |
| +// shell.js loadThreads (same server field, same comparison); the manual-unread |
| +// flag is native-only, deliberately device-local (the server mark is |
| +// forward-only, so "unread again" can't ride the watermark itself). |
| +final class ReadSyncTests: XCTestCase { |
| + func testIsUnread() { |
| + // Someone else's message past the watermark lights the dot… |
| + XCTAssertTrue(ThreadStore.isUnread(lastAt: 100, lastMine: false, seen: 50, manualUnreadAt: 0)) |
| + // …a watermark covering it (read on ANY device) clears it… |
| + XCTAssertFalse(ThreadStore.isUnread(lastAt: 100, lastMine: false, seen: 100, manualUnreadAt: 0)) |
| + XCTAssertFalse(ThreadStore.isUnread(lastAt: 100, lastMine: false, seen: 150, manualUnreadAt: 0)) |
| + // …and your own last message never counts as unread, watermark or not. |
| + XCTAssertFalse(ThreadStore.isUnread(lastAt: 100, lastMine: true, seen: 0, manualUnreadAt: 0)) |
| + |
| + // Mark-as-unread: the flag on exactly the newest message wins over a |
| + // covering watermark… |
| + XCTAssertTrue(ThreadStore.isUnread(lastAt: 100, lastMine: false, seen: 100, manualUnreadAt: 100)) |
| + // …retires itself once a newer message arrives (the watermark rule |
| + // takes over)… |
| + XCTAssertTrue(ThreadStore.isUnread(lastAt: 120, lastMine: false, seen: 100, manualUnreadAt: 100)) |
| + XCTAssertFalse(ThreadStore.isUnread(lastAt: 120, lastMine: false, seen: 120, manualUnreadAt: 100)) |
| + // …and never overrides the own-message rule. |
| + XCTAssertFalse(ThreadStore.isUnread(lastAt: 100, lastMine: true, seen: 100, manualUnreadAt: 100)) |
| + } |
| + |
| + func testManualUnreadStorage() { |
| + let inst = "test-\(UUID().uuidString)" |
| + XCTAssertEqual(ReadState.manualUnreadAt(instance: inst, thread: "t"), 0, "unset reads as 0") |
| + ReadState.setManualUnread(instance: inst, thread: "t", at: 42) |
| + XCTAssertEqual(ReadState.manualUnreadAt(instance: inst, thread: "t"), 42) |
| + ReadState.setManualUnread(instance: inst, thread: "t", at: 0) |
| + XCTAssertEqual(ReadState.manualUnreadAt(instance: inst, thread: "t"), 0, "0 clears the flag") |
| + UserDefaults.standard.removeObject(forKey: "lchat.unread|\(inst)|t") |
| + } |
| +} |
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift| index ac3e27c..74afece 100644 |
| --- a/ios/LChatMac/NativeChatView.swift |
| +++ b/ios/LChatMac/NativeChatView.swift |
| @@ -883,6 +883,12 @@ struct NativeChatView: View { |
| guard liveSocket == nil, let base = instance.baseURL else { return } |
| let socket = ChatSocket(baseURL: base, token: instance.deviceToken) |
| socket?.onFrame = { frame in |
| + // Read on another of this user's devices: clear the dot in place — |
| + // no fetch needed, the frame carries the whole change. |
| + if frame.type == "read", let tid = frame.thread_id, let at = frame.last_read_at { |
| + store.applyReadFrame(threadID: tid, lastReadAt: at) |
| + return |
| + } |
| // Any stored-message mutation anywhere in the instance refreshes |
| // the sidebar (previews, ordering, unread) — debounced so a burst |
| // is one refresh. |