eleven / ocho Public

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>
Paul Campbell b19f3c6ce526bade759fb0e21d2e22216ec3e045 parent b4c7f95
6 files changed, +62 −10
  • changelog/2026-07-02-own-messages-read.md +1 −0
  • ios/LChat/Models.swift +3 −1
  • ios/LChat/ThreadStore.swift +10 −1
  • test/ui.test.mjs +17 −0
  • web/static/chat.js +19 −8
  • web/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