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>
9 files changed,
+326
−21
changelog/2026-07-08-reply-threads.md+4 −0ios/LChat/Views/NativeChatScreen.swift+2 −1ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+65 −14ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift+101 −0ios/LChatMac/NativeFeedView.swift+2 −1package.json+1 −1test/event-vectors.test.mjs+35 −0test/event-vectors/chat.json+67 −0web/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). |