Own messages never mark a chat unread (cross-device) (#67)
The sender's user id now rides inside the encrypted payload (uid) — the server never sees it, but your other devices decrypt it and recognise the message as yours. A chat is unread only when its latest message is from someone else, so replying from one device clears the dot on the rest. web: shared ownMessage helper (ui.js, unit-tested) drives both the bubble mine/theirs rendering and the live unread dot; legacy payloads without a uid fall back to the screen-name comparison the renderer always used. Edits keep the uid since they re-encrypt the original payload. ios: same rule in ThreadStore — the sidebar pip skips threads whose last message is ours.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+62
−10
changelog/2026-07-02-own-messages-read.md+1 −0ios/LChat/Models.swift+3 −1ios/LChat/ThreadStore.swift+10 −1test/ui.test.mjs+17 −0web/static/chat.js+19 −8web/static/ui.js+12 −0
diff --git a/changelog/2026-07-02-own-messages-read.md b/changelog/2026-07-02-own-messages-read.md| new file mode 100644 |
| index 0000000..93a7155 |
| --- /dev/null |
| +++ b/changelog/2026-07-02-own-messages-read.md |
| @@ -0,0 +1 @@ |
| +Chats no longer light up as unread for messages you sent yourself — reply from your phone and the chat is already marked read on your laptop. |
diff --git a/ios/LChat/Models.swift b/ios/LChat/Models.swift| index df12498..72512b3 100644 |
| --- a/ios/LChat/Models.swift |
| +++ b/ios/LChat/Models.swift |
| @@ -62,8 +62,10 @@ struct ServerMessage: Codable, Identifiable { |
| // ---- decrypted payloads (native decrypts only previews + group names) ---- |
| // The decrypted message body — same shape chat.js builds before encrypt(). Native |
| -// only needs `name`/`text`/`attachments` for the sidebar preview line. |
| +// needs `name`/`text`/`attachments` for the sidebar preview line, and `uid` (the |
| +// sender's user id, carried inside the ciphertext) to spot our own messages. |
| struct MessagePayload: Codable { |
| + var uid: String? |
| var name: String? |
| var text: String? |
| var attachments: [Attachment]? |
diff --git a/ios/LChat/ThreadStore.swift b/ios/LChat/ThreadStore.swift| index c41d3ae..5447f21 100644 |
| --- a/ios/LChat/ThreadStore.swift |
| +++ b/ios/LChat/ThreadStore.swift |
| @@ -63,9 +63,18 @@ final class ThreadStore: ObservableObject { |
| else { continue } |
| let key = try? GroupKey(rawKey: raw) |
| var preview = "" |
| + var lastMine = false |
| if let lb = t.last_blob, !lb.isEmpty, let key, |
| let p = try? key.decryptJSON(MessagePayload.self, blob: lb) { |
| preview = previewText(kind: t.kind, payload: p) |
| + // A chat whose latest message is ours isn't unread — sending from |
| + // any device means we've read it. Old payloads have no uid; fall |
| + // back to the screen name (mirrors ownMessage in web/static/ui.js). |
| + if let uid = p.uid, !myUserId.isEmpty { |
| + lastMine = uid == myUserId |
| + } else if let n = p.name, !n.isEmpty { |
| + lastMine = n == rosterLabels[myUserId] |
| + } |
| } |
| let lastAt = t.last_at ?? 0 |
| // Park the key + decrypted title for the notification extension: a |
| @@ -79,7 +88,7 @@ final class ThreadStore: ObservableObject { |
| title: threadTitle(t, key: key), |
| preview: preview, |
| lastAt: lastAt, |
| - unread: lastAt > ReadState.lastSeen(instance: instance.id, thread: t.id), |
| + unread: !lastMine && lastAt > ReadState.lastSeen(instance: instance.id, thread: t.id), |
| pinned: t.pinned ?? false, |
| members: t.members ?? [], |
| threadKeyB64: raw.base64urlEncodedString() |
diff --git a/test/ui.test.mjs b/test/ui.test.mjs| index 4cb9637..adf8711 100644 |
| --- a/test/ui.test.mjs |
| +++ b/test/ui.test.mjs |
| @@ -11,6 +11,7 @@ import { |
| colorScheme, |
| composerToken, |
| pinnedFirst, |
| + ownMessage, |
| DEFAULT_THEME, |
| DEFAULT_APPEARANCE, |
| } from "../web/static/ui.js"; |
| @@ -146,5 +147,21 @@ await test("pinnedFirst: no pins leaves the list unchanged", () => { |
| assert.deepEqual(pinnedFirst(list, () => false), list); |
| }); |
| +await test("ownMessage: matches by uid when both sides have one", () => { |
| + assert.equal(ownMessage({ uid: "u1", name: "Paul" }, "u1", "Paul"), true); |
| + assert.equal(ownMessage({ uid: "u2", name: "Paul" }, "u1", "Paul"), false); // same name, other user |
| +}); |
| + |
| +await test("ownMessage: legacy payloads (no uid) fall back to the screen name", () => { |
| + assert.equal(ownMessage({ name: "Paul" }, "u1", "Paul"), true); |
| + assert.equal(ownMessage({ name: "Maud" }, "u1", "Paul"), false); |
| +}); |
| + |
| +await test("ownMessage: never matches on empty identity", () => { |
| + assert.equal(ownMessage(null, "u1", "Paul"), false); |
| + assert.equal(ownMessage({ name: "" }, "u1", ""), false); |
| + assert.equal(ownMessage({}, "u1", "Paul"), false); |
| +}); |
| + |
| console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| process.exit(failures === 0 ? 0 : 1); |
diff --git a/web/static/chat.js b/web/static/chat.js| index a79289b..8254510 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -29,6 +29,7 @@ import { |
| colorScheme, |
| composerToken, |
| pinnedFirst, |
| + ownMessage, |
| } from "/static/ui.js"; |
| import { richTokens, blockTokens, firstLink, typingSummary, replySnippet, formatBytes, autoGroupTitle, emojiOnlyCount, youtubeId } from "/static/format.js"; |
| import { emojify, emojiMatches } from "/static/emoji.js"; |
| @@ -754,7 +755,7 @@ function previewText(t, data) { |
| function maybeNativeNotify(tid, data, preview) { |
| const bridge = window.webkit?.messageHandlers?.lchatNotify; |
| if (!bridge) return; |
| - if (data.name === session.name) return; |
| + if (ownMessage(data, myUserId, session.name)) return; |
| if (tid === activeId && document.hasFocus()) return; |
| try { |
| bridge.postMessage({ title: threadName[tid] || "New message", body: preview, thread: tid }); |
| @@ -1536,7 +1537,7 @@ async function appendMessage(threadId, m, scroll = true, container = null, cache |
| if (!cache && data.name && typingUntil.delete(data.name)) renderTyping(); |
| const feed = document.getElementById("feed"); |
| const target = container || feed; // where the node lands; feed for live appends |
| - const mine = data.name === session.name; |
| + const mine = ownMessage(data, myUserId, session.name); |
| const t = threads.find((x) => x.id === threadId); |
| const wrap = document.createElement("div"); |
| wrap.className = "msg " + (mine ? "me" : "them"); |
| @@ -2380,7 +2381,10 @@ function sendMessage(raw) { |
| // is kept; the user sends once every chip resolves. |
| if (pending.some((p) => p.loading)) return false; |
| if (!text && !pending.length) return true; |
| + // The sender's user id rides inside the ciphertext (never as server metadata): |
| + // it's what lets your other devices recognise the message as yours. |
| const payload = { name: session.name, text, ts: Date.now() }; |
| + if (myUserId) payload.uid = myUserId; |
| if (pending.length) { |
| // The name/type/size ride inside the encrypted payload; strip local-only fields. |
| payload.attachments = pending.map((a) => { |
| @@ -2963,22 +2967,29 @@ function connectWS() { |
| } |
| // New activity reopens a closed chat (the server clears its hidden flag too). |
| hidden[tid] = false; |
| + // Decrypt once up front: the payload feeds the list preview, and the sender |
| + // identity inside it decides the unread dot — your own message (sent from |
| + // another device or tab) must not light the chat up, and replying elsewhere |
| + // means you've read it, so it clears an existing dot too. |
| + let payload = null; |
| + try { |
| + payload = await decrypt(threadKeys[tid], msg.blob); |
| + } catch {} |
| if (tid === activeId) { |
| await appendMessage(tid, msg, true); |
| } else { |
| - unread[tid] = true; |
| + unread[tid] = !ownMessage(payload, myUserId, session.name); |
| bumpThread(tid); |
| } |
| // Refresh the chat-list preview + last-activity time for this thread. |
| const t = threads.find((x) => x.id === tid); |
| if (t) { |
| t.last_at = msg.created_at || Math.floor(Date.now() / 1000); |
| - try { |
| - const nd = await decrypt(threadKeys[tid], msg.blob); |
| - const preview = previewText(t, nd); |
| + if (payload) { |
| + const preview = previewText(t, payload); |
| threadPreview[tid] = preview; |
| - maybeNativeNotify(tid, nd, preview); |
| - } catch {} |
| + maybeNativeNotify(tid, payload, preview); |
| + } |
| } |
| renderThreadList(); |
| // If the just-joined invitee's message cleared the pending state, drop the |
diff --git a/web/static/ui.js b/web/static/ui.js| index e690581..74dec3c 100644 |
| --- a/web/static/ui.js |
| +++ b/web/static/ui.js |
| @@ -63,6 +63,18 @@ export function composerToken(text, caret) { |
| return { kind: "mention", query, start: i - 1 }; |
| } |
| +// ---- message attribution -------------------------------------------------- |
| +// Whether a decrypted message payload was sent by this user (any of their |
| +// devices). Newer payloads carry the sender's user id (`uid` — inside the |
| +// ciphertext, so the server never sees it); older ones only the screen name, |
| +// so fall back to comparing that (same rule the bubble renderer has always |
| +// used for mine-vs-theirs). |
| +export function ownMessage(payload, myUserId, myName) { |
| + if (!payload) return false; |
| + if (payload.uid && myUserId) return payload.uid === myUserId; |
| + return !!myName && payload.name === myName; |
| +} |
| + |
| // ---- chat list ordering -------------------------------------------------- |
| // Stable pinned-first partition of the thread list: pinned chats float to the |
| // top keeping their relative (recency) order, everything else follows in its |