eleven / ocho Public

web+native: reply threads group on the root message β€” replying to a reply continues the thread (#430)

Replies now form iMessage-style threads keyed on the TOP message. Replying
to any message in a thread stages the root's {id,name,snippet} (web
replyTarget in chat.js; native ChatFeedModel.replyTarget, used by both
platforms' startReply), and the "N Replies" tally groups on the resolved
root β€” legacy chains (a reply quoting another reply) resolve transitively
through the loaded window on both sides.

Contract pinned by two new vectors in test/event-vectors/chat.json
(reply_in_thread_targets_root, reply_thread_grouping), tested against the
real implementations on both sides (event-vectors.test.mjs now imports
chat.js's viewItems/replyTarget; ReplyThreadVectorTests.swift drives the
now-static ChatFeedModel.refreshReplyCounts). Also wires
event-vectors.test.mjs into npm test β€” it had never been added to the
script despite CLAUDE.md listing it.

No server changes: reply metadata stays inside the encrypted payload.
Groundwork for the thread view (PR 2: web overlay; PR 3: iOS/Mac).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell bcdd505bff74ad23cd58d0b36aee6abd989a9a88 parent 6e86d2e
9 files changed, +326 −21
  • changelog/2026-07-08-reply-threads.md +4 −0
  • ios/LChat/Views/NativeChatScreen.swift +2 −1
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +65 −14
  • ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift +101 −0
  • ios/LChatMac/NativeFeedView.swift +2 −1
  • package.json +1 −1
  • test/event-vectors.test.mjs +35 −0
  • test/event-vectors/chat.json +67 −0
  • web/static/apps/chat.js +49 −4
diff --git a/changelog/2026-07-08-reply-threads.md b/changelog/2026-07-08-reply-threads.md
new file mode 100644
index 0000000..29908b4
--- /dev/null
+++ b/changelog/2026-07-08-reply-threads.md
@@ -0,0 +1,4 @@
+πŸ€– Replies now stick to the conversation they started. Replying to a reply
+continues that thread β€” your message quotes the original message at the top of
+the thread, not the reply you tapped β€” and the "N Replies" count under the
+original message now includes the whole back-and-forth.
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index ea6cc21..ff820af 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -860,7 +860,8 @@ struct NativeChatScreen: View {
private func startReply(_ m: ChatFeedModel.Message) {
editingID = nil
- replyTo = ReplyRef(id: m.id, name: m.name, snippet: replySnippet(m.raw))
+ // Replying to a reply continues its thread: target the TOP message.
+ replyTo = model.replyTarget(for: m)
composerFocused = true
}
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index 5c986f5..7142f5f 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -23,10 +23,14 @@ public final class ChatFeedModel: ObservableObject {
public var attachments: [Attachment] = []
public var reply: ReplyRef? // set when this message quotes another
public var reactions: [Reaction] = []
- // Folded from the other direction: how many messages quote THIS one,
- // and where the thread of replies starts (the web's "N Replies" tag).
+ // Folded from the other direction: how many messages are in the reply
+ // thread THIS message tops, and where that thread starts (the web's
+ // "N Replies" tag). Tallies group on the resolved ROOT message.
public var replyCount: Int = 0
public var firstReplyID: Int64?
+ // For a reply: the resolved sid of its thread's TOP message (the web's
+ // `replyRoot`) β€” one hop for new replies, chased for legacy chains.
+ public var threadRootID: Int64?
// The decrypted payload as sent β€” an edit re-encrypts {…raw, text}, so
// attachments/reply metadata survive the rewrite (chat.js does the same).
public var raw = MessagePayload()
@@ -672,7 +676,7 @@ public final class ChatFeedModel: ObservableObject {
// Map a stored (already-decrypted) page into view messages and publish.
private func publishStored(_ stored: [MessageStore.StoredMessage]) {
var list: [Message] = stored.compactMap(viewMessage)
- refreshReplyCounts(&list)
+ Self.refreshReplyCounts(&list)
messages = list
}
@@ -708,7 +712,7 @@ public final class ChatFeedModel: ObservableObject {
}
guard changed else { return }
list.sort { $0.id < $1.id }
- refreshReplyCounts(&list)
+ Self.refreshReplyCounts(&list)
messages = list
}
@@ -755,7 +759,7 @@ public final class ChatFeedModel: ObservableObject {
}
}
list.sort { $0.id < $1.id }
- refreshReplyCounts(&list)
+ Self.refreshReplyCounts(&list)
messages = list
}
@@ -816,7 +820,7 @@ public final class ChatFeedModel: ObservableObject {
guard let id = frame.id else { return }
var list = messages
list.removeAll { $0.id == id }
- refreshReplyCounts(&list)
+ Self.refreshReplyCounts(&list)
// The implosion: rows carry a scale+fade removal transition, and
// deletes are the one mutation that publishes inside an animation
// (arrivals/prepends stay unanimated by design).
@@ -944,7 +948,7 @@ public final class ChatFeedModel: ObservableObject {
var list = messages
fold(id: id, at: at, payload: payload,
edited: edited, reactions: reactions, into: &list)
- refreshReplyCounts(&list)
+ Self.refreshReplyCounts(&list)
messages = list
if let payload {
storeMirror(.init(id: id, at: at, edited: edited,
@@ -1010,20 +1014,67 @@ public final class ChatFeedModel: ObservableObject {
}
}
- // Re-derive each message's reply tally (chat.js's replyCounts fold): a
- // quote names its parent, so count inbound quotes per id. Runs once per
- // batch, before the list publishes.
- private func refreshReplyCounts(_ list: inout [Message]) {
+ // Resolve every reply's thread ROOT (message id β†’ root id). New replies
+ // point straight at the top message, so this is one hop; legacy chains (a
+ // reply quoting another reply) are chased through the loaded window,
+ // stopping at the deepest pointer we can see. Hop-capped so a hostile
+ // payload can't cycle forever. Mirrors chat.js's rootRef β€” pinned by the
+ // reply_thread_grouping vector (test/event-vectors/chat.json).
+ nonisolated static func threadRoots(_ list: [Message]) -> [Int64: Int64] {
+ var byID: [Int64: Message] = [:]
+ for m in list { byID[m.id] = m }
+ var roots: [Int64: Int64] = [:]
+ for m in list {
+ guard var ref = m.reply, let refID = ref.id, refID > 0 else { continue }
+ var hops = 0
+ while hops < 100, let id = ref.id,
+ let parent = byID[id], let up = parent.reply, (up.id ?? 0) > 0 {
+ ref = up
+ hops += 1
+ }
+ if let rootID = ref.id { roots[m.id] = rootID }
+ }
+ return roots
+ }
+
+ /// The ReplyRef a reply to `m` should carry: replying to any message in a
+ /// thread continues that thread, so the ref always names the thread's TOP
+ /// message β€” never the tapped reply. When the root is loaded its
+ /// name/snippet are snapshotted fresh; an unloaded root falls back to the
+ /// deepest quoted ref we can see. Mirrors chat.js's replyTarget.
+ nonisolated static func replyTarget(in list: [Message], for m: Message) -> ReplyRef {
+ guard let reply = m.reply, (reply.id ?? 0) > 0 else {
+ return ReplyRef(id: m.id, name: m.name, snippet: replySnippet(m.raw))
+ }
+ let rootID = threadRoots(list)[m.id] ?? reply.id
+ if let id = rootID, let root = list.first(where: { $0.id == id }) {
+ return ReplyRef(id: root.id, name: root.name, snippet: replySnippet(root.raw))
+ }
+ return ReplyRef(id: rootID, name: reply.name, snippet: reply.snippet)
+ }
+
+ /// Composer entry point for both platforms' startReply.
+ public func replyTarget(for m: Message) -> ReplyRef {
+ Self.replyTarget(in: messages, for: m)
+ }
+
+ // Re-derive each message's reply-thread tally (chat.js's replyCounts fold):
+ // every reply counts toward its resolved ROOT, so a legacy chain tallies on
+ // the thread's top message. Runs once per batch, before the list publishes.
+ // Static so the reply_thread_grouping vector test can drive it directly.
+ nonisolated static func refreshReplyCounts(_ list: inout [Message]) {
+ let roots = Self.threadRoots(list)
var counts: [Int64: Int] = [:]
var first: [Int64: Int64] = [:]
for m in list {
- guard let parent = m.reply?.id, parent > 0 else { continue }
- counts[parent, default: 0] += 1
- if first[parent] == nil { first[parent] = m.id }
+ guard let root = roots[m.id], root > 0 else { continue }
+ counts[root, default: 0] += 1
+ if first[root] == nil { first[root] = m.id }
}
for i in list.indices {
list[i].replyCount = counts[list[i].id] ?? 0
list[i].firstReplyID = first[list[i].id]
+ list[i].threadRootID = roots[list[i].id]
}
}
}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift
new file mode 100644
index 0000000..42cebc6
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift
@@ -0,0 +1,101 @@
+import XCTest
+@testable import LChatCore
+
+// πŸ€– Cross-platform contract tests for reply-thread grouping β€” the native side
+// of test/event-vectors/chat.json's `reply_thread_grouping` +
+// `reply_in_thread_targets_root` vectors (web twin: test/event-vectors.test.mjs
+// against chat.js's viewItems/replyTarget).
+//
+// The contract: reply threads group on the resolved ROOT message. New replies
+// point straight at the root; a legacy chain (a reply quoting another reply)
+// resolves transitively through loaded messages. Replying to ANY message in a
+// thread stages the root β€” never the tapped reply.
+final class ReplyThreadVectorTests: XCTestCase {
+ typealias Message = ChatFeedModel.Message
+
+ // The reply_thread_grouping vector's message_sequence, folded.
+ private func vectorMessages() -> [Message] {
+ let alice = MessagePayload(type: nil, uid: "user-alice-1", name: "Alice",
+ text: "Hello everyone", ts: 1_700_000_000_000,
+ action: nil, attachments: nil, replyTo: nil)
+ return [
+ Message(id: 100, name: "Alice", text: "Hello everyone", mine: false,
+ at: 1_700_000_000, edited: false, raw: alice),
+ Message(id: 101, name: "Bob", text: "Hi Alice", mine: false,
+ at: 1_700_000_001, edited: false,
+ reply: ReplyRef(id: 100, name: "Alice", snippet: "Hello everyone")),
+ // Legacy chain: quotes reply 101, not the root β€” must still resolve to 100.
+ Message(id: 102, name: "Charlie", text: "Welcome!", mine: false,
+ at: 1_700_000_002, edited: false,
+ reply: ReplyRef(id: 101, name: "Bob", snippet: "Hi Alice")),
+ Message(id: 103, name: "Dana", text: "Good to be here", mine: false,
+ at: 1_700_000_003, edited: false,
+ reply: ReplyRef(id: 100, name: "Alice", snippet: "Hello everyone")),
+ ]
+ }
+
+ func testThreadGrouping() {
+ var list = vectorMessages()
+ ChatFeedModel.refreshReplyCounts(&list)
+
+ // expected_threads.roots: every reply resolves to root 100.
+ XCTAssertEqual(list.first { $0.id == 101 }?.threadRootID, 100)
+ XCTAssertEqual(list.first { $0.id == 102 }?.threadRootID, 100, "legacy chain resolves transitively")
+ XCTAssertEqual(list.first { $0.id == 103 }?.threadRootID, 100)
+ XCTAssertNil(list.first { $0.id == 100 }?.threadRootID, "the root is not itself a reply")
+
+ // expected_threads.reply_counts + first_reply.
+ XCTAssertEqual(list.first { $0.id == 100 }?.replyCount, 3, "the root tallies its whole thread")
+ XCTAssertEqual(list.first { $0.id == 100 }?.firstReplyID, 101)
+ XCTAssertEqual(list.first { $0.id == 101 }?.replyCount, 0, "a mid-thread reply never tallies")
+ }
+
+ func testReplyTargetStagesRoot() {
+ let list = vectorMessages()
+
+ // expected_reply_target: replying to the legacy chain reply 102 stages the
+ // root's id with a fresh snapshot of the root's name/text.
+ let target = ChatFeedModel.replyTarget(in: list, for: list.first { $0.id == 102 }!)
+ XCTAssertEqual(target.id, 100)
+ XCTAssertEqual(target.name, "Alice")
+ XCTAssertEqual(target.snippet, "Hello everyone")
+
+ // Replying to a direct reply also stages the root.
+ let fromDirect = ChatFeedModel.replyTarget(in: list, for: list.first { $0.id == 103 }!)
+ XCTAssertEqual(fromDirect.id, 100)
+
+ // Replying to a non-reply targets that message itself (it becomes the root).
+ let fresh = ChatFeedModel.replyTarget(in: list, for: list.first { $0.id == 100 }!)
+ XCTAssertEqual(fresh.id, 100)
+ XCTAssertEqual(fresh.name, "Alice")
+ XCTAssertEqual(fresh.snippet, "Hello everyone")
+ }
+
+ func testUnloadedRootFallsBackToQuotedRef() {
+ // The parent scrolled out of the loaded window: keep the deepest pointer we
+ // can see (the quoted ref's frozen snapshot) rather than inventing one.
+ let orphan = Message(id: 200, name: "Eve", text: "Late reply", mine: false,
+ at: 1_700_000_010, edited: false,
+ reply: ReplyRef(id: 42, name: "Old Root", snippet: "long ago"))
+ var list = [orphan]
+ ChatFeedModel.refreshReplyCounts(&list)
+ XCTAssertEqual(list[0].threadRootID, 42, "an unloaded parent is still the best-known root")
+
+ let target = ChatFeedModel.replyTarget(in: list, for: list[0])
+ XCTAssertEqual(target.id, 42)
+ XCTAssertEqual(target.name, "Old Root")
+ XCTAssertEqual(target.snippet, "long ago")
+ }
+
+ func testReplyCycleIsHopCapped() {
+ // A hostile payload could quote in a cycle β€” the walk must terminate.
+ var list = [
+ Message(id: 1, name: "A", text: "a", mine: false, at: 1, edited: false,
+ reply: ReplyRef(id: 2, name: "B", snippet: "b")),
+ Message(id: 2, name: "B", text: "b", mine: false, at: 2, edited: false,
+ reply: ReplyRef(id: 1, name: "A", snippet: "a")),
+ ]
+ ChatFeedModel.refreshReplyCounts(&list) // must not hang
+ XCTAssertNotNil(list[0].threadRootID)
+ }
+}
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 0b214cf..3d43cc3 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -1016,7 +1016,8 @@ struct NativeFeedView: View {
private func startReply(_ m: ChatFeedModel.Message) {
editingID = nil
- replyTo = ReplyRef(id: m.id, name: m.name, snippet: replySnippet(m.raw))
+ // Replying to a reply continues its thread: target the TOP message.
+ replyTo = model.replyTarget(for: m)
composerFocused = true
}
diff --git a/package.json b/package.json
index be238b0..6fe6a5f 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node home/test/crypto.test.mjs && node home/test/serverlist.test.mjs && node home/test/fader.test.mjs"
+ "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/event-vectors.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node home/test/crypto.test.mjs && node home/test/serverlist.test.mjs && node home/test/fader.test.mjs"
}
}
diff --git a/test/event-vectors.test.mjs b/test/event-vectors.test.mjs
index aa55267..83c5944 100644
--- a/test/event-vectors.test.mjs
+++ b/test/event-vectors.test.mjs
@@ -1,5 +1,9 @@
import assert from "assert";
import { allVectors } from "./event-vectors/index.js";
+// The reply-thread rollup is tested against the REAL implementation (chat.js
+// exports both as pure functions), not a reconstruction β€” grouping rules are
+// too easy to fork silently.
+import { viewItems, replyTarget } from "../web/static/apps/chat.js";
// Import chat.js fold handlers β€” the ones we're testing.
// The app.hostEvents from web/static/apps/chat.js.
@@ -180,6 +184,37 @@ allVectors.chat.forEach((vector) => {
});
}
+ // Reply-thread grouping vectors: fold the whole sequence, then assert the
+ // thread rollup (every reply resolves to its ROOT β€” legacy chains included)
+ // and the compose-side contract (replying anywhere in a thread stages the root).
+ if (vector.expected_threads) {
+ test(`chat threads: ${vector.name} β€” replies group on the resolved root`, () => {
+ let state = { msgs: [] };
+ for (const step of vector.message_sequence) {
+ const event = { type: "chat.msg", ...step.decrypted_payload };
+ const meta = { id: step.server_message.id, ts: step.server_message.created_at * 1000 };
+ state = testChatMsgFold(state, event, meta);
+ }
+ const items = viewItems(state.msgs);
+ const bySid = new Map(items.map((i) => [i.sid, i]));
+ for (const [sid, root] of Object.entries(vector.expected_threads.roots)) {
+ assert.strictEqual(bySid.get(Number(sid))?.replyRoot, root, `message ${sid} should resolve to root ${root}`);
+ }
+ for (const [root, count] of Object.entries(vector.expected_threads.reply_counts)) {
+ assert.strictEqual(bySid.get(Number(root))?.replyCount, count, `root ${root} should tally ${count} thread messages`);
+ }
+ for (const [root, first] of Object.entries(vector.expected_threads.first_reply)) {
+ const firstReply = items.find((i) => i.replyRoot === Number(root));
+ assert.strictEqual(firstReply && firstReply.sid, first, `first reply in root ${root}'s thread should be ${first}`);
+ }
+ const rt = vector.expected_reply_target;
+ if (rt) {
+ const m = state.msgs.find((x) => x.sid === rt.for_message);
+ assert.deepStrictEqual(replyTarget(state.msgs, m), rt.replyTo, "replyTarget should stage the thread's top message");
+ }
+ });
+ }
+
if (vector.expected_fold_event?.type === "chat.react") {
test(`chat.react: ${vector.name} β€” folds reactions correctly and produces expected event shape`, () => {
const state = { msgs: [{ sid: vector.server_reaction_frame.message_id, reactions: [] }] };
diff --git a/test/event-vectors/chat.json b/test/event-vectors/chat.json
index 61beb78..ff670b9 100644
--- a/test/event-vectors/chat.json
+++ b/test/event-vectors/chat.json
@@ -119,6 +119,73 @@
"attachments": []
}
},
+ {
+ "name": "reply_in_thread_targets_root",
+ "description": "Replying to a message that is itself a reply continues that thread: the composer stages the thread's TOP message, so the payload's replyTo carries the ROOT's id/name/snippet β€” never the tapped reply's.",
+ "note": "Compose-side contract (web replyTarget in chat.js; native ChatFeedModel.replyTarget). Here Dana replied to message 12347 (Charlie's reply), but the staged replyTo names root 12345. The fold treats it like any reply; grouping is pinned by reply_thread_grouping.",
+ "decrypted_payload": {
+ "name": "Dana",
+ "text": "Same here!",
+ "ts": 1700000004000,
+ "uid": "user-dana-1",
+ "replyTo": {
+ "id": 12345,
+ "name": "Alice",
+ "snippet": "Hello everyone"
+ }
+ },
+ "server_message": {
+ "id": 12349,
+ "created_at": 1700000004,
+ "blob": "encrypted(decrypted_payload)"
+ },
+ "expected_fold_event": {
+ "type": "chat.msg",
+ "name": "Dana",
+ "text": "Same here!",
+ "ts": 1700000004000,
+ "uid": "user-dana-1",
+ "replyTo": {
+ "id": 12345,
+ "name": "Alice",
+ "snippet": "Hello everyone"
+ },
+ "attachments": []
+ }
+ },
+ {
+ "name": "reply_thread_grouping",
+ "description": "Reply threads group on the resolved ROOT message: new replies point straight at the root; a legacy chain (a reply quoting another reply) resolves transitively through loaded messages. The root's tally counts every message in its thread, and the first reply is the tally's jump target.",
+ "note": "Multi-message vector: fold the sequence, then derive the thread rollup (web viewItems replyRoot/replyCount in chat.js; native ChatFeedModel.threadRoots/refreshReplyCounts). Message 102 is a LEGACY chain β€” it quotes reply 101, not the root β€” and must still resolve to root 100.",
+ "message_sequence": [
+ {
+ "server_message": { "id": 100, "created_at": 1700000000 },
+ "decrypted_payload": { "name": "Alice", "text": "Hello everyone", "ts": 1700000000000, "uid": "user-alice-1" }
+ },
+ {
+ "server_message": { "id": 101, "created_at": 1700000001 },
+ "decrypted_payload": { "name": "Bob", "text": "Hi Alice", "ts": 1700000001000, "uid": "user-bob-1", "replyTo": { "id": 100, "name": "Alice", "snippet": "Hello everyone" } }
+ },
+ {
+ "server_message": { "id": 102, "created_at": 1700000002 },
+ "decrypted_payload": { "name": "Charlie", "text": "Welcome!", "ts": 1700000002000, "uid": "user-charlie-1", "replyTo": { "id": 101, "name": "Bob", "snippet": "Hi Alice" } }
+ },
+ {
+ "server_message": { "id": 103, "created_at": 1700000003 },
+ "decrypted_payload": { "name": "Dana", "text": "Good to be here", "ts": 1700000003000, "uid": "user-dana-1", "replyTo": { "id": 100, "name": "Alice", "snippet": "Hello everyone" } }
+ }
+ ],
+ "expected_threads": {
+ "roots": { "101": 100, "102": 100, "103": 100 },
+ "reply_counts": { "100": 3 },
+ "first_reply": { "100": 101 }
+ },
+ "expected_reply_target": {
+ "note": "Replying to ANY message in the thread (here the legacy chain reply 102) stages the root's id with a fresh snapshot of the root's name/text.",
+ "for_message": 102,
+ "replyTo": { "id": 100, "name": "Alice", "snippet": "Hello everyone" }
+ }
+ },
{
"name": "action_line",
"description": "A /me-style action line (e.g., 'added milk' from a to-do app).",
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index 121b1ab..9897a50 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -290,10 +290,51 @@ function dayLabel(ms) {
// reactions and the reply tally β€” and a change to it patches only those rows in
// place (see patchBubble), never rebuilding, so a decrypted photo's <img> and a
// playing YouTube embed survive a reaction or an incoming reply landing on it.
+// rootRef walks a reply pointer to its thread's TOP message: new replies point
+// straight at the root, so this is one hop; legacy chains (a reply quoting
+// another reply) are chased through the loaded window. Returns the deepest ref
+// reachable β€” its .id is the root's sid when the chain resolves, or the last
+// known pointer when the parent scrolled out of the loaded window. Hop-capped
+// so a hostile payload can't cycle forever.
+function rootRef(bySid, reply) {
+ let ref = reply;
+ for (let hops = 0; hops < 100; hops++) {
+ const parent = bySid.get(ref.id);
+ if (!parent || !parent.reply || !parent.reply.id) return ref;
+ ref = parent.reply;
+ }
+ return ref;
+}
+
+// replyTarget: the replyTo a reply to `m` should carry. Replying to any message
+// in a thread continues that thread, so the ref always names the thread's TOP
+// message β€” never the tapped reply. When the root is loaded, its name/snippet
+// are snapshotted fresh; an unloaded root falls back to the deepest quoted ref
+// we can see. Pure and exported for tests (mirrored natively by
+// ChatFeedModel.replyTarget β€” pinned by the reply_thread_grouping vector).
+export function replyTarget(msgs, m) {
+ if (!m.reply || !m.reply.id) return { id: m.sid, name: m.name, snippet: replySnippet(m.raw) };
+ const bySid = new Map(msgs.map((x) => [x.sid, x]));
+ const ref = rootRef(bySid, m.reply);
+ const root = bySid.get(ref.id);
+ return root
+ ? { id: root.sid, name: root.name, snippet: replySnippet(root.raw) }
+ : { id: ref.id || 0, name: ref.name || "", snippet: ref.snippet || "" };
+}
+
export function viewItems(msgs) {
- const replyCounts = new Map(); // parent sid β†’ replies
+ // Reply threads group on the resolved ROOT message: every reply tallies on
+ // its thread's top message (legacy chains resolve transitively), and each
+ // reply carries its root sid (`replyRoot`) so taps/jumps land on the thread.
+ const bySid = new Map(msgs.map((m) => [m.sid, m]));
+ const replyCounts = new Map(); // thread root sid β†’ messages in its thread
+ const roots = new Map(); // reply sid β†’ its thread's root sid
for (const m of msgs) {
- if (m.reply && m.reply.id) replyCounts.set(m.reply.id, (replyCounts.get(m.reply.id) || 0) + 1);
+ if (m.reply && m.reply.id) {
+ const root = rootRef(bySid, m.reply).id;
+ roots.set(m.sid, root);
+ replyCounts.set(root, (replyCounts.get(root) || 0) + 1);
+ }
}
const out = [];
let prev = null;
@@ -315,6 +356,7 @@ export function viewItems(msgs) {
timeSep,
cont,
replyCount,
+ replyRoot: roots.get(m.sid) || 0,
vrev: `${m.rev}|${timeSep ? (timeSep.withDay ? "D" : "t") : ""}|${cont ? 1 : 0}`,
srev: `${reactSig}|${replyCount}`,
});
@@ -687,7 +729,9 @@ function renderItem(m, ctx) {
wrap.dataset.id = m.sid; // lets a reply quote scroll back to this message
wrap.dataset.t = m.ts;
wrap.dataset.sender = m.name;
- if (m.reply && m.reply.id) wrap.dataset.replyTo = m.reply.id;
+ // Tag with the resolved thread root (not the quoted id) so the "N Replies"
+ // jump on the root finds legacy chain replies too.
+ if (m.reply && m.reply.id) wrap.dataset.replyTo = m.replyRoot || m.reply.id;
if (!mine && m.name && host && host.isGroup && host.isGroup(ctx.threadId) && !m.cont) {
const nm = document.createElement("div");
@@ -2511,7 +2555,8 @@ function buildSkeleton(el, ctx) {
refreshSend,
startReply(m) {
editing = null;
- replyTo = { id: m.sid, name: m.name, snippet: replySnippet(m.raw) };
+ // Replying to a reply continues its thread: target the TOP message.
+ replyTo = replyTarget(((ctx.getState() || {}).msgs) || [], m);
refreshBanners();
// Embedded: the native shell draws the reply banner and focuses its own
// composer (this one is hidden).