eleven / messenger Public

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>
Paul Campbell 20201cefed65a1c2ae9a77013750c17578dda006 parent 7e38b12
6 files changed, +128 −7
  • ios/LChatCore/Sources/LChatCore/ChatSocket.swift +1 −0
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +7 −0
  • ios/LChatCore/Sources/LChatCore/Models.swift +1 −0
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +74 −7
  • ios/LChatCore/Tests/LChatCoreTests/ReadSyncTests.swift +39 −0
  • ios/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.