web+native: Reply and threads are two separate verbs — nothing joins a thread by accident (#1490)
* web: Reply and threads are two separate verbs
Plain Reply and "Reply in thread" now do genuinely different things, and
neither leaks into the other.
`replyTarget` served both, root-resolving EVERY reply — so replying to a
reply silently joined (or invented) a thread nobody had opened. It splits
into two pure, exported, Node-tested helpers:
- `quoteTarget(m)` — the exact message clicked, no walk, no flag. Plain
Reply everywhere (hover strip, picker, sheet, swipe, ⌘R) uses it, so a
reply-to-a-reply stacks inline in the feed.
- `threadTarget(msgs, m)` — the root of the thread `m` is already in, or
`m` itself when it's in none (that message becomes the new thread's
root). "Reply in thread", the pane composer's fixedReply and the
jump-into-thread fallback use it. The `thread: true` wire flag is still
added only at the compose seam.
Thread membership is now exactly the wire flag plus a direct pointer
(`threadRootOf`) — `rootRef`'s transitive chain walk is gone. So:
`threadItems` is a root plus its flagged replies; the "N Replies" tally
counts thread replies ONLY; and an inline reply's quote card carries no
thread link and jumps to the message it quotes (the pre-thread behaviour)
instead of opening a thread.
Event vectors updated deliberately — they are the spec, not a snapshot:
`reply_thread_grouping` (an inline chain is no longer a thread; root 100
tallies 1, not 4), `reply_in_thread_targets_root` renamed to
`reply_in_thread_continues_the_thread`, notes on `message_with_reply` /
`message_in_thread` / `thread_reply_root_unloaded`, and a new
`inline_reply_chain_is_not_a_thread` proving a chain of three produces no
thread and no tally. New keys `expected_thread_items` and
`expected_reply_targets` (quote + thread) are checked by both suites.
Existing history carries no `thread` flag, so every reply in it is a plain
inline quote: today's tallies disappear and those chains stay in the feed,
which is the intended transition.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* core: quoteTarget / threadTarget — a thread is the flag, not a chain
The native twins of the web split. `ChatFeedModel.replyTarget` becomes
`quoteTarget(for:)` (the exact message, for plain Reply) and
`threadTarget(in:for:)` (the thread's root, or the message itself when it
is in no thread, for "Reply in Thread").
`threadRootID(of:)` replaces the hop-capped chain walk: a message belongs
to a thread iff its `replyTo` carries `thread: true`, and the root is the
id it points at. `threadRoots`, `threadItems`, `threadReplyRef`,
`mainFeedItems` and `refreshReplyCounts` all read that one predicate, so
the tally counts thread replies only and an inline chain folds into no
thread.
MessageBubble's quote card follows the same fork: a thread reply's card
opens its thread (the only way it renders in the feed is with its root
paged out, so that IS the way back), a plain reply's card jumps to the
message it quotes.
ReplyThreadVectorTests rewritten around the new contract, and now drives
both sequence vectors (reply_thread_grouping and the new
inline_reply_chain_is_not_a_thread) from chat.json — grouping, tallies,
feed visibility, expected_thread_items and expected_reply_targets — so web
and native are pinned to byte-identical expectations.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* mac+ios: the two reply verbs on the native surfaces
startReply stages `quoteTarget` (the exact bubble tapped) and
replyInThread resolves `threadTarget`, matching the web. jumpIntoThread
resolves its root through `ChatFeedModel.threadRootID(of:)` rather than
falling back to any quoted id.
Both containers gain the web's overlay guard: while the lifted thread view
is open, a Reply on one of its bubbles stages the thread's root WITH the
flag instead of quoting that bubble into the transcript. iOS already
routed the overlay's own menus that way (threadMessageActions); this is
the belt on startReply itself, and on the Mac it also fixes an existing
gap — its overlay bubbles use the plain feed menu, so a Reply there used
to send an unflagged reply to the root, landing outside the thread.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* web+native: a quote card that can't reach its target says so, and pages back for it
Review finding on #1490. With no history carrying the thread flag, EVERY
historical reply is now a plain reply — so the plain quote card's jump is the
default path, and on a cold-opened chat its target is usually older than the
loaded page. All three platforms then did nothing at all: web scrollToMessage
returned on a DOM miss, Mac jump(to:) and iOS jumpAndWave guarded on
`model.messages.first(where:)`. A dead control.
Now the jump pages history back for its target and lands on it, generalising
the walk the thread view already had:
- web: `pageInRoot`'s loop becomes `pageBackTo(sid, stillWanted)`, returning
found / gone / capped; `revealMessage` drives it for a jump, reached through
the mount registry as `threads.get(id).reveal(sid)`. Same loadingOlder /
exhausted guards and the same 5-page cap.
- native: `ChatFeedModel.pageBack(to:) -> JumpReach` in LChatCore (8 pages,
matching the Mac's existing consumeJump walk), driven by `reveal(_:)` in
both containers.
Bounded, always: at most cap iterations, and each one returns or must make
progress — target found, the loaded window starting BELOW the target (pages
are contiguous, so it was deleted), `exhausted`, or an unmoved oldest id. The
native walk waits out loadOlder's own cooldown once before giving up; the web
walk waits out an in-flight scroll-back before starting, since both re-anchor
scrollTop against their own pre-prepend height. Neither can recurse: a `found`
means the message is loaded, so the retried jump takes a loaded path.
When it can't reach the message the reader is told, inline and never floating:
a new `.chatapp-feed-note` on web and the shared `FeedNoteBar` (mounted via
.safeAreaInset) on Mac and iOS. Two answers, because they differ: "isn't
available any more" (deleted / start of history) vs "further back than this
jump reaches — keep scrolling up for it". One wording for all three, pinned on
both sides — `jumpReachNote` in chat.js and `ChatFeedModel.jumpReachNote`,
each tested against the other's exact strings.
Also from the review:
- Stale comments corrected: `paintQuoteThreadLink`'s header still claimed an
inline quote card "has always opened its whole thread" (the behaviour this
branch removes, contradicting threadLinkCount 20 lines up), and Message's
replyCount / threadRootID / threadReplyCount docs still described resolved
roots, chased chains and "anything that isn't a reply".
- `Message.threadRootID` keeps a comment saying why it stays: no container
reads it (they call the static), but it's the native twin of the web's
`replyRoot` — which IS read, for data-reply-to — and what the vectors'
expected_threads.roots asserts against here.
- The Mac overlay guard is described honestly now: `threadDocked` is a
constant true there, so it's a belt for the parked overlay path, not a bug
that was live. iOS's is a second guard alongside threadMessageActions.
- ReplyThreadVectorTests derives its sequence-vector list from the JSON
(every entry with expected_threads) instead of hardcoding two names, so a
new vector can't pass on web and skip natively.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* web+native: the reveal actually lands, and a failed fetch stops meaning "deleted"
Three findings on 965898f7.
1. The landing was yanked straight back to the bottom, so the fix didn't work
in the case it exists for. Every page pageBackTo folds re-renders the feed,
and render() decides "was at bottom" BEFORE the prepend — so from the bottom
of a busy chat each page started a follow glide. A registered glide both
writes scrollTop every frame (an auto-behaviour write cancels an in-flight
smooth scroll) and makes feedNearBottom answer "at bottom" regardless of
geometry, so every later render re-armed it. The iteration that finds the
target returns in the same synchronous turn as the last prepend, so the
glide had never stepped and was always live: the note flashed, the feed
never moved. Reduce Motion ON hid it — that path jumps instead of gliding.
revealMessage now calls a new exported feedCancelGlide(feed) and clears
feedAtBottom before flashMessage, so the ResizeObserver's re-pin bails too
(clearing the note is itself a resize). A Node test pins the mechanism: a
registered glide claims "at bottom" against contrary geometry, cancelling
releases the claim, and an already-scheduled step is inert afterwards.
2. A failed history fetch told the reader the message was deleted.
chatAppLoadOlder swallowed every error and returned 0; pageBackTo read 0 as
end-of-history, latched `exhausted` for the whole mount and answered "gone".
One offline blip or a 502 mid-deploy became a permanent "isn't available any
more", with scroll-back dead until remount and the retry answering instantly
without touching the network. loadOlder now returns -1 for a failed fetch
(non-ok status, throw, bad JSON) and 0 only when the SERVER says history
ends. The distinction is a pure, exported, Node-tested pageResult(n) →
page|end|error, used by both the walk and the scroll-back handler: only
"end" latches `exhausted` or answers "gone"; "error" is retryable ("capped").
3. Two of the three native jump routes were still silent no-ops. Mac
consumeJump and iOS runPendingJump kept their own hand-rolled 8-page walks
ending in a bare return, so a search hit or notification tap that couldn't
be reached did nothing while web explained. Both now just call
jump/jumpAndWave, which pages back through reveal() — the walk exists once,
and all three entry points behave alike.
Also:
- A different target now supersedes an in-flight walk instead of being dropped
(the old comment described only the same-target case).
- try/finally around the walk and around each loadOlder: a rejecting host used
to latch loadingOlder, revealSid and the sticky note forever — latent only
because chatAppLoadOlder swallowed everything, which (2) stops it doing.
- a11y: the role="status" node is no longer display:none between updates (it
collapses via :empty), so a live-region update is announced from a node that
was already in the accessibility tree.
- Native pageBack no longer burns a guaranteed-no-op loadOlder plus a 550ms
sleep on every page after the first (loadOlder re-arms its cooldown in a
defer): the wait is taken up front from the second pass on, saving ~4s on a
full walk, with the first pass still retrying once in case the reader had
just scroll-loaded.
- Reach pinned: web PAGE_BACK_CAP 5 -> 8 to match ChatFeedModel.jumpPageCap.
Both fetch the server's 100-row default page, so one jump reaches ~800
messages everywhere and the same message can't answer "capped" on web and
land on native.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
12 files changed,
+1400
−526
changelog/2026-08-28-thread-pane.md+22 −17ios/LChat/Views/NativeChatScreen.swift+100 −33ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+163 −63ios/LChatCore/Sources/LChatCore/FeedNoteBar.swift+36 −0ios/LChatCore/Sources/LChatCore/MessageBubble.swift+12 −13ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift+262 −138ios/LChatMac/NativeFeedView.swift+93 −27test/apps.test.mjs+153 −47test/event-vectors.test.mjs+30 −11test/event-vectors/chat.json+124 −24web/static/apps/chat.js+392 −146web/static/shell.js+13 −7
diff --git a/changelog/2026-08-28-thread-pane.md b/changelog/2026-08-28-thread-pane.md| index 0cb785d..159c0d2 100644 |
| --- a/changelog/2026-08-28-thread-pane.md |
| +++ b/changelog/2026-08-28-thread-pane.md |
| @@ -1,17 +1,22 @@ |
| -🤖 Reply threads now open beside the chat on a desktop-sized screen instead |
| -of over it. Tap a message's "N Replies" tally or its quote card and the thread |
| -docks as a column on the right, with its own reply box at the bottom — so you |
| -can follow the main conversation and the thread at the same time. The message |
| -menu has a new "Reply in thread" next to "Reply" — and on a computer it's a |
| -button on the message itself: hover any message and it sits beside the reply |
| -arrow, drawn as two speech bubbles so the two are easy to tell apart. While a |
| -thread is open you'll find it under the ▤ Views button in the header. On a |
| -phone the thread still lifts over the chat the way it always has. Replies you |
| -make in a thread now stay in the thread: the chat itself shows just the |
| -"N Replies" tally under the original message (tap it to read them), so a busy |
| -thread no longer crowds the conversation. A plain Reply still lands in the |
| -chat as before, and a search hit or link to a thread reply opens the thread |
| -for you. And where a reply in the chat quotes a message whose thread has |
| -grown, its quote card now shows that thread's reply count — the same number |
| -the original message shows — so you can see there's more to read and tap |
| -straight through to it. |
| +🤖 Replying and starting a thread are now two separate things, and you choose |
| +which one you want. **Reply** quotes the exact message you tapped and stays in |
| +the chat, the way it always has — reply to a reply and it simply stacks in the |
| +conversation. **Reply in thread** starts (or continues) a thread hanging off |
| +that message: those replies live in the thread, and the chat shows just an |
| +"N Replies" tally under the original message. Nothing gets pulled into a |
| +thread by accident any more — a thread exists only because someone chose to |
| +start one. Replies you made before this all count as plain replies, so they |
| +stay in the chat exactly where they are. |
| + |
| +"Reply in thread" sits next to "Reply" in the message menu, and on a computer |
| +it's a button on the message itself: hover any message and it's beside the |
| +reply arrow, drawn as two speech bubbles so the two are easy to tell apart. |
| + |
| +Threads now open beside the chat on a desktop-sized screen instead of over it. |
| +Tap a message's "N Replies" tally and the thread docks as a column on the |
| +right, with its own reply box at the bottom — so you can follow the main |
| +conversation and the thread at the same time. While a thread is open you'll |
| +find it under the ▤ Views button in the header. On a phone the thread still |
| +lifts over the chat the way it always has. Tapping the quote card above a |
| +plain reply jumps you to the message it's quoting; a search hit or link to a |
| +reply made in a thread opens that thread for you. |
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift| index eaa3f3b..7cfd4de 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -128,6 +128,13 @@ struct NativeChatScreen: View { |
| // The notification-tap thread-reply check ran for this open (one-shot, |
| // like the unread anchor it complements — landOnUnreadThreadReply). |
| @State private var unreadThreadChecked = false |
| + // The feed's inline status line (FeedNoteBar, under the header): a jump |
| + // that has to page history back for its target says so, and says why when |
| + // it can't reach it. `revealing` is the target of the walk in flight — |
| + // one at a time, so a second tap can't stack walks. |
| + @State private var feedNote: String? |
| + @State private var feedNoteGen = 0 |
| + @State private var revealing: Int64? |
| // Where the open thread presents (thread-pane spec §6, the iPad slice): |
| // in a REGULAR horizontal size class it docks — a flush right-hand pane |
| // beside the feed with its own composer, Slack-style; compact keeps the |
| @@ -429,6 +436,12 @@ struct NativeChatScreen: View { |
| // A DM partner renamed themself past my name for them |
| // (#577): the keep/adopt choice, inline under the header. |
| RenameBanner(store: store, thread: thread) |
| + // The feed's inline status line — a jump that has to page |
| + // history back for its target, and why when it can't |
| + // reach it. Inside the measured stack on purpose: the |
| + // feed's rest inset grows with it, so the note takes real |
| + // layout space instead of floating over the transcript. |
| + FeedNoteBar(feedNote) |
| } |
| // Measured for the feed's rest inset (the feed extends under |
| // the header; the probe never shapes layout). |
| @@ -760,25 +773,15 @@ struct NativeChatScreen: View { |
| @State private var mountSpan: Perf.Span? |
| @State private var mountEnded = false |
| - // A search hit lands ON its message: page history until the target is |
| - // loaded, then jump-and-wave. (The open still lands at the bottom first — |
| - // the walk is bounded so a hit deeper than ~8 pages stays un-jumped |
| - // rather than risking a watchdog-length stall.) |
| + // A search hit lands ON its message: jumpAndWave pages history back for it |
| + // through reveal() when it isn't loaded (the open still lands at the |
| + // bottom first), and says so inline when the walk can't reach it — a hit |
| + // deeper than the jump's reach is explained rather than silently ignored. |
| + // (This used to carry its own hand-rolled walk ending in a bare `return`.) |
| private func runPendingJump() { |
| guard let j = Self.pendingJump, j.thread == thread.id, model.ready else { return } |
| Self.pendingJump = nil |
| - Task { |
| - var tries = 0 |
| - while !model.messages.contains(where: { $0.id == j.message }), |
| - !model.exhausted, tries < 8 { |
| - tries += 1 |
| - await model.loadOlder() |
| - try? await Task.sleep(nanoseconds: 550_000_000) |
| - } |
| - guard model.messages.contains(where: { $0.id == j.message }) else { return } |
| - try? await Task.sleep(nanoseconds: 150_000_000) |
| - jumpAndWave(j.message) |
| - } |
| + jumpAndWave(j.message) |
| } |
| // The feed column — the scrolling transcript, the lifted thread overlay |
| @@ -1014,7 +1017,7 @@ struct NativeChatScreen: View { |
| // way, so a search hit or notification never dead-ends on "the newest |
| // reply" instead of the one asked for. |
| private func jumpIntoThread(_ target: ChatFeedModel.Message) { |
| - guard let root = target.threadRootID ?? target.reply?.id, root > 0 else { return } |
| + guard let root = ChatFeedModel.threadRootID(of: target) else { return } |
| openThread(root) |
| threadJump = target.id |
| threadJumpTick += 1 |
| @@ -1109,16 +1112,18 @@ struct NativeChatScreen: View { |
| } |
| } |
| - // "Reply in thread" (thread-pane spec §3.5): resolve the tapped message |
| - // to its thread's ROOT (replyTarget — replying to a reply continues its |
| - // thread), open the thread view pinned to it (a re-tap inside the open |
| - // thread just re-arms the root reply), then put the caret in the reply |
| - // box — the pane's bar when docked, the channel bar staged to the root |
| - // under the overlay. Unlike a tally/quote tap this IS a reply intent, so |
| - // the keyboard pops even on the overlay path, which otherwise never |
| - // focuses on touch. |
| + // "Reply in thread" (thread-pane spec §3.5): resolve the tapped message to |
| + // its thread's ROOT (threadTarget — a message already IN a thread |
| + // continues it, anything else roots a NEW thread on itself), open the |
| + // thread view pinned to it (a re-tap inside the open thread just re-arms |
| + // the root reply), then put the caret in the reply box — the pane's bar |
| + // when docked, the channel bar staged to the root under the overlay. |
| + // Unlike a tally/quote tap this IS a reply intent, so the keyboard pops |
| + // even on the overlay path, which otherwise never focuses on touch. This |
| + // verb is the ONLY way a thread ever begins; nothing is absorbed into one |
| + // by replying. |
| private func replyInThread(_ m: ChatFeedModel.Message) { |
| - guard let rootID = model.replyTarget(for: m).id, rootID > 0 else { return } |
| + guard let rootID = model.threadTarget(for: m).id, rootID > 0 else { return } |
| if threadRootID != rootID { |
| openThread(rootID) |
| } else if !threadDocked { |
| @@ -1288,9 +1293,9 @@ struct NativeChatScreen: View { |
| // The pane's send: an ORDINARY replyTo reply — no command routing, no |
| // default-command detour (matching the web pane composer). The reply |
| - // target derives FRESH here (ChatFeedModel.replyTarget semantics): the |
| - // root may have paged in, or the chain's deepest snapshot improved, |
| - // since the pane opened. |
| + // target derives FRESH here (ChatFeedModel.threadReplyRef): the root may |
| + // have paged in since the pane opened, and a loaded root snapshots better |
| + // than the frozen quote. |
| private func sendPaneDraft() { |
| // An edit replaces the message in place — same rule as the channel bar. |
| if let id = paneEditingID { |
| @@ -1331,12 +1336,18 @@ struct NativeChatScreen: View { |
| // Scroll to a message and wave it — quote-taps and search hits share this. |
| // The near/far animation split lives in the container's scrollTo. |
| private func jumpAndWave(_ id: Int64) { |
| + guard let target = model.messages.first(where: { $0.id == id }) else { |
| + // Not loaded at all — older than the page we hold. Page back for |
| + // it rather than doing nothing: this is the ordinary case for the |
| + // quote card above a plain reply on a cold-opened chat. |
| + reveal(id) |
| + return |
| + } |
| // Loaded but not in the transcript = a thread-only reply whose root |
| // is loaded (mainFeedItems): its bubble is in the thread view, so |
| // land there. (With the root unloaded it degrades to an inline bubble |
| // and the ordinary scroll below finds it.) |
| - if let target = model.messages.first(where: { $0.id == id }), |
| - !model.feedMessages.contains(where: { $0.id == id }) { |
| + if !model.feedMessages.contains(where: { $0.id == id }) { |
| jumpIntoThread(target) |
| return |
| } |
| @@ -1347,6 +1358,46 @@ struct NativeChatScreen: View { |
| } |
| } |
| + // Page history back for a jump target that isn't loaded, then land on it — |
| + // the web's revealMessage, the Mac's reveal. Never a silent no-op: the |
| + // wait says so inline, and a walk that can't reach the message says which |
| + // kind of can't (deleted vs. further back than one jump goes). One walk at |
| + // a time. |
| + // |
| + // Cannot recurse: model.pageBack returning .found means the message IS in |
| + // model.messages, so jumpAndWave below takes a loaded path and can't come |
| + // back here. |
| + private func reveal(_ id: Int64) { |
| + guard revealing == nil, id > 0 else { return } |
| + revealing = id |
| + showFeedNote("Looking for that message…", sticky: true) // the outcome replaces it |
| + Task { |
| + let reach = await model.pageBack(to: id) |
| + revealing = nil |
| + showFeedNote(ChatFeedModel.jumpReachNote(reach)) |
| + guard reach == .found, model.messages.contains(where: { $0.id == id }) else { return } |
| + try? await Task.sleep(nanoseconds: 150_000_000) // let the prepended rows land |
| + jumpAndWave(id) |
| + } |
| + } |
| + |
| + // The note answers something the reader just did, so it clears itself |
| + // rather than going stale behind a thread switch — the transient-status |
| + // convention the busy captions already use here. A note that's still true |
| + // while work is in flight ("Looking for that message…") is `sticky`: it's |
| + // replaced by the outcome, never timed out from under a walk that runs |
| + // long. The generation counter keeps every clear cancel-safe. |
| + private static let feedNoteSeconds: TimeInterval = 6 |
| + private func showFeedNote(_ text: String?, sticky: Bool = false) { |
| + feedNote = text |
| + feedNoteGen += 1 |
| + guard text != nil, !sticky else { return } |
| + let gen = feedNoteGen |
| + DispatchQueue.main.asyncAfter(deadline: .now() + Self.feedNoteSeconds) { |
| + if feedNoteGen == gen { feedNote = nil } |
| + } |
| + } |
| + |
| private func playVideo(_ att: Attachment, from frame: CGRect) { |
| guard interaction.blobDownload == nil else { return } |
| interaction.blobDownload = (att.id, 0) |
| @@ -1818,8 +1869,24 @@ struct NativeChatScreen: View { |
| private func startReply(_ m: ChatFeedModel.Message) { |
| editingID = nil |
| - // Replying to a reply continues its thread: target the TOP message. |
| - replyTo = model.replyTarget(for: m) |
| + // A second guard alongside the container menus: the overlay's own |
| + // bubbles already route Reply through threadMessageActions, which |
| + // stages the flagged root, so this catches any OTHER path into |
| + // startReply while the overlay owns the composer (a hotkey, a swipe, |
| + // a future call site). Under the overlay the channel bar IS the open |
| + // thread's reply box — without this the plain verb would quote that |
| + // bubble and land the reply in the transcript, out of the thread the |
| + // reader is standing in. |
| + if let rootID = threadRootID, !threadDocked, model.threadTarget(for: m).id == rootID { |
| + stageThreadReply() |
| + composerFocused = true |
| + return |
| + } |
| + // Plain Reply quotes the EXACT message tapped — never a root walk. |
| + // Replying to a reply stacks another quote in the feed; joining a |
| + // thread is the other verb's job ("Reply in thread"), and only that |
| + // verb's. |
| + replyTo = model.quoteTarget(for: m) |
| composerFocused = true |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift| index 590e5b7..5983b39 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| @@ -37,16 +37,26 @@ public final class ChatFeedModel: ObservableObject { |
| public var reactionEvents: [ReactionEvent] = [] |
| // 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. |
| + // "N Replies" tag). The tally counts THREAD replies only — replies |
| + // someone deliberately made in the thread (replyTo.thread); an |
| + // ordinary inline quote reply is already visible in the feed and adds |
| + // nothing here. |
| 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. |
| + // For a THREAD reply: the id of the thread it's in — the id its |
| + // `replyTo` points straight at (the web's `replyRoot`). nil for |
| + // everything else, inline quote replies included: they're in no |
| + // thread. No container reads this today — they call the static |
| + // `threadRootID(of:)` — but it stays as the native twin of the web's |
| + // viewItems `replyRoot` (which IS read, for `data-reply-to`), and |
| + // it's what the vectors' expected_threads.roots is asserted against on |
| + // this side. Drop it only when the web drops `replyRoot`. |
| public var threadRootID: Int64? |
| - // For a reply: its own view of that thread's tally — the SAME number |
| - // its root's `replyCount` shows (the web's viewItems `threadCount`), so |
| - // a quote card that doubles as a thread link can never disagree with |
| - // the root it points at. 0 for anything that isn't a reply. |
| + // For a THREAD reply: its own view of that thread's tally — the SAME |
| + // number its root's `replyCount` shows (the web's viewItems |
| + // `threadCount`), so a quote card that doubles as a thread link can |
| + // never disagree with the root it points at. 0 for anything that isn't |
| + // a thread reply. |
| public var threadReplyCount: Int = 0 |
| // The decrypted payload as sent — an edit re-encrypts {…raw, text}, so |
| // attachments/reply metadata survive the rewrite (chat.js does the same). |
| @@ -1038,6 +1048,75 @@ public final class ChatFeedModel: ObservableObject { |
| status = "\(messages.count) messages" |
| } |
| + /// What a jump found when it went looking for its target — three genuinely |
| + /// different answers, because "gone" is final and "capped" means keep |
| + /// scrolling. Mirrors the web's pageBackTo return. |
| + public enum JumpReach: Sendable { case found, gone, capped } |
| + |
| + /// How many history pages ONE jump walks back looking for its target. A |
| + /// message thousands back is a scroll, not a jump — and an unbounded walk |
| + /// would pull an entire history into memory on a stray tap. |
| + /// |
| + /// PINNED to the web's PAGE_BACK_CAP (chat.js): both fetch the server's |
| + /// 100-row default page — native by passing no `limit`, web by asking for |
| + /// 100 — so one jump reaches ~800 messages on every platform and the same |
| + /// message can't answer "capped" here and land there. |
| + private static let jumpPageCap = 8 |
| + |
| + /// How long loadOlder's own cooldown runs (see its guard). Waited out |
| + /// deliberately in pageBack rather than discovered by burning a call. |
| + private static let olderCooldown: UInt64 = 550_000_000 |
| + |
| + /// Page history backward until message `id` is in the loaded window. |
| + /// |
| + /// The native twin of the web's `pageBackTo`, and the fix for a jump whose |
| + /// target simply isn't loaded — a quote card above a plain reply whose |
| + /// parent is older than the loaded page (the DEFAULT for every reply in |
| + /// existing history, none of which carries the thread flag), a search hit, |
| + /// a notification. Without it those taps did nothing at all. |
| + /// |
| + /// **Terminates, always.** At most `jumpPageCap` iterations, and each one |
| + /// either returns or must make progress: the target turning up returns |
| + /// `.found`; a loaded window that starts BELOW the target without holding |
| + /// it returns `.gone` (pages are contiguous, so the message was deleted); |
| + /// `exhausted` (an empty page — the start of history) returns `.gone`; and |
| + /// a pass that leaves the oldest loaded id unmoved gives up rather than |
| + /// spinning. |
| + /// |
| + /// `loadOlder()` re-arms its own half-second cooldown in a `defer`, so a |
| + /// call made straight after a successful page is a GUARANTEED no-op. The |
| + /// wait is therefore taken up front on every pass but the first — burning |
| + /// a call to discover it, then sleeping anyway, cost an 8-page walk about |
| + /// four extra seconds. The first pass skips the wait (the cooldown is |
| + /// usually idle) and retries once if it turns out not to be, which is the |
| + /// case where the reader had just scroll-loaded. |
| + public func pageBack(to id: Int64) async -> JumpReach { |
| + guard id > 0 else { return .gone } |
| + for pass in 0..<Self.jumpPageCap { |
| + if messages.contains(where: { $0.id == id }) { return .found } |
| + guard let oldest = messages.first?.id else { return .gone } |
| + if oldest < id || exhausted { return .gone } |
| + if pass > 0 { try? await Task.sleep(nanoseconds: Self.olderCooldown) } |
| + await loadOlder() |
| + if pass == 0, messages.first?.id == oldest, !exhausted { |
| + try? await Task.sleep(nanoseconds: Self.olderCooldown) |
| + await loadOlder() |
| + } |
| + if messages.first?.id == oldest { return exhausted ? .gone : .capped } |
| + } |
| + return messages.contains(where: { $0.id == id }) ? .found : .capped |
| + } |
| + |
| + /// The inline wording for a jump that couldn't land — one string, so web, |
| + /// Mac and iOS say the same thing. nil for `.found` (nothing to say). |
| + nonisolated public static func jumpReachNote(_ reach: JumpReach) -> String? { |
| + switch reach { |
| + case .found: return nil |
| + case .capped: return "That message is further back than this jump reaches — keep scrolling up for it." |
| + case .gone: return "That message isn't available any more." |
| + } |
| + } |
| + |
| // A 200-message page is 200 AES-GCM opens + JSON |
| // decodes + Message builds — done on the main actor (this class's home) |
| // it froze the UI on open, worst on long chats and the prewarm burst. |
| @@ -1722,48 +1801,64 @@ public final class ChatFeedModel: ObservableObject { |
| slowTimers[cid] = nil |
| } |
| - // 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). |
| + /// The id of the thread a message BELONGS to, or nil for a message in no |
| + /// thread. A thread exists only because someone chose "Reply in thread", |
| + /// so membership is exactly the wire flag: a reply carrying |
| + /// `replyTo.thread` points DIRECTLY at its thread's root (every thread |
| + /// send derives its target from the root — threadReplyRef), so this is a |
| + /// straight read, never a walk. Plain quote replies — the ordinary Reply |
| + /// verb, and every reply in the history from before the two verbs split — |
| + /// belong to no thread: an inline chain is conversation in the feed, not |
| + /// a thread nobody opened. Mirrors chat.js's threadRootOf. |
| + nonisolated public static func threadRootID(of m: Message) -> Int64? { |
| + guard isThreadOnly(m), let id = m.reply?.id, id > 0 else { return nil } |
| + return id |
| + } |
| + |
| + // Every thread reply's root (message id → root id). Mirrors chat.js's |
| + // threadRootOf fold — pinned by the reply_thread_grouping and |
| + // inline_reply_chain_is_not_a_thread vectors (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 } |
| + if let root = threadRootID(of: m) { roots[m.id] = root } |
| } |
| 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 }) { |
| + /// The ReplyRef a PLAIN reply to `m` carries — the exact message tapped, |
| + /// nothing resolved. Replying to a reply quotes that reply; the chain |
| + /// stacks inline in the feed and starts no thread. Mirrors chat.js's |
| + /// quoteTarget — pinned by reply_thread_grouping's |
| + /// expected_reply_targets.quote. |
| + nonisolated static func quoteTarget(for m: Message) -> ReplyRef { |
| + ReplyRef(id: m.id, name: m.name, snippet: replySnippet(m.raw)) |
| + } |
| + |
| + /// Composer entry point for both platforms' startReply. |
| + public func quoteTarget(for m: Message) -> ReplyRef { |
| + Self.quoteTarget(for: m) |
| + } |
| + |
| + /// The ReplyRef a "Reply in Thread" on `m` carries — the ROOT of the |
| + /// thread the reply belongs in. A message already IN a thread continues |
| + /// that thread (its root, snapshotted fresh when loaded, the quoted |
| + /// snapshot until then); anything else — a plain message, a quote reply, |
| + /// a whole inline chain — roots a NEW thread on ITSELF, because until |
| + /// someone chose this verb there was no thread to join. Mirrors chat.js's |
| + /// threadTarget. The `thread: true` wire flag is added at the compose |
| + /// seam (.inThread()), never here. |
| + nonisolated static func threadTarget(in list: [Message], for m: Message) -> ReplyRef { |
| + guard let rootID = threadRootID(of: m) else { return quoteTarget(for: m) } |
| + if let root = list.first(where: { $0.id == rootID }) { |
| return ReplyRef(id: root.id, name: root.name, snippet: replySnippet(root.raw)) |
| } |
| - return ReplyRef(id: rootID, name: reply.name, snippet: reply.snippet) |
| + return ReplyRef(id: rootID, name: m.reply?.name, snippet: m.reply?.snippet) |
| } |
| - /// Composer entry point for both platforms' startReply. |
| - public func replyTarget(for m: Message) -> ReplyRef { |
| - Self.replyTarget(in: messages, for: m) |
| + /// Composer entry point for both platforms' "Reply in Thread". |
| + public func threadTarget(for m: Message) -> ReplyRef { |
| + Self.threadTarget(in: messages, for: m) |
| } |
| /// What ⌘R replies to: the newest real message, yours or anyone else's |
| @@ -1785,19 +1880,20 @@ public final class ChatFeedModel: ObservableObject { |
| } |
| /// The reply thread rooted at `rootID`: the root message (when loaded) |
| - /// first, then every message whose RESOLVED thread root is that id, in |
| - /// server order. Legacy chains (a reply quoting another reply) resolve |
| - /// transitively via threadRoots, so they land in the same thread as their |
| - /// ancestors; when the root itself has scrolled out of the loaded window |
| - /// the replies still fold (the view renders the quoted snapshot as its |
| - /// header instead). Mirrors chat.js's threadItems — the thread view |
| - /// re-derives this from current state on every publish, never a cached |
| - /// list. Pinned by the web twin in test/apps.test.mjs. |
| + /// first, then every message whose thread root is that id, in server |
| + /// order. Membership is the flag and a direct pointer (threadRootID(of:)): |
| + /// an inline quote chain hanging off the same root is NOT part of the |
| + /// thread, it's ordinary feed conversation. When the root itself has |
| + /// scrolled out of the loaded window the replies still fold (the view |
| + /// renders the quoted snapshot as its header instead). Mirrors chat.js's |
| + /// threadItems — the thread view re-derives this from current state on |
| + /// every publish, never a cached list. Pinned by the web twin in |
| + /// test/apps.test.mjs and by expected_thread_items in chat.json. |
| nonisolated public static func threadItems(in list: [Message], root rootID: Int64) -> [Message] { |
| - let roots = threadRoots(list) |
| + guard rootID > 0 else { return [] } |
| var out: [Message] = [] |
| if let root = list.first(where: { $0.id == rootID }) { out.append(root) } |
| - out.append(contentsOf: list.filter { roots[$0.id] == rootID }) |
| + out.append(contentsOf: list.filter { threadRootID(of: $0) == rootID }) |
| // Pending (optimistic) copies sort LAST, never ahead of the root or a |
| // confirmed reply — the web's bySidPendingLast rule, and the cause of |
| // its thread-view "blink" when a just-sent reply sorted above the |
| @@ -1812,17 +1908,16 @@ public final class ChatFeedModel: ObservableObject { |
| /// The ReplyRef the open thread view stages on the composer (always the |
| /// thread's root) — doubling as the header stand-in while the root isn't |
| /// loaded. A loaded root snapshots its name/snippet fresh; otherwise fall |
| - /// back to the deepest quoted ref any loaded member of the thread carries |
| - /// (the web hands openThread the tapped message's replyTarget as this |
| + /// back to the quoted snapshot a loaded member of the thread carries (the |
| + /// web hands openThread the tapped message's threadTarget as this |
| /// fallback; natively the tap only carries the root id, so it's re-derived |
| /// from state). |
| nonisolated public static func threadReplyRef(in list: [Message], root rootID: Int64) -> ReplyRef { |
| if let root = list.first(where: { $0.id == rootID }) { |
| return ReplyRef(id: root.id, name: root.name, snippet: replySnippet(root.raw)) |
| } |
| - let roots = threadRoots(list) |
| - if let m = list.first(where: { roots[$0.id] == rootID }) { |
| - return replyTarget(in: list, for: m) |
| + if let m = list.first(where: { threadRootID(of: $0) == rootID }) { |
| + return threadTarget(in: list, for: m) |
| } |
| return ReplyRef(id: rootID) |
| } |
| @@ -1834,16 +1929,16 @@ public final class ChatFeedModel: ObservableObject { |
| /// A reply made IN a thread (its payload's replyTo carries thread:true — |
| /// ReplyRef): it belongs to the thread view, not the main feed (see |
| - /// mainFeedItems for the one exception). Everything else about it is |
| - /// unchanged — it still tallies on its root (refreshReplyCounts), folds |
| - /// into threadItems, and counts as an arrival for unread/notification |
| - /// purposes. |
| + /// mainFeedItems for the one exception). The flag is also what makes it a |
| + /// thread reply at all — it tallies on its root (refreshReplyCounts) and |
| + /// folds into threadItems where a plain quote reply does neither — and it |
| + /// counts as an arrival for unread/notification purposes either way. |
| nonisolated public static func isThreadOnly(_ m: Message) -> Bool { |
| m.reply?.thread == true |
| } |
| /// The main feed's item list from the full fold: every message except |
| - /// the thread-only replies whose resolved ROOT is loaded (Slack's model — |
| + /// the thread-only replies whose ROOT is loaded (Slack's model — |
| /// the feed shows a threaded root with its "N replies" tally; the replies |
| /// themselves live in the thread view). ROOT-AWARE, deliberately: the |
| /// tally only exists on a loaded root (refreshReplyCounts), so a thread |
| @@ -1898,8 +1993,11 @@ public final class ChatFeedModel: ObservableObject { |
| } |
| // 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. |
| + // the "N Replies" on a root counts its THREAD — only the replies someone |
| + // deliberately made in it (threadRootID(of:)). An ordinary inline reply |
| + // contributes nothing: it's already visible right there in the feed, so a |
| + // tally on top of it would count the same conversation twice and read as a |
| + // thread nobody started. 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) |
| @@ -1928,9 +2026,11 @@ public final class ChatFeedModel: ObservableObject { |
| } |
| /// Does this reply's quote card double as a link into its thread, and with |
| - /// what count? A reply whose thread holds MORE than just it has |
| - /// conversation to go and see, so the card says so; a lone quote-reply's |
| - /// card stays exactly as it was. The number is the thread's WHOLE tally |
| + /// what count? Only a THREAD reply ever wears one (an inline reply's |
| + /// threadReplyCount is 0 — its card quotes and jumps, nothing more), and |
| + /// only while its thread holds more than just it: rendered inline because |
| + /// its root is paged out, its card is the one way back into the |
| + /// conversation it belongs to. The number is the thread's WHOLE tally |
| /// (the same one the root wears), never "the others besides me". Returns 0 |
| /// for "no thread link". Mirrors chat.js's `threadLinkCount`. |
| nonisolated public static func threadLinkCount(_ m: Message) -> Int { |
diff --git a/ios/LChatCore/Sources/LChatCore/FeedNoteBar.swift b/ios/LChatCore/Sources/LChatCore/FeedNoteBar.swift| new file mode 100644 |
| index 0000000..2d8f7e4 |
| --- /dev/null |
| +++ b/ios/LChatCore/Sources/LChatCore/FeedNoteBar.swift |
| @@ -0,0 +1,36 @@ |
| +import SwiftUI |
| + |
| +// 🤖 The feed's inline status line — the transcript's twin of the reply-thread |
| +// view's note (ReplyThreadView's `note`), and of the web's .chatapp-feed-note. |
| +// Deliberately NOT a floating banner: mounted through .safeAreaInset it takes |
| +// real layout space above the transcript, so it never curtains a message |
| +// someone is reading or about to tap (the project's no-toast rule). |
| +// |
| +// Today it answers a jump that has to page history back for its target — |
| +// "looking…", and when the walk can't reach it, why — so a tap on a quote card |
| +// is never a silent no-op. It renders nothing for a nil/empty note, so callers |
| +// can bind it straight to an optional. |
| +public struct FeedNoteBar: View { |
| + private let text: String? |
| + |
| + public init(_ text: String?) { self.text = text } |
| + |
| + public var body: some View { |
| + if let text, !text.isEmpty { |
| + VStack(spacing: 0) { |
| + Text(text) |
| + .font(.system(size: 12)) |
| + .foregroundStyle(.secondary) |
| + .frame(maxWidth: .infinity, alignment: .leading) |
| + .padding(.horizontal, 16) |
| + .padding(.vertical, 8) |
| + Divider() |
| + } |
| + // A material, not a flat colour: the note sits over a scrolling |
| + // transcript, so it must stay legible whatever slides under it. |
| + .background(.bar) |
| + .accessibilityElement(children: .combine) |
| + .accessibilityLabel(Text(text)) |
| + } |
| + } |
| +} |
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift| index 179194c..e4f91cc 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| @@ -430,22 +430,21 @@ public struct MessageBubble: View { |
| // style.css .reply-quote: bordered, 14pt corners, muted name + one-line |
| // ellipsized snippet; tapping jumps to the quoted message. |
| // |
| - // A quoted reply that renders inline in the feed (a quote reply, and all |
| - // the history from before thread-only replies existed) has always opened |
| - // its WHOLE thread on tap — but nothing on the card said so, so the thread |
| - // was undiscoverable from there. When the thread holds more than this one |
| - // reply, the card wears the same link-coloured "N Replies" the root wears |
| - // (web: .reply-quote-thread) — inside the card, so the whole thing reads as |
| - // one way in rather than a quote with a badge stuck on. A lone quote-reply |
| - // is untouched. |
| + // Two verbs, two cards. A THREAD reply is only ever rendered inline |
| + // because its root is paged out, so its card is the way back into its |
| + // thread: when that thread holds more than this one reply the card wears |
| + // the same link-coloured "N Replies" the root wears (web: |
| + // .reply-quote-thread) — inside the card, so the whole thing reads as one |
| + // way in rather than a quote with a badge stuck on. |
| private func replyQuote(_ reply: ReplyRef) -> some View { |
| let threadLink = ChatFeedModel.threadLinkCount(message) |
| return Button { |
| - // A quote tap opens the whole thread, anchored on its resolved |
| - // ROOT (the quoted ref may name a mid-chain parent on a legacy |
| - // reply — threadRootID already chased the chain). Jump-to-parent |
| - // stays the belt for a container without a thread view. |
| - if let open = onOpenThread, let root = message.threadRootID ?? reply.id, root > 0 { |
| + // A THREAD reply's card opens its thread on the root it points at. |
| + // A PLAIN reply's card is a quote, and tapping a quote jumps to the |
| + // message it quotes — the behaviour that predates threads, and the |
| + // one a reader expects when nothing here said "thread". The jump is |
| + // also the belt for a container with no thread view. |
| + if let root = ChatFeedModel.threadRootID(of: message), let open = onOpenThread { |
| open(root) |
| } else if let id = reply.id, id > 0 { |
| onJump?(id) |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift| index 482037f..7db3466 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift |
| @@ -2,40 +2,57 @@ 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). |
| +// of EVERY sequence vector in test/event-vectors/chat.json that carries |
| +// `expected_threads` (today: `reply_thread_grouping` and |
| +// `inline_reply_chain_is_not_a_thread`), plus `thread_reply_root_unloaded`. |
| +// Web twin: test/event-vectors.test.mjs against chat.js's |
| +// viewItems/quoteTarget/threadTarget/threadItems. |
| // |
| -// 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. |
| +// The vector list is DERIVED, never hardcoded: a new vector with thread |
| +// expectations must be checked on this side too, and a hardcoded list would |
| +// let it pass on web while silently skipping natively. |
| +// |
| +// The contract: inline replies and threads are SEPARATE. A thread is its root |
| +// plus the replies FLAGGED into it (replyTo.thread, pointing straight at the |
| +// root) — nothing else. Plain quote replies, including a chain of them, stay |
| +// inline in the feed, belong to no thread, and move no tally. A thread exists |
| +// only because someone chose "Reply in Thread". |
| final class ReplyThreadVectorTests: XCTestCase { |
| typealias Message = ChatFeedModel.Message |
| - // The reply_thread_grouping vector's message_sequence, folded. |
| + // The reply_thread_grouping vector's message_sequence, folded. 101 and 103 |
| + // are ordinary quote replies to root 100; 102 quotes reply 101 (a chain of |
| + // inline quotes); only 104 was made IN the thread. |
| 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) |
| + func raw(_ name: String, _ text: String) -> MessagePayload { |
| + var p = MessagePayload() |
| + p.name = name |
| + p.text = text |
| + return p |
| + } |
| return [ |
| Message(id: 100, name: "Alice", text: "Hello everyone", mine: false, |
| - at: 1_700_000_000, edited: false, raw: alice), |
| + at: 1_700_000_000, edited: false, raw: raw("Alice", "Hello everyone")), |
| 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. |
| + reply: ReplyRef(id: 100, name: "Alice", snippet: "Hello everyone"), |
| + raw: raw("Bob", "Hi Alice")), |
| + // A chain of inline quotes: this one quotes reply 101, not the root. It |
| + // is NOT in 100's thread — it isn't in any thread. |
| 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")), |
| + reply: ReplyRef(id: 101, name: "Bob", snippet: "Hi Alice"), |
| + raw: raw("Charlie", "Welcome!")), |
| 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")), |
| - // Sent IN the thread (replyTo.thread): tallies like any reply, but the |
| - // main feed leaves it out — see testMainFeedVisibilityFromVector. |
| + reply: ReplyRef(id: 100, name: "Alice", snippet: "Hello everyone"), |
| + raw: raw("Dana", "Good to be here")), |
| + // Sent IN the thread (replyTo.thread): the only member of 100's thread — |
| + // it alone tallies, and the main feed leaves it out. |
| Message(id: 104, name: "Erin", text: "Replying from inside the thread", mine: false, |
| at: 1_700_000_004, edited: false, |
| - reply: ReplyRef(id: 100, name: "Alice", snippet: "Hello everyone", thread: true)), |
| + reply: ReplyRef(id: 100, name: "Alice", snippet: "Hello everyone", thread: true), |
| + raw: raw("Erin", "Replying from inside the thread")), |
| ] |
| } |
| @@ -43,35 +60,38 @@ final class ReplyThreadVectorTests: XCTestCase { |
| 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) |
| - XCTAssertEqual(list.first { $0.id == 104 }?.threadRootID, 100, "a thread-only reply groups like any other") |
| + // expected_threads.roots: only the flagged reply belongs to a thread. |
| + XCTAssertNil(list.first { $0.id == 101 }?.threadRootID, "a quote reply is in no thread") |
| + XCTAssertNil(list.first { $0.id == 102 }?.threadRootID, "a chain of quote replies is still no thread") |
| + XCTAssertNil(list.first { $0.id == 103 }?.threadRootID) |
| + XCTAssertEqual(list.first { $0.id == 104 }?.threadRootID, 100, "the flagged reply names its root directly") |
| 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, 4, "the root tallies its whole thread, thread-only replies included") |
| - XCTAssertEqual(list.first { $0.id == 100 }?.firstReplyID, 101) |
| - XCTAssertEqual(list.first { $0.id == 101 }?.replyCount, 0, "a mid-thread reply never tallies") |
| + // expected_threads.reply_counts + first_reply: the tally counts thread |
| + // replies ONLY — the three inline replies add nothing. |
| + XCTAssertEqual(list.first { $0.id == 100 }?.replyCount, 1, "the root tallies its one thread reply") |
| + XCTAssertEqual(list.first { $0.id == 100 }?.firstReplyID, 104) |
| + XCTAssertEqual(list.first { $0.id == 101 }?.replyCount, 0, "an inline reply quoted by another wears no tally") |
| } |
| // ---- the quote card's thread link (web twin: chat.js threadLinkCount) ---- |
| - // A reply that renders INLINE in the feed (a quote reply, and all the history |
| - // from before thread-only replies existed) has always opened its whole thread |
| - // on tap; its card now says so — but only when the thread holds more than |
| - // that one reply, and always with the SAME number the root's tally shows. |
| + // Only a THREAD reply's card ever links into a thread — and it only renders |
| + // in the feed at all when its root is paged out (thread_reply_root_unloaded). |
| + // A plain quote reply's card is a quote and a jump, nothing more. |
| - func testQuoteCardThreadLinkIsTheRootsOwnTally() { |
| + func testOnlyThreadRepliesWearAThreadLink() { |
| var list = vectorMessages() |
| ChatFeedModel.refreshReplyCounts(&list) |
| let root = list.first { $0.id == 100 }! |
| - for id: Int64 in [101, 102, 103, 104] { |
| + for id: Int64 in [101, 102, 103] { |
| let m = list.first { $0.id == id }! |
| - XCTAssertEqual(m.threadReplyCount, root.replyCount, |
| - "reply \(id) reads its root's tally — legacy chains and thread-only replies included") |
| - XCTAssertEqual(ChatFeedModel.threadLinkCount(m), 4, "…so its card links into the thread") |
| + XCTAssertEqual(m.threadReplyCount, 0, "inline reply \(id) is in no thread, so there is no size to read") |
| + XCTAssertEqual(ChatFeedModel.threadLinkCount(m), 0, "…and its card stays a plain quote") |
| } |
| + let inThread = list.first { $0.id == 104 }! |
| + XCTAssertEqual(inThread.threadReplyCount, root.replyCount, "a thread reply reads its root's tally") |
| + XCTAssertEqual(ChatFeedModel.threadLinkCount(inThread), 0, |
| + "a thread of one is not a conversation to go and see") |
| XCTAssertEqual(ChatFeedModel.threadLinkCount(root), 0, |
| "a root never wears one — it has the tally itself") |
| XCTAssertEqual(ChatFeedModel.threadLinkCount(Message(id: 1, name: "Al", text: "hi", mine: false, |
| @@ -79,23 +99,33 @@ final class ReplyThreadVectorTests: XCTestCase { |
| "and a plain message has no thread to link to") |
| } |
| - func testLoneQuoteReplyKeepsAPlainCardUntilASecondReplyLands() { |
| - let root = Message(id: 200, name: "Alice", text: "root", mine: false, at: 100, edited: false) |
| - let quote = Message(id: 201, name: "Bob", text: "quote reply", mine: false, at: 101, edited: false, |
| - reply: ReplyRef(id: 200, name: "Alice", snippet: "root")) |
| - var list = [root, quote] |
| - ChatFeedModel.refreshReplyCounts(&list) |
| - XCTAssertEqual(list[1].threadReplyCount, 1, "the thread holds exactly this one reply") |
| - XCTAssertEqual(ChatFeedModel.threadLinkCount(list[1]), 0, |
| - "one reply is not a conversation to go and see — the card stays a plain quote") |
| - |
| - // A second reply lands in the thread (hidden from the transcript): there is |
| - // something through the card now, so it becomes a thread link. |
| - list.append(Message(id: 202, name: "Cy", text: "in thread", mine: false, at: 102, edited: false, |
| - reply: ReplyRef(id: 200, name: "Alice", snippet: "root", thread: true))) |
| + func testAThreadReplyShownInlineKeepsItsWayBack() { |
| + // The one case a thread link renders in the transcript: the root is paged |
| + // out, so both replies degrade to inline bubbles and each card is the way |
| + // back into the thread they belong to. |
| + var list = [ |
| + Message(id: 51, name: "Cy", text: "in an old thread", mine: false, at: 101, edited: false, |
| + reply: ReplyRef(id: 7, name: "Al", snippet: "old root", thread: true)), |
| + Message(id: 52, name: "Al", text: "also in it", mine: false, at: 102, edited: false, |
| + reply: ReplyRef(id: 7, name: "Al", snippet: "old root", thread: true)), |
| + ] |
| ChatFeedModel.refreshReplyCounts(&list) |
| - XCTAssertEqual(ChatFeedModel.threadLinkCount(list[1]), 2) |
| - XCTAssertEqual(list[0].replyCount, 2, "the same number the root's own tally shows") |
| + XCTAssertEqual(ChatFeedModel.mainFeedItems(list).map(\.id), [51, 52], |
| + "no loaded root to carry the tally — both show inline") |
| + XCTAssertEqual(list.map { ChatFeedModel.threadLinkCount($0) }, [2, 2], |
| + "and each card reads the whole thread's size") |
| + } |
| + |
| + // The web twin is test/apps.test.mjs's "jumpReachNote" test, which pins the |
| + // same two sentences — a jump that can't land must read identically on |
| + // every platform, and must never be a silent no-op. |
| + func testJumpReachNoteAnswersEveryOutcome() { |
| + XCTAssertNil(ChatFeedModel.jumpReachNote(.found), "a landing has nothing to say") |
| + XCTAssertEqual(ChatFeedModel.jumpReachNote(.gone), "That message isn't available any more.") |
| + XCTAssertEqual(ChatFeedModel.jumpReachNote(.capped), |
| + "That message is further back than this jump reaches — keep scrolling up for it.") |
| + XCTAssertNotEqual(ChatFeedModel.jumpReachNote(.gone), ChatFeedModel.jumpReachNote(.capped), |
| + "'gone' is final; 'capped' means keep scrolling — never the same sentence") |
| } |
| func testReplyCountLabelPluralisesAndLocalises() { |
| @@ -107,52 +137,90 @@ final class ReplyThreadVectorTests: XCTestCase { |
| // ---- thread-only replies vs the main feed (expected_main_feed) ------------ |
| // Driven straight from test/event-vectors/chat.json so the Swift side runs |
| - // the SAME sequence the web suite runs. Strict: expected_main_feed must be |
| + // the SAME sequences the web suite runs. Strict: expected_main_feed must be |
| // present and name every message in the sequence — the keys are the |
| // contract, a lagging JSON fails here rather than skipping. |
| - func testMainFeedVisibilityFromVector() throws { |
| - let v = try ChatVectors.vector("reply_thread_grouping") |
| - let sequence = try XCTUnwrap(v["message_sequence"] as? [[String: Any]]) |
| - var list = try sequence.map(ChatVectors.message) |
| - ChatFeedModel.refreshReplyCounts(&list) |
| - |
| - // expected_threads, from the JSON this time (the hand-built twin above |
| - // pins the same numbers): roots, tallies, first reply. |
| - let threads = try XCTUnwrap(v["expected_threads"] as? [String: Any]) |
| - let roots = try XCTUnwrap(threads["roots"] as? [String: NSNumber]) |
| - for (sid, root) in roots { |
| - XCTAssertEqual(list.first { $0.id == Int64(sid) }?.threadRootID, root.int64Value, "root of \(sid)") |
| - } |
| - let counts = try XCTUnwrap(threads["reply_counts"] as? [String: NSNumber]) |
| - for (sid, n) in counts { |
| - XCTAssertEqual(list.first { $0.id == Int64(sid) }?.replyCount, n.intValue, "reply count of \(sid)") |
| + /// Every sequence vector that carries thread expectations, checked against |
| + /// the pure selectors: grouping, tallies, feed visibility, thread lists and |
| + /// the two compose verbs. `inline_reply_chain_is_not_a_thread` is the one |
| + /// that proves an inline chain produces NO thread and NO tally. |
| + /// |
| + /// The list comes from the JSON — every vector with `expected_threads` — so |
| + /// a vector added for the web can't quietly skip this side. |
| + func testSequenceVectorsPinTheThreadContract() throws { |
| + let all = try ChatVectors.all().filter { |
| + $0["expected_threads"] != nil && $0["message_sequence"] != nil |
| } |
| - let first = try XCTUnwrap(threads["first_reply"] as? [String: NSNumber]) |
| - for (sid, id) in first { |
| - XCTAssertEqual(list.first { $0.id == Int64(sid) }?.firstReplyID, id.int64Value, "first reply of \(sid)") |
| + XCTAssertFalse(all.isEmpty, "chat.json must carry sequence vectors with expected_threads") |
| + for v in all { |
| + let name = (v["name"] as? String) ?? "?" |
| + let sequence = try XCTUnwrap(v["message_sequence"] as? [[String: Any]], "\(name) message_sequence") |
| + var list = try sequence.map(ChatVectors.message) |
| + ChatFeedModel.refreshReplyCounts(&list) |
| + |
| + // expected_threads: which messages are in a thread (0 = none), the |
| + // tallies, and the first thread reply. |
| + let threads = try XCTUnwrap(v["expected_threads"] as? [String: Any], "\(name) expected_threads") |
| + let roots = try XCTUnwrap(threads["roots"] as? [String: NSNumber]) |
| + for (sid, root) in roots { |
| + // The web reads replyRoot, where "no thread" is 0; native leaves the |
| + // optional nil. Compare through the same zero so the JSON is one spec. |
| + XCTAssertEqual(list.first { $0.id == Int64(sid) }?.threadRootID ?? 0, root.int64Value, |
| + "\(name): thread root of \(sid)") |
| + } |
| + let counts = try XCTUnwrap(threads["reply_counts"] as? [String: NSNumber]) |
| + for (sid, n) in counts { |
| + XCTAssertEqual(list.first { $0.id == Int64(sid) }?.replyCount, n.intValue, |
| + "\(name): reply count of \(sid)") |
| + } |
| + let first = try XCTUnwrap(threads["first_reply"] as? [String: NSNumber]) |
| + for (sid, id) in first { |
| + XCTAssertEqual(list.first { $0.id == Int64(sid) }?.firstReplyID, id.int64Value, |
| + "\(name): first reply of \(sid)") |
| + } |
| + |
| + // expected_thread_items: the thread view's list, root first. |
| + let items = try XCTUnwrap(v["expected_thread_items"] as? [String: Any], "\(name) expected_thread_items") |
| + for (key, want) in items { |
| + guard let root = Int64(key), let ids = want as? [NSNumber] else { continue } // "note" isn't a root |
| + XCTAssertEqual(ChatFeedModel.threadItems(in: list, root: root).map(\.id), |
| + ids.map(\.int64Value), "\(name): threadItems(\(key))") |
| + } |
| + |
| + // expected_main_feed: every message's main-feed visibility, through the |
| + // pure filter — thread replies hidden, everything else (roots, inline |
| + // replies, inline chains) shown, in order. |
| + let feed = try XCTUnwrap(v["expected_main_feed"] as? [String: Bool], |
| + "\(name) must carry expected_main_feed") |
| + let visible = ChatFeedModel.mainFeedItems(list) |
| + for m in list { |
| + let want = try XCTUnwrap(feed[String(m.id)], "expected_main_feed must name message \(m.id)") |
| + XCTAssertEqual(visible.contains { $0.id == m.id }, want, "\(name): main-feed visibility of \(m.id)") |
| + } |
| + XCTAssertEqual(visible.map(\.id), list.filter { feed[String($0.id)] == true }.map(\.id), |
| + "\(name): the feed keeps server order") |
| + |
| + // expected_reply_targets: the two compose verbs, against the same state. |
| + let targets = try XCTUnwrap(v["expected_reply_targets"] as? [String: Any], |
| + "\(name) must carry expected_reply_targets") |
| + for (sid, want) in (targets["quote"] as? [String: [String: Any]]) ?? [:] { |
| + let m = try XCTUnwrap(list.first { $0.id == Int64(sid) }) |
| + assertRef(ChatFeedModel.quoteTarget(for: m), want, |
| + "\(name): quoteTarget(\(sid)) — plain Reply targets the exact message clicked") |
| + } |
| + for (sid, want) in (targets["thread"] as? [String: [String: Any]]) ?? [:] { |
| + let m = try XCTUnwrap(list.first { $0.id == Int64(sid) }) |
| + assertRef(ChatFeedModel.threadTarget(in: list, for: m), want, |
| + "\(name): threadTarget(\(sid)) — Reply in Thread targets the thread's root") |
| + } |
| } |
| + } |
| - // expected_main_feed: every message's main-feed visibility, through the |
| - // pure filter — thread-only replies hidden, everything else (roots, |
| - // inline replies, legacy chains) shown, in order. |
| - let feed = try XCTUnwrap(v["expected_main_feed"] as? [String: Bool], |
| - "reply_thread_grouping must carry expected_main_feed") |
| - let visible = ChatFeedModel.mainFeedItems(list) |
| - for m in list { |
| - let want = try XCTUnwrap(feed[String(m.id)], "expected_main_feed must name message \(m.id)") |
| - XCTAssertEqual(visible.contains { $0.id == m.id }, want, "main-feed visibility of \(m.id)") |
| - } |
| - XCTAssertEqual(visible.map(\.id), list.filter { feed[String($0.id)] == true }.map(\.id), |
| - "the feed keeps server order") |
| - |
| - // The thread view still shows the whole thread, hidden replies included. |
| - let hidden = list.filter { feed[String($0.id)] == false }.map(\.id) |
| - XCTAssertFalse(hidden.isEmpty, "the vector must carry at least one thread-only reply") |
| - let thread = ChatFeedModel.threadItems(in: list, root: 100).map(\.id) |
| - for id in hidden { |
| - XCTAssertTrue(thread.contains(id), "thread-only reply \(id) folds into its thread") |
| - } |
| + private func assertRef(_ got: ReplyRef, _ want: [String: Any], _ label: String) { |
| + XCTAssertEqual(got.id, (want["id"] as? NSNumber)?.int64Value, "\(label) — id") |
| + XCTAssertEqual(got.name, want["name"] as? String, "\(label) — name") |
| + XCTAssertEqual(got.snippet, want["snippet"] as? String, "\(label) — snippet") |
| } |
| func testThreadReplyWithUnloadedRootStaysInFeed() throws { |
| @@ -276,15 +344,14 @@ final class ReplyThreadVectorTests: XCTestCase { |
| // Root-aware: the same thread reply with its root NOT loaded stays inline. |
| XCTAssertEqual(ChatFeedModel.mainFeedItems([inThread, b, inline]).map(\.id), [2, 3, 4], |
| "no loaded root to carry the tally — the reply shows inline") |
| - // A legacy chain resolves to its root through a loaded mid-chain parent: |
| - // a flagged reply quoting a REPLY hides only when the resolved root is |
| - // loaded, not merely the quoted parent. |
| + // The root is the one the flag POINTS AT — no walking. A flagged reply |
| + // quoting an inline reply is hidden by THAT message being loaded. |
| let chained = Message(id: 5, name: "Di", text: "chain", mine: false, at: 5, edited: false, |
| reply: ReplyRef(id: 4, name: "Cy", snippet: "quote reply", thread: true)) |
| XCTAssertEqual(ChatFeedModel.mainFeedItems([a, inline, chained]).map(\.id), [1, 4], |
| - "resolved root 1 loaded — hidden") |
| - XCTAssertEqual(ChatFeedModel.mainFeedItems([inline, chained]).map(\.id), [4, 5], |
| - "quoted parent loaded but the resolved root 1 is not — shown") |
| + "its root 4 is loaded — hidden, tallying on 4") |
| + XCTAssertEqual(ChatFeedModel.mainFeedItems([a, chained]).map(\.id), [1, 5], |
| + "root 4 not loaded — shown inline, whatever else is") |
| } |
| #if DEBUG |
| @@ -304,71 +371,124 @@ final class ReplyThreadVectorTests: XCTestCase { |
| XCTAssertEqual(model.messages.map(\.id), [100, 101, 102, 103, 104]) |
| XCTAssertEqual(model.feedMessages.map(\.id), [100, 101, 102, 103], |
| "the feed list is the fold minus thread-only replies") |
| - XCTAssertEqual(model.feedMessages.first { $0.id == 100 }?.replyCount, 4, |
| - "the root's tally still counts the hidden reply") |
| + XCTAssertEqual(model.feedMessages.first { $0.id == 100 }?.replyCount, 1, |
| + "the root's tally counts the hidden thread reply — and only it") |
| } |
| #endif |
| - func testReplyTargetStagesRoot() { |
| + // ---- the two compose verbs, hand-built (the JSON twin is above) ---------- |
| + |
| + func testQuoteTargetIsAlwaysTheExactMessage() { |
| 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") |
| + // Plain Reply on a chain reply quotes THAT reply — no root walk. This is |
| + // the change: it used to stage root 100 and silently join a thread. |
| + let onChain = ChatFeedModel.quoteTarget(for: list.first { $0.id == 102 }!) |
| + XCTAssertEqual(onChain.id, 102) |
| + XCTAssertEqual(onChain.name, "Charlie") |
| + XCTAssertEqual(onChain.snippet, "Welcome!") |
| - // 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) |
| + // Even inside a thread the plain verb quotes the bubble it was tapped on. |
| + XCTAssertEqual(ChatFeedModel.quoteTarget(for: list.first { $0.id == 104 }!).id, 104) |
| - // 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 }!) |
| + // A non-reply quotes itself, as it always did. |
| + let fresh = ChatFeedModel.quoteTarget(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] |
| + func testThreadTargetContinuesAThreadOrStartsOne() { |
| + let list = vectorMessages() |
| + |
| + // Already in a thread → its ROOT, snapshotted fresh. |
| + let inThread = ChatFeedModel.threadTarget(in: list, for: list.first { $0.id == 104 }!) |
| + XCTAssertEqual(inThread.id, 100) |
| + XCTAssertEqual(inThread.name, "Alice") |
| + XCTAssertEqual(inThread.snippet, "Hello everyone") |
| + |
| + // In no thread → the message itself becomes the new thread's root, whether |
| + // it's a plain message, a quote reply, or the deep end of a quote chain. |
| + for id: Int64 in [100, 101, 102, 103] { |
| + XCTAssertEqual(ChatFeedModel.threadTarget(in: list, for: list.first { $0.id == id }!).id, id, |
| + "\(id) is in no thread, so Reply in Thread roots a new one on it") |
| + } |
| + } |
| + |
| + func testUnloadedThreadRootFallsBackToQuotedRef() { |
| + // The root scrolled out of the loaded window: keep the frozen snapshot the |
| + // reply carries, so a reply staged from here still lands in the thread. |
| + var list = [ |
| + 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", thread: true)), |
| + ] |
| ChatFeedModel.refreshReplyCounts(&list) |
| - XCTAssertEqual(list[0].threadRootID, 42, "an unloaded parent is still the best-known root") |
| + XCTAssertEqual(list[0].threadRootID, 42, "an unloaded root is still the thread it points at") |
| - let target = ChatFeedModel.replyTarget(in: list, for: list[0]) |
| + let target = ChatFeedModel.threadTarget(in: list, for: list[0]) |
| XCTAssertEqual(target.id, 42) |
| XCTAssertEqual(target.name, "Old Root") |
| XCTAssertEqual(target.snippet, "long ago") |
| + |
| + // The SAME message with no flag is in no thread at all: Reply in Thread |
| + // roots one on it, and it never tallies on 42. |
| + var inline = [list[0]] |
| + inline[0].reply = ReplyRef(id: 42, name: "Old Root", snippet: "long ago") |
| + ChatFeedModel.refreshReplyCounts(&inline) |
| + XCTAssertNil(inline[0].threadRootID, "an inline reply belongs to no thread, loaded root or not") |
| + XCTAssertEqual(ChatFeedModel.threadTarget(in: inline, for: inline[0]).id, 200) |
| } |
| // threadItems — the thread view's selector; web twin: test/apps.test.mjs |
| - // "threadItems selects a root's whole thread — legacy chains resolve into it". |
| + // "threadItems selects a root's thread — the flagged replies and nothing else". |
| private func threadItemsFixture() -> [Message] { |
| [ |
| Message(id: 1, name: "Al", text: "root", mine: false, at: 1, edited: false), |
| Message(id: 2, name: "Bo", text: "aside", mine: false, at: 2, edited: false), |
| Message(id: 3, name: "Cy", text: "re", mine: false, at: 3, edited: false, |
| - reply: ReplyRef(id: 1, name: "Al", snippet: "root")), |
| - // A legacy chain: this reply quotes the REPLY (3), not the root — it |
| - // must still resolve transitively into 1's thread. |
| + reply: ReplyRef(id: 1, name: "Al", snippet: "root", thread: true)), |
| Message(id: 4, name: "Al", text: "re-re", mine: false, at: 4, edited: false, |
| - reply: ReplyRef(id: 3, name: "Cy", snippet: "re")), |
| + reply: ReplyRef(id: 1, name: "Al", snippet: "root", thread: true)), |
| Message(id: 5, name: "Bo", text: "other thread", mine: false, at: 5, edited: false, |
| - reply: ReplyRef(id: 2, name: "Bo", snippet: "aside")), |
| + reply: ReplyRef(id: 2, name: "Bo", snippet: "aside", thread: true)), |
| + // A plain quote reply to the SAME root: it stacks inline in the feed and |
| + // is not in the thread — nobody chose to put it there. |
| + Message(id: 6, name: "Di", text: "inline quote", mine: false, at: 6, edited: false, |
| + reply: ReplyRef(id: 1, name: "Al", snippet: "root")), |
| ] |
| } |
| - func testThreadItemsSelectsWholeThread() { |
| + func testThreadItemsSelectsTheFlaggedRepliesOnly() { |
| let msgs = threadItemsFixture() |
| XCTAssertEqual(ChatFeedModel.threadItems(in: msgs, root: 1).map(\.id), [1, 3, 4], |
| - "root first, then its thread in server order; the reply-to-a-reply resolves in; asides and other threads stay out") |
| + "root first, then its thread in server order; asides, other threads and the inline quote reply stay out") |
| XCTAssertEqual(ChatFeedModel.threadItems(in: msgs, root: 2).map(\.id), [2, 5], |
| "the other thread folds on its own root") |
| + XCTAssertEqual(ChatFeedModel.threadItems(in: msgs, root: 0).map(\.id), [], |
| + "id 0 is never a thread root") |
| + } |
| + |
| + func testAnInlineReplyChainIsNotAThread() { |
| + // The regression the two-verb split exists to prevent: replying to a reply |
| + // used to root-resolve and silently join (or invent) a thread. |
| + var chain = [ |
| + Message(id: 1, name: "Al", text: "root", mine: false, at: 1, edited: false), |
| + Message(id: 2, name: "Bo", text: "re", mine: false, at: 2, edited: false, |
| + reply: ReplyRef(id: 1, name: "Al", snippet: "root")), |
| + Message(id: 3, name: "Cy", text: "re-re", mine: false, at: 3, edited: false, |
| + reply: ReplyRef(id: 2, name: "Bo", snippet: "re")), |
| + ] |
| + ChatFeedModel.refreshReplyCounts(&chain) |
| + XCTAssertEqual(ChatFeedModel.threadItems(in: chain, root: 1).map(\.id), [1], |
| + "the root's thread is just the root — the chain isn't in it") |
| + XCTAssertEqual(ChatFeedModel.threadItems(in: chain, root: 2).map(\.id), [2], |
| + "and the middle reply has no thread of its own either") |
| + XCTAssertEqual(ChatFeedModel.mainFeedItems(chain).map(\.id), [1, 2, 3], |
| + "all three stack inline in the feed") |
| + XCTAssertEqual(chain.map(\.replyCount), [0, 0, 0], "and NOTHING wears a tally") |
| + XCTAssertEqual(chain.map { ChatFeedModel.threadLinkCount($0) }, [0, 0, 0], |
| + "no quote card doubles as a thread link") |
| } |
| func testThreadItemsSortPendingCopiesLast() { |
| @@ -399,7 +519,7 @@ final class ReplyThreadVectorTests: XCTestCase { |
| // replies we can see (the view renders the quoted snapshot as its header). |
| let msgs = Array(threadItemsFixture().dropFirst(2)) |
| XCTAssertEqual(ChatFeedModel.threadItems(in: msgs, root: 1).map(\.id), [3, 4], |
| - "unloaded root — replies only, chain intact") |
| + "unloaded root — replies only") |
| XCTAssertEqual(ChatFeedModel.threadItems(in: threadItemsFixture(), root: 99).map(\.id), [], |
| "an unknown root has no thread") |
| } |
| @@ -418,8 +538,8 @@ final class ReplyThreadVectorTests: XCTestCase { |
| XCTAssertEqual(staged.name, "Al") |
| XCTAssertEqual(staged.snippet, "root") |
| - // Unloaded root: the deepest quoted ref a loaded thread member carries |
| - // stands in (the web's threadFallback via replyTarget). |
| + // Unloaded root: the quoted snapshot a loaded thread member carries stands |
| + // in (the web's threadFallback via threadTarget). |
| let partial = Array(msgs.dropFirst(2)) |
| let fallback = ChatFeedModel.threadReplyRef(in: partial, root: 1) |
| XCTAssertEqual(fallback.id, 1) |
| @@ -432,15 +552,19 @@ final class ReplyThreadVectorTests: XCTestCase { |
| XCTAssertEqual(bare.id, 7) |
| } |
| - func testReplyCycleIsHopCapped() { |
| - // A hostile payload could quote in a cycle — the walk must terminate. |
| + func testHostileReplyPayloadsTerminate() { |
| + // Two flagged replies quoting each other: with membership read straight |
| + // off the flag there is no walk to cycle, but the rollup must still be |
| + // sane — each is in the OTHER's "thread", neither is its own root. |
| var list = [ |
| Message(id: 1, name: "A", text: "a", mine: false, at: 1, edited: false, |
| - reply: ReplyRef(id: 2, name: "B", snippet: "b")), |
| + reply: ReplyRef(id: 2, name: "B", snippet: "b", thread: true)), |
| Message(id: 2, name: "B", text: "b", mine: false, at: 2, edited: false, |
| - reply: ReplyRef(id: 1, name: "A", snippet: "a")), |
| + reply: ReplyRef(id: 1, name: "A", snippet: "a", thread: true)), |
| ] |
| ChatFeedModel.refreshReplyCounts(&list) // must not hang |
| - XCTAssertNotNil(list[0].threadRootID) |
| + XCTAssertEqual(list[0].threadRootID, 2) |
| + XCTAssertEqual(list[1].threadRootID, 1) |
| + XCTAssertEqual(list.map(\.replyCount), [1, 1]) |
| } |
| } |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index 4577bfe..4d9d8b9 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -234,6 +234,13 @@ struct NativeFeedView: View { |
| @State private var routeNotice: String? |
| @State private var routeNoticeGen = 0 |
| @State private var dcRouting = false |
| + // The feed's inline status line (FeedNoteBar, above the transcript): a |
| + // jump that has to page history back for its target says so, and says why |
| + // when it can't reach it. `revealing` is the target of the walk in flight |
| + // — one at a time, so a second tap can't stack walks. |
| + @State private var feedNote: String? |
| + @State private var feedNoteGen = 0 |
| + @State private var revealing: Int64? |
| // The thread's COMMAND HOST — the hidden per-thread webview that runs |
| // mini-app slash commands (/emoji) and shows their panels; the panel |
| // reveals the webview above the composer (see CommandPanelWebView.swift). |
| @@ -884,6 +891,11 @@ struct NativeFeedView: View { |
| composer |
| } |
| } |
| + // The feed's inline status line — in flow above the transcript, never |
| + // floating (see FeedNoteBar). Nothing to say renders nothing. |
| + .safeAreaInset(edge: .top, spacing: 0) { |
| + FeedNoteBar(feedNote) |
| + } |
| // Tamper-evidence failure banner (docs/tamper-evidence.md) — only when |
| // this space fails an integrity check; "What's this?" fills the report |
| // alert below. |
| @@ -2006,8 +2018,8 @@ struct NativeFeedView: View { |
| // — it lives in the thread, never as a main-feed bubble; Slack's model) |
| // — no command routing, no default-command detour (matching the web pane |
| // composer). The reply target derives FRESH here (ChatFeedModel |
| - // .replyTarget semantics): the root may have paged in, or the chain's |
| - // deepest snapshot improved, since the pane opened. |
| + // .threadReplyRef): the root may have paged in since the pane opened, and |
| + // a loaded root snapshots better than the frozen quote. |
| private func sendPaneDraft() { |
| // An edit replaces the message in place — same rule as the channel bar. |
| if let id = paneEditingID { |
| @@ -2173,7 +2185,7 @@ struct NativeFeedView: View { |
| // way, so a search hit or media-sheet jump never dead-ends on "the |
| // newest reply" instead of the one asked for. |
| private func jumpIntoThread(_ target: ChatFeedModel.Message) { |
| - guard let root = target.threadRootID ?? target.reply?.id, root > 0 else { return } |
| + guard let root = ChatFeedModel.threadRootID(of: target) else { return } |
| openThread(root) |
| threadJump = target.id |
| threadJumpTick += 1 |
| @@ -2183,14 +2195,15 @@ struct NativeFeedView: View { |
| } |
| } |
| - // "Reply in Thread" (thread-pane spec §3.5; web openReplyThread): open |
| - // the message's thread anchored on its resolved ROOT — replyTarget |
| - // semantics, so replying to a reply joins the existing thread rather than |
| - // forking one on the reply — with the caret in the pane's bar (openThread |
| - // claims that focus). The thread already open on that root: just the |
| - // caret, the same as Reply on one of the pane's own bubbles. |
| + // "Reply in Thread" (thread-pane spec §3.5; web openReplyThread): open the |
| + // message's thread — threadTarget semantics, so a message already IN a |
| + // thread continues it while anything else roots a NEW thread on itself — |
| + // with the caret in the pane's bar (openThread claims that focus). The |
| + // thread already open on that root: just the caret, the same as Reply on |
| + // one of the pane's own bubbles. This verb is the ONLY way a thread ever |
| + // begins; nothing is absorbed into one by replying. |
| private func replyInThread(_ m: ChatFeedModel.Message) { |
| - guard let rootID = model.replyTarget(for: m).id, rootID > 0 else { return } |
| + guard let rootID = model.threadTarget(for: m).id, rootID > 0 else { return } |
| if threadRootID == rootID { |
| if threadDocked { paneFocused = true } else { stageThreadReply() } |
| } else { |
| @@ -2273,8 +2286,23 @@ struct NativeFeedView: View { |
| private func startReply(_ m: ChatFeedModel.Message) { |
| editingID = nil |
| - // Replying to a reply continues its thread: target the TOP message. |
| - replyTo = model.replyTarget(for: m) |
| + // Belt for the parked overlay path. Mac v1 always docks |
| + // (`threadDocked` is a constant true), and docked pane bubbles route |
| + // to the pane's own bar (paneMessageActions), so this branch is |
| + // unreachable today — it exists so the overlay layout, if it's ever |
| + // switched back on here, can't regress the way it would without it: |
| + // under the overlay the channel bar IS the open thread's reply box, so |
| + // a Reply on one of the view's bubbles must stage the root WITH the |
| + // thread flag rather than quote that bubble into the transcript. |
| + if let rootID = threadRootID, !threadDocked, model.threadTarget(for: m).id == rootID { |
| + stageThreadReply() |
| + return |
| + } |
| + // Plain Reply quotes the EXACT message tapped — never a root walk. |
| + // Replying to a reply stacks another quote in the feed; joining a |
| + // thread is the other verb's job ("Reply in Thread"), and only that |
| + // verb's. |
| + replyTo = model.quoteTarget(for: m) |
| composerFocused = true |
| } |
| @@ -2508,27 +2536,25 @@ struct NativeFeedView: View { |
| } |
| } |
| - // A pending open-on-message request (search): once this feed is the |
| - // target and ready, page back until the message is loaded, then wave. |
| + // A pending open-on-message request (search): once this feed is the target |
| + // and ready, land on the message — jump() pages back for it through |
| + // reveal() when it isn't loaded, so a search hit that can't be reached is |
| + // explained inline like every other jump instead of doing nothing. (This |
| + // used to carry its own hand-rolled walk ending in a bare `return`.) |
| private func consumeJump(_ proxy: ScrollViewProxy) { |
| guard let j = app.nativeJump, j.thread == threadID, model.ready else { return } |
| app.nativeJump = nil |
| - Task { |
| - var tries = 0 |
| - while !model.messages.contains(where: { $0.id == j.message }), |
| - !model.exhausted, tries < 8 { |
| - tries += 1 |
| - await model.loadOlder() |
| - try? await Task.sleep(nanoseconds: 550_000_000) // loadOlder's cooldown |
| - } |
| - guard model.messages.contains(where: { $0.id == j.message }) else { return } |
| - try? await Task.sleep(nanoseconds: 150_000_000) // let rows land |
| - jump(to: j.message, proxy: proxy) |
| - } |
| + jump(to: j.message, proxy: proxy) |
| } |
| private func jump(to id: Int64, proxy: ScrollViewProxy) { |
| - guard let target = model.messages.first(where: { $0.id == id }) else { return } |
| + guard let target = model.messages.first(where: { $0.id == id }) else { |
| + // Not loaded at all — older than the page we hold. Page back for |
| + // it rather than doing nothing: this is the ordinary case for the |
| + // quote card above a plain reply on a cold-opened chat. |
| + reveal(id, proxy: proxy) |
| + return |
| + } |
| // Loaded but not in the transcript = a thread-only reply whose root |
| // is loaded (mainFeedItems): its bubble is in the pane, so land |
| // there. (With the root unloaded it degrades to an inline bubble and |
| @@ -2554,6 +2580,46 @@ struct NativeFeedView: View { |
| } |
| } |
| + // Page history back for a jump target that isn't loaded, then land on it — |
| + // the web's revealMessage. Never a silent no-op: the wait says so inline, |
| + // and a walk that can't reach the message says which kind of can't |
| + // (deleted vs. further back than one jump goes). One walk at a time. |
| + // |
| + // Cannot recurse: model.pageBack returning .found means the message IS in |
| + // model.messages, so the jump below takes its loaded path and can't come |
| + // back here. |
| + private func reveal(_ id: Int64, proxy: ScrollViewProxy) { |
| + guard revealing == nil, id > 0 else { return } |
| + revealing = id |
| + showFeedNote("Looking for that message…", sticky: true) // the outcome replaces it |
| + Task { |
| + let reach = await model.pageBack(to: id) |
| + revealing = nil |
| + showFeedNote(ChatFeedModel.jumpReachNote(reach)) |
| + guard reach == .found, model.messages.contains(where: { $0.id == id }) else { return } |
| + try? await Task.sleep(nanoseconds: 150_000_000) // let the prepended rows land |
| + jump(to: id, proxy: proxy) |
| + } |
| + } |
| + |
| + // The note answers something the reader just did, so it clears itself |
| + // rather than going stale behind a thread switch — the transient-status |
| + // convention busyText/routeNotice already use here. A note that's still |
| + // true while work is in flight ("Looking for that message…") is `sticky`: |
| + // it's replaced by the outcome, never timed out from under a walk that |
| + // runs long. The generation counter keeps every clear cancel-safe, like |
| + // routeNoticeGen. |
| + private static let feedNoteSeconds: TimeInterval = 6 |
| + private func showFeedNote(_ text: String?, sticky: Bool = false) { |
| + feedNote = text |
| + feedNoteGen += 1 |
| + guard text != nil, !sticky else { return } |
| + let gen = feedNoteGen |
| + DispatchQueue.main.asyncAfter(deadline: .now() + Self.feedNoteSeconds) { |
| + if feedNoteGen == gen { feedNote = nil } |
| + } |
| + } |
| + |
| // Download an attachment: fetch + decrypt, save straight to ~/Downloads |
| // (unique name, Finder reveal) — the same Safari-like convention the |
| // webview's WKDownload path uses. Writing plaintext is fine here: it's the |
diff --git a/test/apps.test.mjs b/test/apps.test.mjs| index 5d3abcb..fd30748 100644 |
| --- a/test/apps.test.mjs |
| +++ b/test/apps.test.mjs |
| @@ -90,7 +90,7 @@ import { app as dnsimpleApp } from "../web/static/apps/dnsimple.js"; // register |
| import { app as setupApp } from "../web/static/apps/setup.js"; // the first-run setup conversation's app |
| import { app as welcomeApp } from "../web/static/apps/welcome.js"; // the joiner welcome conversation's app |
| import { app as adminApp } from "../web/static/apps/admin.js"; // the admin panel (registers /admin) |
| -import { app as chatApp, viewItems, threadItems, inMainFeed, bySidIndex, newlyDirtyMentionSids, threadLinkCount, replyCountLabel } from "../web/static/apps/chat.js"; // the chat app |
| +import { app as chatApp, viewItems, threadItems, inMainFeed, bySidIndex, newlyDirtyMentionSids, threadLinkCount, replyCountLabel, quoteTarget, threadTarget, jumpReachNote, pageResult, feedNearBottom, feedGlideToBottom, feedCancelGlide } from "../web/static/apps/chat.js"; // the chat app |
| import { app as vaultApp } from "../web/static/apps/vault.js"; // the Vault app (registers /vault) |
| import { app as emojiApp, searchEmoji } from "../web/static/apps/emoji.js"; // registers /emoji (the command-panel reference app) |
| import { app as gifApp, pickRendition, renditionCandidates, gifItems, GIF_MAX_BYTES } from "../web/static/apps/gif.js"; // registers /gif |
| @@ -1128,15 +1128,20 @@ await test("chat: folds original-shaped messages (text, attachments, replies), d |
| s = reduce(s, { type: "chat.msg", text: "yes!", replyTo: { id: 10, name: "Al", snippet: "hi" } }, { id: 12 }); |
| // thread:false — the flag absent on the wire is an inline quote reply (inMainFeed). |
| assert.deepEqual(s.msgs[2].reply, { id: 10, name: "Al", snippet: "hi", thread: false }); |
| - // The parent's viewItems row tallies it. The tally rides the SOFT rev (srev), |
| - // so it patches the reply-count in place — it must NOT be in the hard vrev, |
| - // which would rebuild the whole bubble (and reload a photo). |
| - const v = viewItems(s.msgs); |
| - assert.equal(v[0].replyCount, 1, "parent shows one reply"); |
| + // A plain quote reply tallies NOTHING — it's already visible in the feed. |
| + // Only a thread reply (replyTo.thread) moves a root's "N Replies". |
| + let v = viewItems(s.msgs); |
| + assert.equal(v[0].replyCount, 0, "an inline quote reply leaves its parent tally-free"); |
| + s = reduce(s, { type: "chat.msg", text: "in thread", replyTo: { id: 10, name: "Al", snippet: "hi", thread: true } }, { id: 14 }); |
| + // The tally rides the SOFT rev (srev), so it patches the reply-count in |
| + // place — it must NOT be in the hard vrev, which would rebuild the whole |
| + // bubble (and reload a photo). |
| + v = viewItems(s.msgs); |
| + assert.equal(v[0].replyCount, 1, "the thread reply is what the parent counts"); |
| assert.ok(v[0].srev.endsWith("|1"), "the tally rides the soft rev (patched in place)"); |
| assert.equal(v[0].vrev, "0|D|0", "the tally is NOT in the hard rev — no bubble rebuild"); |
| s = reduce(s, { type: "chat.msg", text: " " }, { id: 13 }); // nothing to say |
| - assert.equal(s.msgs.length, 3); |
| + assert.equal(s.msgs.length, 4); |
| }); |
| await test("chat: a scroll-back page folded later still sorts before newer messages", () => { |
| @@ -1190,27 +1195,66 @@ await test("chat: viewItems groups runs by uid — a rename doesn't split, namel |
| assert.equal(v[3].cont, false, "different uids never group, even both nameless"); |
| }); |
| -await test("chat: threadItems selects a root's whole thread — legacy chains resolve into it", () => { |
| +await test("chat: threadItems selects a root's thread — the flagged replies and nothing else", () => { |
| const msgs = [ |
| { id: "m1", sid: 1, name: "Al", text: "root", ts: 1, reply: null, rev: 0 }, |
| { id: "m2", sid: 2, name: "Bo", text: "aside", ts: 2, reply: null, rev: 0 }, |
| - { id: "m3", sid: 3, name: "Cy", text: "re", ts: 3, reply: { id: 1, name: "Al", snippet: "root" }, rev: 0 }, |
| - // A legacy chain: this reply quotes the REPLY (m3), not the root — it must |
| - // still resolve transitively into m1's thread. |
| - { id: "m4", sid: 4, name: "Al", text: "re-re", ts: 4, reply: { id: 3, name: "Cy", snippet: "re" }, rev: 0 }, |
| - { id: "m5", sid: 5, name: "Bo", text: "other thread", ts: 5, reply: { id: 2, name: "Bo", snippet: "aside" }, rev: 0 }, |
| + { id: "m3", sid: 3, name: "Cy", text: "re", ts: 3, reply: { id: 1, name: "Al", snippet: "root", thread: true }, rev: 0 }, |
| + { id: "m4", sid: 4, name: "Al", text: "re-re", ts: 4, reply: { id: 1, name: "Al", snippet: "root", thread: true }, rev: 0 }, |
| + { id: "m5", sid: 5, name: "Bo", text: "other thread", ts: 5, reply: { id: 2, name: "Bo", snippet: "aside", thread: true }, rev: 0 }, |
| + // A plain quote reply to the SAME root: it stacks inline in the feed and |
| + // is not in the thread — nobody chose to put it there. |
| + { id: "m6", sid: 6, name: "Di", text: "inline quote", ts: 6, reply: { id: 1, name: "Al", snippet: "root" }, rev: 0 }, |
| ]; |
| assert.deepEqual( |
| threadItems(msgs, 1).map((m) => m.sid), |
| [1, 3, 4], |
| - "root first, then its thread in server order; the reply-to-a-reply resolves in; asides and other threads stay out", |
| + "root first, then its thread in server order; asides, other threads and the inline quote reply stay out", |
| ); |
| assert.deepEqual(threadItems(msgs, 2).map((m) => m.sid), [2, 5], "the other thread folds on its own root"); |
| // Root scrolled out of the loaded window: the thread still folds from the |
| - // replies we can see (the view renders the quoted snapshot as its header — |
| - // the chain resolves to the same unloaded sid either way). |
| - assert.deepEqual(threadItems(msgs.slice(2), 1).map((m) => m.sid), [3, 4], "unloaded root — replies only, chain intact"); |
| + // replies we can see (the view renders the quoted snapshot as its header). |
| + assert.deepEqual(threadItems(msgs.slice(2), 1).map((m) => m.sid), [3, 4], "unloaded root — replies only"); |
| assert.deepEqual(threadItems(msgs, 99), [], "an unknown root has no thread"); |
| + assert.deepEqual(threadItems(msgs, 0), [], "sid 0 is the pending-copy key, never a thread root"); |
| +}); |
| + |
| +await test("chat: an inline reply chain is NOT a thread — no absorption, no tally", () => { |
| + // The regression the two-verb split exists to prevent: replying to a reply |
| + // used to root-resolve and silently join (or invent) a thread. |
| + const msgs = [ |
| + { id: "m1", sid: 1, name: "Al", text: "root", ts: 1, reply: null, rev: 0 }, |
| + { id: "m2", sid: 2, name: "Bo", text: "re", ts: 2, reply: { id: 1, name: "Al", snippet: "root" }, rev: 0 }, |
| + { id: "m3", sid: 3, name: "Cy", text: "re-re", ts: 3, reply: { id: 2, name: "Bo", snippet: "re" }, rev: 0 }, |
| + ]; |
| + assert.deepEqual(threadItems(msgs, 1).map((m) => m.sid), [1], "the root's thread is just the root — the chain isn't in it"); |
| + assert.deepEqual(threadItems(msgs, 2).map((m) => m.sid), [2], "and the middle reply has no thread of its own either"); |
| + const v = viewItems(msgs, { mainFeed: true }); |
| + assert.deepEqual(v.map((m) => m.sid), [1, 2, 3], "all three stack inline in the feed"); |
| + assert.deepEqual(v.map((m) => m.replyCount), [0, 0, 0], "and NOTHING wears a tally"); |
| + assert.deepEqual(v.map((m) => m.replyRoot), [0, 0, 0], "no message belongs to a thread"); |
| + assert.deepEqual(v.map((m) => threadLinkCount(m)), [0, 0, 0], "no quote card doubles as a thread link"); |
| +}); |
| + |
| +await test("chat: quoteTarget vs threadTarget — the two reply verbs", () => { |
| + const msgs = [ |
| + { id: "m1", sid: 1, name: "Al", text: "root", ts: 1, reply: null, raw: { text: "root" }, rev: 0 }, |
| + { id: "m2", sid: 2, name: "Bo", text: "inline", ts: 2, reply: { id: 1, name: "Al", snippet: "root" }, raw: { text: "inline" }, rev: 0 }, |
| + { id: "m3", sid: 3, name: "Cy", text: "in thread", ts: 3, reply: { id: 1, name: "Al", snippet: "root", thread: true }, raw: { text: "in thread" }, rev: 0 }, |
| + ]; |
| + // Plain Reply: the EXACT bubble, every time — no msgs needed, nothing walked. |
| + assert.deepEqual(quoteTarget(msgs[0]), { id: 1, name: "Al", snippet: "root" }); |
| + assert.deepEqual(quoteTarget(msgs[1]), { id: 2, name: "Bo", snippet: "inline" }, "replying to a reply quotes THAT reply, not its parent"); |
| + assert.deepEqual(quoteTarget(msgs[2]), { id: 3, name: "Cy", snippet: "in thread" }, "even inside a thread the plain verb quotes the bubble"); |
| + // Reply in thread: the root of the thread it's already in, else itself. |
| + assert.deepEqual(threadTarget(msgs, msgs[0]), { id: 1, name: "Al", snippet: "root" }, "a plain message roots its own new thread"); |
| + assert.deepEqual(threadTarget(msgs, msgs[1]), { id: 2, name: "Bo", snippet: "inline" }, "an inline reply is in no thread — it roots one on itself"); |
| + assert.deepEqual(threadTarget(msgs, msgs[2]), { id: 1, name: "Al", snippet: "root" }, "a thread reply continues its thread: the ROOT, snapshotted fresh"); |
| + // Root paged out: keep the frozen snapshot the reply carries, so the reply |
| + // still lands in the same thread. |
| + assert.deepEqual(threadTarget(msgs.slice(2), msgs[2]), { id: 1, name: "Al", snippet: "root" }, "unloaded root — the quoted snapshot stands in"); |
| + // Flagged but ref-less (a thread reply mid-send): it can only quote itself. |
| + assert.deepEqual(threadTarget(msgs, { sid: 9, name: "Di", raw: { text: "x" }, reply: { id: 0, thread: true } }), { id: 9, name: "Di", snippet: "x" }); |
| }); |
| await test("chat: threadItems keeps the root FIRST while an optimistic reply is still pending (the thread-view blink)", () => { |
| @@ -1220,7 +1264,7 @@ await test("chat: threadItems keeps the root FIRST while an optimistic reply is |
| // root listed as a body bubble, then all snapped back: the blink. |
| const msgs = [ |
| { id: "m1", sid: 1, name: "Al", text: "root", ts: 1, reply: null, rev: 0 }, |
| - { id: "m3", sid: 3, name: "Cy", text: "re", ts: 3, reply: { id: 1, name: "Al", snippet: "root" }, rev: 0 }, |
| + { id: "m3", sid: 3, name: "Cy", text: "re", ts: 3, reply: { id: 1, name: "Al", snippet: "root", thread: true }, rev: 0 }, |
| { id: "cabc", sid: 0, cid: "abc", pending: true, name: "Me", text: "sending…", ts: 9, reply: { id: 1, name: "Al", snippet: "root", thread: true }, rev: 0 }, |
| ]; |
| const items = threadItems(msgs, 1); |
| @@ -1239,11 +1283,12 @@ await test("chat: inMainFeed — a reply made in a thread lives only there (whil |
| // Root-aware: with the root paged out there is no bubble to carry a tally, |
| // so hiding would render the reply NOWHERE — it degrades to an inline bubble. |
| assert.equal(inMainFeed({ sid: 4, reply: { id: 1, name: "Al", snippet: "hi", thread: true } }, noRoot), true, "root not loaded — the thread reply shows inline until the root pages in"); |
| - // The root is the RESOLVED one: a flagged reply chained off a loaded reply |
| - // whose own root is paged out is still rootless here. |
| + // The root is the one the flag POINTS AT — no walking. A thread reply |
| + // pointing at a loaded message is hidden by it; pointing at an unloaded one |
| + // it shows, whatever else happens to be loaded. |
| const mid = { sid: 3, name: "Cy", text: "re", reply: { id: 1, name: "Al", snippet: "hi" } }; |
| - assert.equal(inMainFeed({ sid: 5, reply: { id: 3, name: "Cy", snippet: "re", thread: true } }, bySidIndex([mid])), true, "chain parent loaded but the root isn't — shown"); |
| - assert.equal(inMainFeed({ sid: 5, reply: { id: 3, name: "Cy", snippet: "re", thread: true } }, bySidIndex([root, mid])), false, "chain resolves to a loaded root — hidden"); |
| + assert.equal(inMainFeed({ sid: 5, reply: { id: 3, name: "Cy", snippet: "re", thread: true } }, bySidIndex([mid])), false, "its root (3) is loaded — hidden, tallying on 3"); |
| + assert.equal(inMainFeed({ sid: 5, reply: { id: 3, name: "Cy", snippet: "re", thread: true } }, bySidIndex([root])), true, "root 3 not loaded — shown inline, whatever else is"); |
| // Flagged but ref-less: id 0 is the pending-copy key, so without the guard |
| // this would hide itself behind any in-flight send, with no root tally to |
| // carry it. Native's threadRoots skips id 0 the same way. |
| @@ -1261,25 +1306,25 @@ await test("chat: the chat.msg fold carries the thread flag (default false) and |
| assert.equal(s.msgs[2].raw.replyTo.thread, true, "raw keeps the flag, so an edit ({ ...raw, text }) re-sends it and the reply stays in its thread"); |
| }); |
| -await test("chat: viewItems({ mainFeed: true }) hides thread replies but still tallies them on the root", () => { |
| +await test("chat: viewItems({ mainFeed: true }) hides thread replies and tallies ONLY them on the root", () => { |
| const base = Date.UTC(2026, 6, 1, 12, 0, 0); |
| const msgs = [ |
| { id: "m1", sid: 1, uid: "u1", name: "Al", text: "root", ts: base, reply: null, rev: 0 }, |
| { id: "m2", sid: 2, uid: "u2", name: "Bo", text: "quote reply", ts: base + 1000, reply: { id: 1, name: "Al", snippet: "root" }, rev: 0 }, |
| { id: "m3", sid: 3, uid: "u3", name: "Cy", text: "in thread", ts: base + 2000, reply: { id: 1, name: "Al", snippet: "root", thread: true }, rev: 0 }, |
| - // A legacy chain off the thread reply still resolves to root 1 — and, |
| - // flagged, still hides. |
| - { id: "m4", sid: 4, uid: "u1", name: "Al", text: "in thread too", ts: base + 3000, reply: { id: 3, name: "Cy", snippet: "in thread", thread: true }, rev: 0 }, |
| + { id: "m4", sid: 4, uid: "u1", name: "Al", text: "in thread too", ts: base + 3000, reply: { id: 1, name: "Al", snippet: "root", thread: true }, rev: 0 }, |
| { id: "m5", sid: 5, uid: "u2", name: "Bo", text: "aside", ts: base + 4000, reply: null, rev: 0 }, |
| ]; |
| const main = viewItems(msgs, { mainFeed: true }); |
| assert.deepEqual(main.map((m) => m.sid), [1, 2, 5], "thread replies drop out; the quote reply and the aside stay"); |
| - assert.equal(main[0].replyCount, 3, "the root tallies every reply in its thread, hidden ones included"); |
| - assert.ok(main[0].srev.endsWith("|3|ok|1"), "the tally still rides the soft rev — the root patches in place, never rebuilds"); |
| + assert.equal(main[0].replyCount, 2, "the root tallies its two THREAD replies — the inline quote reply is not one of them"); |
| + assert.equal(main[1].replyCount, 0, "and the inline reply, visible right there, wears no tally of its own"); |
| + assert.ok(main[0].srev.endsWith("|2|ok|1"), "the tally still rides the soft rev — the root patches in place, never rebuilds"); |
| const all = viewItems(msgs); |
| assert.deepEqual(all.map((m) => m.sid), [1, 2, 3, 4, 5], "the plain call (the thread view) lists everything"); |
| - assert.equal(all[3].replyRoot, 1, "the chained thread reply resolves to the root either way"); |
| - assert.equal(all[0].replyCount, 3, "same tally either way"); |
| + assert.equal(all[3].replyRoot, 1, "a thread reply carries its root either way"); |
| + assert.equal(all[1].replyRoot, 0, "an inline reply carries none — it's in no thread"); |
| + assert.equal(all[0].replyCount, 2, "same tally either way"); |
| }); |
| await test("chat: viewItems({ mainFeed: true }) shows a thread reply inline while its root is paged out, then collapses it into the tally", () => { |
| @@ -1328,31 +1373,92 @@ await test("chat: viewItems({ mainFeed: true }) derives separators and sender ru |
| assert.deepEqual(all[3].timeSep, { withDay: true }, "and the hidden reply itself opens the day there"); |
| }); |
| -await test("chat: threadLinkCount — an inline reply's quote card becomes a thread link only when its thread holds more than it", () => { |
| +await test("chat: threadLinkCount — only a THREAD reply's card links into a thread, and only when the thread holds more than it", () => { |
| const base = Date.UTC(2026, 6, 1, 12, 0, 0); |
| const root = { id: "m1", sid: 1, uid: "u1", name: "Al", text: "root", ts: base, reply: null, rev: 0 }; |
| - // An ordinary quote reply — no thread flag, so it renders inline in the main |
| - // feed (this is all of the history from before thread-only replies existed). |
| + // An ordinary quote reply — no thread flag. Its card is a quote and a jump |
| + // back to what it quotes, never a way into a thread it isn't in. |
| const quote = { id: "m2", sid: 2, uid: "u2", name: "Bo", text: "quote reply", ts: base + 1000, reply: { id: 1, name: "Al", snippet: "root" }, rev: 0 }; |
| const lone = viewItems([root, quote], { mainFeed: true }); |
| - assert.equal(lone[1].threadCount, 1, "the thread holds exactly this one reply"); |
| - assert.equal(threadLinkCount(lone[1]), 0, "…so the card stays a plain quote — no chip, no noise"); |
| + assert.equal(lone[1].threadCount, 0, "an inline reply is in no thread, so there is no size to read"); |
| + assert.equal(threadLinkCount(lone[1]), 0, "…and its card stays a plain quote — no chip, no noise"); |
| assert.equal(threadLinkCount(lone[0]), 0, "a root never wears one either — it has the tally itself"); |
| assert.equal(threadLinkCount({}), 0, "a plain message has no thread to link to"); |
| - // A second reply lands in the thread (hidden from the transcript): there's |
| - // now conversation to go and see, so the inline card says so. |
| + // Thread replies land. The inline reply's card is STILL untouched — the |
| + // thread is not its conversation. |
| const inThread = { id: "m3", sid: 3, uid: "u3", name: "Cy", text: "in thread", ts: base + 2000, reply: { id: 1, name: "Al", snippet: "root", thread: true }, rev: 0 }; |
| const two = viewItems([root, quote, inThread], { mainFeed: true }); |
| - assert.equal(threadLinkCount(two[1]), 2, "two replies in the thread — the card links to it"); |
| - assert.equal(two[0].replyCount, 2, "and the number IS the root's own tally — the two can never disagree"); |
| - assert.ok(two[1].srev !== lone[1].srev, "the count rides the SOFT rev, so the card repaints in place (no bubble rebuild)"); |
| - assert.equal(two[1].vrev, lone[1].vrev, "and never the hard rev — a sibling reply must not reload the bubble's photo"); |
| - // A legacy chain (a reply quoting a reply) resolves to the same root, so |
| - // every card in the thread reads the same size. |
| - const chained = { id: "m4", sid: 4, uid: "u1", name: "Al", text: "chained", ts: base + 3000, reply: { id: 2, name: "Bo", snippet: "quote reply" }, rev: 0 }; |
| - const three = viewItems([root, quote, inThread, chained], { mainFeed: true }); |
| - assert.deepEqual(three.map((m) => threadLinkCount(m)), [0, 3, 3], "root has no link; both inline replies read the whole thread's size"); |
| - assert.equal(three[0].replyCount, 3, "still the root's tally"); |
| + assert.equal(threadLinkCount(two[1]), 0, "a sibling thread landing changes nothing about an inline quote card"); |
| + assert.equal(two[0].replyCount, 1, "the root's tally counts the thread reply only"); |
| + assert.equal(two[1].srev, lone[1].srev, "and the inline reply's soft rev is unmoved — nothing to repaint"); |
| + // Where the link DOES appear: a thread reply rendered inline because its |
| + // root is paged out. Its card is the one way back into its thread. |
| + const page = [ |
| + { id: "m50", sid: 50, uid: "u2", name: "Bo", text: "recent", ts: base, reply: null, rev: 0 }, |
| + { id: "m51", sid: 51, uid: "u3", name: "Cy", text: "in an old thread", ts: base + 1000, reply: { id: 7, name: "Al", snippet: "old root", thread: true }, rev: 0 }, |
| + { id: "m52", sid: 52, uid: "u1", name: "Al", text: "also in it", ts: base + 2000, reply: { id: 7, name: "Al", snippet: "old root", thread: true }, rev: 0 }, |
| + ]; |
| + const orphans = viewItems(page, { mainFeed: true }); |
| + assert.deepEqual(orphans.map((m) => m.sid), [50, 51, 52], "root paged out — both thread replies degrade to inline bubbles"); |
| + assert.deepEqual(orphans.map((m) => threadLinkCount(m)), [0, 2, 2], "and each card reads the whole thread's size"); |
| +}); |
| + |
| +await test("chat: a running feed glide claims 'at bottom' — and feedCancelGlide is what releases it", () => { |
| + // The bug this pins: a jump that pages history back re-renders the feed on |
| + // every page, and render() decides "was at bottom" BEFORE the prepend — so |
| + // from the bottom of a busy chat each page starts a follow glide. While that |
| + // glide is registered, feedNearBottom reports "at bottom" REGARDLESS of |
| + // geometry, so every later render re-arms the follow, and its rAF step keeps |
| + // writing scrollTop — cancelling the smooth scrollIntoView the landing uses. |
| + // Net: the note flashed and the feed never moved. (Only with Reduce Motion |
| + // OFF: the reduced-motion path jumps instead of gliding, so it never armed.) |
| + const frames = []; |
| + const feed = { scrollTop: 0, scrollHeight: 10000, clientHeight: 500, addEventListener() {} }; |
| + const g = globalThis; |
| + const priorMM = g.matchMedia, priorRAF = g.requestAnimationFrame; |
| + g.matchMedia = () => ({ matches: false }); // Reduce Motion OFF — the failing case |
| + g.requestAnimationFrame = (fn) => frames.push(fn); // hold the steps; never run a frame |
| + try { |
| + assert.equal(feedNearBottom(feed, 60), false, "geometry alone says nowhere near the bottom"); |
| + feedGlideToBottom(feed, false); |
| + assert.equal(frames.length, 1, "Reduce Motion off arms a real rAF glide"); |
| + assert.equal(feedNearBottom(feed, 60), true, "…and while it runs the feed claims to be at the bottom, whatever the geometry — this is what re-arms the follow on every render"); |
| + feedCancelGlide(feed); |
| + assert.equal(feedNearBottom(feed, 60), false, "cancelling releases the claim, so the next render won't re-arm it"); |
| + // The already-scheduled step must be inert too, or it writes scrollTop on |
| + // the very next frame and kills an in-flight smooth scroll. |
| + frames[0](); |
| + assert.equal(feed.scrollTop, 0, "a cancelled glide's pending frame must not touch scrollTop"); |
| + assert.equal(frames.length, 1, "and must not schedule another"); |
| + } finally { |
| + g.matchMedia = priorMM; |
| + g.requestAnimationFrame = priorRAF; |
| + } |
| +}); |
| + |
| +await test("chat: pageResult — an EMPTY history page and a FAILED fetch are never the same thing", () => { |
| + assert.equal(pageResult(100), "page", "rows folded — keep walking"); |
| + assert.equal(pageResult(1), "page"); |
| + // 0 is the server's answer: there is nothing older. This is the ONLY result |
| + // that may latch `exhausted` or tell a reader a message is gone. |
| + assert.equal(pageResult(0), "end"); |
| + // -1 is host.loadOlder saying the fetch failed (offline, 5xx mid-deploy, |
| + // bad JSON). Latching on this would kill scroll-back for the whole mount |
| + // AND report a perfectly-alive message as permanently deleted. |
| + assert.equal(pageResult(-1), "error"); |
| + assert.notEqual(pageResult(-1), pageResult(0), "an errored page must never read as the start of history"); |
| +}); |
| + |
| +await test("chat: jumpReachNote — a jump that can't land always says why, and says WHICH kind of can't", () => { |
| + assert.equal(jumpReachNote("found"), "", "a landing has nothing to say"); |
| + assert.ok(jumpReachNote("gone"), "a deleted target is answered, never a silent no-op"); |
| + assert.ok(jumpReachNote("capped"), "a target further back than one jump walks is answered too"); |
| + assert.notEqual(jumpReachNote("gone"), jumpReachNote("capped"), "'gone' is final; 'capped' means keep scrolling — never the same sentence"); |
| + assert.equal(jumpReachNote("anything else"), "", "an unknown outcome says nothing rather than inventing an answer"); |
| + // Byte-identical to the Swift twin (ChatFeedModel.jumpReachNote) — the two |
| + // are pinned here so web, Mac and iOS can't drift into different wording. |
| + assert.equal(jumpReachNote("gone"), "That message isn't available any more."); |
| + assert.equal(jumpReachNote("capped"), "That message is further back than this jump reaches — keep scrolling up for it."); |
| }); |
| await test("chat: replyCountLabel pluralises and localises — one wording for the tally and the quote card's thread link", () => { |
diff --git a/test/event-vectors.test.mjs b/test/event-vectors.test.mjs| index b8348ef..b3c4b62 100644 |
| --- a/test/event-vectors.test.mjs |
| +++ b/test/event-vectors.test.mjs |
| @@ -3,7 +3,7 @@ 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, inMainFeed, bySidIndex } from "../web/static/apps/chat.js"; |
| +import { viewItems, quoteTarget, threadTarget, threadItems, inMainFeed, bySidIndex } from "../web/static/apps/chat.js"; |
| // Views fold against the REAL app (views.js exports it) — its whole job is |
| // clamping hostile events, so a reconstruction would test the wrong thing. |
| import { viewsApp } from "../web/static/apps/views.js"; |
| @@ -346,17 +346,22 @@ allVectors.chat.forEach((vector) => { |
| if (vector.expected_threads) { |
| const mainBySid = new Map(main.map((i) => [i.sid, i])); |
| for (const [root, count] of Object.entries(vector.expected_threads.reply_counts)) { |
| - assert.strictEqual(mainBySid.get(Number(root))?.replyCount, count, `root ${root}'s transcript tally should still count hidden thread replies`); |
| + // A message hidden in its own thread has no transcript bubble to |
| + // carry a tally — the rollup itself is checked below, unfiltered. |
| + const row = mainBySid.get(Number(root)); |
| + if (!row) continue; |
| + assert.strictEqual(row.replyCount, count, `root ${root}'s transcript tally should count its thread replies (and only those)`); |
| } |
| } |
| }); |
| } |
| // 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). |
| + // thread rollup. A thread is its root plus the replies FLAGGED into it — |
| + // an inline quote chain resolves to nothing and tallies nowhere — plus the |
| + // two compose verbs (quoteTarget = the exact bubble, threadTarget = the root). |
| if (vector.expected_threads) { |
| - test(`chat threads: ${vector.name} — replies group on the resolved root`, () => { |
| + test(`chat threads: ${vector.name} — only flagged replies join a thread`, () => { |
| let state = { msgs: [] }; |
| for (const step of vector.message_sequence) { |
| const event = { type: "chat.msg", ...step.decrypted_payload }; |
| @@ -366,19 +371,33 @@ allVectors.chat.forEach((vector) => { |
| 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}`); |
| + assert.strictEqual(bySid.get(Number(sid))?.replyRoot, root, root ? `message ${sid} should belong to thread ${root}` : `message ${sid} should belong to NO thread`); |
| } |
| 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`); |
| + assert.strictEqual(bySid.get(Number(root))?.replyCount, count, `${root} should tally ${count} thread replies`); |
| } |
| 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"); |
| + // The thread view's own list, straight off the pure selector. |
| + for (const [root, ids] of Object.entries(vector.expected_thread_items || {})) { |
| + if (root === "note") continue; |
| + assert.deepStrictEqual(threadItems(state.msgs, Number(root)).map((m) => m.sid), ids, `threadItems(${root}) should be exactly ${JSON.stringify(ids)}`); |
| + } |
| + // The compose seam: plain Reply quotes the exact message, Reply in |
| + // thread names the thread's root (or the message itself when it's in |
| + // no thread — that message becomes the new root). |
| + const targets = vector.expected_reply_targets; |
| + if (targets) { |
| + for (const [sid, want] of Object.entries(targets.quote || {})) { |
| + const m = state.msgs.find((x) => x.sid === Number(sid)); |
| + assert.deepStrictEqual(quoteTarget(m), want, `quoteTarget(${sid}) — plain Reply targets the exact message clicked`); |
| + } |
| + for (const [sid, want] of Object.entries(targets.thread || {})) { |
| + const m = state.msgs.find((x) => x.sid === Number(sid)); |
| + assert.deepStrictEqual(threadTarget(state.msgs, m), want, `threadTarget(${sid}) — Reply in thread targets the thread's root`); |
| + } |
| } |
| }); |
| } |
diff --git a/test/event-vectors/chat.json b/test/event-vectors/chat.json| index 6350961..ceb55e1 100644 |
| --- a/test/event-vectors/chat.json |
| +++ b/test/event-vectors/chat.json |
| @@ -89,7 +89,7 @@ |
| { |
| "name": "message_with_reply", |
| "description": "A reply-to message with quoted context — a plain quote reply (no thread flag), which renders inline in the main feed exactly as it always has.", |
| - "note": "replyTo.thread is ABSENT here: that is the wire form of every reply from before the flag existed and of the ordinary Reply verb. Clients fold it as thread:false and list the message in the main feed (expected_main_feed_visible). Contrast message_in_thread.", |
| + "note": "replyTo.thread is ABSENT here: that is the wire form of every reply from before the flag existed and of the ordinary Reply verb. Clients fold it as thread:false and list the message in the main feed (expected_main_feed_visible). The plain verb targets the EXACT message clicked — replyTo.id names the bubble the member tapped Reply on, never a resolved root (web quoteTarget in chat.js; native ChatFeedModel.quoteTarget), so replying to a reply stacks another quote inline and starts no thread. Its quote card jumps to the quoted message; it wears no thread link and contributes nothing to any 'N Replies' tally. Contrast message_in_thread.", |
| "decrypted_payload": { |
| "name": "Charlie", |
| "text": "I agree!", |
| @@ -123,8 +123,8 @@ |
| }, |
| { |
| "name": "message_in_thread", |
| - "description": "A reply composed IN a thread (the docked pane's or mobile overlay's composer, or the 'Reply in thread' verb): replyTo carries thread:true, and the message lives only in its thread — the main feed shows just the root's 'N Replies' tally, never this bubble (Slack model).", |
| - "note": "The thread flag is ADDITIVE and rides INSIDE replyTo ({ id, name, snippet, thread: true }) — the same shape old clients already decode, which ignore the extra key and show the reply inline (harmless degradation). Absent/false = an inline quote reply (message_with_reply). Web: chat.msg fold sets reply.thread (default false); inMainFeed(m, bySid) is the predicate, viewItems(msgs, { mainFeed: true }) the transcript filter — the reply still counts in its root's replyCount and still lists in threadItems. The hide is ROOT-AWARE: expected_main_feed_visible is evaluated WITH the quoted root (replyTo.id) present in the loaded window — the test seeds it from the snapshot — because the tally lives on the root's bubble. With the root paged out the reply renders inline like a quote reply, and collapses into the tally when the root loads (thread_reply_root_unloaded). Native mirrors: MessagePayload.replyTo.thread, the root-aware feed filter, the tally, and the thread view. The flag is set at the compose seam (thread view / verb), never by replyTarget, and survives edits ({ ...raw, text }).", |
| + "description": "A reply composed IN a thread (the docked pane's or mobile overlay's composer, or the 'Reply in thread' verb): replyTo carries thread:true and points DIRECTLY at the thread's root. The message lives only in its thread — the main feed shows just the root's 'N Replies' tally, never this bubble (Slack model).", |
| + "note": "The thread flag is ADDITIVE and rides INSIDE replyTo ({ id, name, snippet, thread: true }) — the same shape old clients already decode, which ignore the extra key and show the reply inline (harmless degradation). Absent/false = an inline quote reply (message_with_reply). THE FLAG IS THE WHOLE DEFINITION OF A THREAD: membership is the flag plus a direct pointer at the root (web threadRootOf; native ChatFeedModel.threadRoots), never a walk up a quote chain — a thread exists only because a member chose 'Reply in thread', so nothing is absorbed into one by accident. Web: chat.msg fold sets reply.thread (default false); inMainFeed(m, bySid) is the predicate, viewItems(msgs, { mainFeed: true }) the transcript filter — the reply counts in its root's replyCount (only thread replies do) and lists in threadItems. The hide is ROOT-AWARE: expected_main_feed_visible is evaluated WITH the quoted root (replyTo.id) present in the loaded window — the test seeds it from the snapshot — because the tally lives on the root's bubble. With the root paged out the reply renders inline like a quote reply, and collapses into the tally when the root loads (thread_reply_root_unloaded). Native mirrors: MessagePayload.replyTo.thread, the root-aware feed filter, the tally, and the thread view. The flag is set at the compose seam (thread view / verb), never by the pure targets (quoteTarget/threadTarget), and survives edits ({ ...raw, text }).", |
| "decrypted_payload": { |
| "name": "Bea", |
| "text": "In the thread", |
| @@ -173,7 +173,7 @@ |
| { |
| "name": "thread_reply_root_unloaded", |
| "description": "A reply made IN a thread whose root is older than the loaded history: the sequence holds ONLY the flagged reply (replyTo.id = 100 with its name/snippet snapshot) and no message 100. It must render in the main feed as an ordinary inline bubble, quote card and all.", |
| - "note": "The thread hide is ROOT-AWARE. The 'N Replies' tally lives on the root's bubble, so with the root paged out a hidden reply would render NOWHERE — cold-open a busy chat and its newest message would be invisible until the reader blind-scrolled back past the root. So a thread-flagged reply is hidden only when its RESOLVED root (rootRef / replyRoot, legacy chains included) is present in the loaded list; otherwise it degrades to an inline bubble (its quote card still opens the thread and pages the root in), and it collapses into the tally the moment the root loads — the same message flips from expected_main_feed true to false purely on what is loaded. Web: inMainFeed(m, bySid) over bySidIndex(msgs); viewItems(msgs, { mainFeed: true }). Native mirrors the same rule in its feed filter.", |
| + "note": "The thread hide is ROOT-AWARE. The 'N Replies' tally lives on the root's bubble, so with the root paged out a hidden reply would render NOWHERE — cold-open a busy chat and its newest message would be invisible until the reader blind-scrolled back past the root. So a thread-flagged reply is hidden only when the root it points at (replyTo.id / replyRoot) is present in the loaded list; otherwise it degrades to an inline bubble, and it collapses into the tally the moment the root loads — the same message flips from expected_main_feed true to false purely on what is loaded. This is the ONE case where a card wearing a thread link renders in the transcript: a thread reply shown inline keeps its way back into the thread (threadLinkCount), while a plain quote reply's card only ever jumps to what it quotes. Web: inMainFeed(m, bySid) over bySidIndex(msgs); viewItems(msgs, { mainFeed: true }). Native mirrors the same rule in its feed filter.", |
| "message_sequence": [ |
| { |
| "server_message": { |
| @@ -199,9 +199,9 @@ |
| } |
| }, |
| { |
| - "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.", |
| + "name": "reply_in_thread_continues_the_thread", |
| + "description": "'Reply in thread' on a message that is ALREADY in a thread continues that thread: the composer stages the thread's ROOT — never the tapped reply — and the compose seam flags it thread:true.", |
| + "note": "Compose-side contract for the thread verb (web threadTarget in chat.js; native ChatFeedModel.threadTarget), and the reason a thread stays flat: every reply in it points straight at the root, so there is never a chain to walk. Here Dana tapped 'Reply in thread' on Erin's thread reply, which belongs to root 12345 — so the staged replyTo names 12345 with thread:true, and Dana's reply lands in the thread, not the transcript. RENAMED from reply_in_thread_targets_root, whose premise (plain Reply root-resolving through an inline chain) is exactly what the two-verb split removed: the plain verb now targets the tapped bubble (message_with_reply), and a message NOT already in a thread roots a new thread on itself (inline_reply_chain_is_not_a_thread).", |
| "decrypted_payload": { |
| "name": "Dana", |
| "text": "Same here!", |
| @@ -210,7 +210,8 @@ |
| "replyTo": { |
| "id": 12345, |
| "name": "Alice", |
| - "snippet": "Hello everyone" |
| + "snippet": "Hello everyone", |
| + "thread": true |
| } |
| }, |
| "server_message": { |
| @@ -227,15 +228,17 @@ |
| "replyTo": { |
| "id": 12345, |
| "name": "Alice", |
| - "snippet": "Hello everyone" |
| + "snippet": "Hello everyone", |
| + "thread": true |
| }, |
| "attachments": [] |
| - } |
| + }, |
| + "expected_main_feed_visible": false |
| }, |
| { |
| "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 — including a reply made IN the thread (replyTo.thread), which the main feed hides — 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 104 carries replyTo.thread:true — it groups and tallies like any reply and lists in the thread view, but expected_main_feed says it is HIDDEN from the main feed (web inMainFeed / viewItems mainFeed; native's feed filter) while every other message shows. expected_main_feed semantics: a thread-flagged reply is hidden iff its RESOLVED root is present in the sequence (root loaded → its bubble carries the tally); rootless it shows inline — see thread_reply_root_unloaded. Separators and sender runs in the main feed derive over the shown messages only.", |
| + "description": "Inline replies and threads are SEPARATE. A thread is its root plus the replies flagged thread:true that point at it — nothing else — so the root's 'N Replies' tally counts thread replies ONLY. Quote replies (101, 103) and a quote-of-a-quote chain (102) stay inline in the feed, tally nothing, and belong to no thread.", |
| + "note": "Multi-message vector: fold the sequence, then derive the thread rollup (web viewItems replyRoot/replyCount + threadItems in chat.js; native ChatFeedModel.threadRoots/refreshReplyCounts/threadItems). THIS VECTOR CHANGED with the two-verb split (plain Reply vs Reply in thread): it used to assert that 101/102/103 resolved transitively into root 100's thread and that the root tallied 4. They no longer do. Message 102 quotes reply 101 — a chain of inline quotes is a conversation in the feed, not a thread nobody opened — so it has NO thread root and adds nothing to any tally; the same goes for the direct quote replies 101 and 103. Only 104 carries replyTo.thread:true, so it alone is in root 100's thread: expected_threads gives root 100 a tally of ONE, first_reply 104, and expected_thread_items lists the thread as [100, 104]. expected_main_feed says 104 is HIDDEN from the main feed (web inMainFeed / viewItems mainFeed; native's feed filter) while every other message shows — a thread-flagged reply is hidden iff the root it points at is present in the sequence; rootless it shows inline (thread_reply_root_unloaded). Separators and sender runs in the main feed derive over the shown messages only. expected_reply_targets pins the two compose verbs against this same state.", |
| "message_sequence": [ |
| { |
| "server_message": { |
| @@ -321,18 +324,27 @@ |
| ], |
| "expected_threads": { |
| "roots": { |
| - "101": 100, |
| - "102": 100, |
| - "103": 100, |
| + "101": 0, |
| + "102": 0, |
| + "103": 0, |
| "104": 100 |
| }, |
| "reply_counts": { |
| - "100": 4 |
| + "100": 1, |
| + "101": 0, |
| + "102": 0, |
| + "103": 0, |
| + "104": 0 |
| }, |
| "first_reply": { |
| - "100": 101 |
| + "100": 104 |
| } |
| }, |
| + "expected_thread_items": { |
| + "note": "The thread view's list for each root: the root plus its thread replies, in server order. Root 100's thread holds only 104 — the inline replies are not in it. 101 has no thread of its own until someone starts one on it.", |
| + "100": [100, 104], |
| + "101": [101] |
| + }, |
| "expected_main_feed": { |
| "100": true, |
| "101": true, |
| @@ -340,13 +352,101 @@ |
| "103": true, |
| "104": false |
| }, |
| - "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" |
| + "expected_reply_targets": { |
| + "note": "Two verbs, two targets, both pure. `quote` is plain Reply (web quoteTarget(m); native ChatFeedModel.quoteTarget) — the EXACT message clicked, no resolution, no flag, so it renders inline. `thread` is Reply in thread (web threadTarget(msgs, m); native ChatFeedModel.threadTarget) — the root of the thread the message is already in, or the message ITSELF when it is in none, because that message becomes the new thread's root. The compose seam adds thread:true to the `thread` target on the wire; the helpers stay flag-free.", |
| + "quote": { |
| + "100": { "id": 100, "name": "Alice", "snippet": "Hello everyone" }, |
| + "102": { "id": 102, "name": "Charlie", "snippet": "Welcome!" }, |
| + "104": { "id": 104, "name": "Erin", "snippet": "Replying from inside the thread" } |
| + }, |
| + "thread": { |
| + "100": { "id": 100, "name": "Alice", "snippet": "Hello everyone" }, |
| + "102": { "id": 102, "name": "Charlie", "snippet": "Welcome!" }, |
| + "104": { "id": 100, "name": "Alice", "snippet": "Hello everyone" } |
| + } |
| + } |
| + }, |
| + { |
| + "name": "inline_reply_chain_is_not_a_thread", |
| + "description": "Three messages, each an ordinary quote reply to the one before: a root, a reply, and a reply to that reply. No thread flag anywhere, so there is NO thread — every bubble shows inline in the feed and NOTHING wears an 'N Replies' tally.", |
| + "note": "The regression this vector exists to catch. Before the two-verb split, replying to a reply silently root-resolved and joined (or created) a thread, so a chain like this collapsed into 'root + 2 replies' with a tally on 300 and the two replies hidden from the feed — a thread nobody chose. Now: reply_counts are all 0, roots are all 0, threadItems(300) is just [300], and expected_main_feed shows all three. expected_reply_targets pins that Reply on 302 quotes 302 (not 300), and that Reply in thread on 302 roots a NEW thread on 302 itself — the only way a thread ever begins.", |
| + "message_sequence": [ |
| + { |
| + "server_message": { |
| + "id": 300, |
| + "created_at": 1700000000 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Alice", |
| + "text": "Anyone up for lunch?", |
| + "ts": 1700000000000, |
| + "uid": "user-alice-1" |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 301, |
| + "created_at": 1700000001 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Bob", |
| + "text": "I'm in", |
| + "ts": 1700000001000, |
| + "uid": "user-bob-1", |
| + "replyTo": { |
| + "id": 300, |
| + "name": "Alice", |
| + "snippet": "Anyone up for lunch?" |
| + } |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 302, |
| + "created_at": 1700000002 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Charlie", |
| + "text": "Me too — one o'clock?", |
| + "ts": 1700000002000, |
| + "uid": "user-charlie-1", |
| + "replyTo": { |
| + "id": 301, |
| + "name": "Bob", |
| + "snippet": "I'm in" |
| + } |
| + } |
| + } |
| + ], |
| + "expected_threads": { |
| + "roots": { |
| + "301": 0, |
| + "302": 0 |
| + }, |
| + "reply_counts": { |
| + "300": 0, |
| + "301": 0, |
| + "302": 0 |
| + }, |
| + "first_reply": {} |
| + }, |
| + "expected_thread_items": { |
| + "note": "Every root's thread is just itself — an inline chain creates none.", |
| + "300": [300], |
| + "301": [301], |
| + "302": [302] |
| + }, |
| + "expected_main_feed": { |
| + "300": true, |
| + "301": true, |
| + "302": true |
| + }, |
| + "expected_reply_targets": { |
| + "quote": { |
| + "302": { "id": 302, "name": "Charlie", "snippet": "Me too — one o'clock?" } |
| + }, |
| + "thread": { |
| + "302": { "id": 302, "name": "Charlie", "snippet": "Me too — one o'clock?" } |
| } |
| } |
| }, |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index e970dac..bcb2712 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -52,7 +52,10 @@ import { decodeBlurhash } from "../blurhash.js"; |
| // loadBlob(threadId, att) → url fetch + decrypt an attachment blob |
| // upload(threadId, file) → att | null downscale/allow-list/encrypt/upload |
| // unfurl(url) → preview | null the OpenGraph card data |
| -// loadOlder(threadId, beforeSid) → n fold an older history page |
| +// loadOlder(threadId, beforeSid) → n fold an older history page: rows |
| +// folded, 0 = the server says history |
| +// ends here, -1 = the FETCH FAILED |
| +// (never latch `exhausted` on -1) |
| // mentionNames() / mention(label, el) roster labels + the member menu |
| // isGroup(threadId) / agentKind(name) sender-name + AI/BOT-badge rules |
| // icons the original in-code SVG icon strings |
| @@ -181,6 +184,11 @@ export function feedGlideToBottom(feed, jump) { |
| } |
| if (feedGlides.has(feed)) return; // the running glide chases new growth itself |
| feedGlides.add(feed); |
| + // NB for anyone adding a scroll of their own: while this set holds the feed, |
| + // step() writes scrollTop EVERY frame and feedNearBottom reports "at bottom" |
| + // regardless of geometry — so a smooth scrollIntoView elsewhere is cancelled |
| + // on the next frame and each render re-arms the follow. Call feedCancelGlide |
| + // first (see revealMessage). |
| // Also detect intervention we DON'T get events for (scrollbar drag, PageUp, |
| // arrow keys): if scrollTop is meaningfully below where we last placed it, the |
| // user moved it — bail so we never fight them back down. |
| @@ -205,6 +213,16 @@ export function feedGlideToBottom(feed, jump) { |
| requestAnimationFrame(step); |
| } |
| +// Stop a running follow-the-bottom glide. The wheel/touchmove listeners above |
| +// do this when the reader intervenes; a scroll WE start has to do it |
| +// explicitly, because step() would otherwise overwrite scrollTop on its next |
| +// frame (an auto-behaviour write cancels an in-flight smooth scroll) and the |
| +// glide's presence keeps feedNearBottom true, re-arming the follow on every |
| +// subsequent render. |
| +export function feedCancelGlide(feed) { |
| + feedGlides.delete(feed); |
| +} |
| + |
| function addLocalNotice(ctx, spec) { |
| composerHints.set(ctx.threadId, spec.hint || ""); |
| composerAttachHints.set(ctx.threadId, spec.attachHint || ""); |
| @@ -275,7 +293,7 @@ const composers = new Map(); // threadId → { refreshSend(), startReply(item), |
| // paneComposer() hands out the docked pane's composer api while one is mounted |
| // (null otherwise) — how setConnected and bubble actions reach the second |
| // composer without a second registry. |
| -const threads = new Map(); // threadId → { open(rootSid, ref, opener), close(), isOpen(), paneComposer(), openForMessage(sid) } |
| +const threads = new Map(); // threadId → { open(rootSid, ref, opener), close(), isOpen(), paneComposer(), openForMessage(sid), reveal(sid) } |
| // composerFor: the composer a bubble action (Reply, Edit, swipe-reply) lands |
| // in — the docked thread pane's own when the acting bubble lives inside that |
| @@ -292,8 +310,8 @@ function composerFor(anchor, ctx) { |
| } |
| // openReplyThread: the "Reply in thread" verb — open the thread `m` belongs |
| -// to, anchored on its resolved ROOT (replyTarget: a reply's thread is its |
| -// root's, never the tapped reply's own), docked or lifted per openThread's |
| +// to (threadTarget: a message already in a thread continues it; anything else |
| +// roots a new thread on itself), docked or lifted per openThread's |
| // width check. From a bubble inside the pane itself this is harmless: same |
| // root, so openThread's already-open branch just refocuses. The opener is |
| // where focus returns on close, so only a node that can take focus is worth |
| @@ -303,7 +321,7 @@ function composerFor(anchor, ctx) { |
| function openReplyThread(m, ctx, anchor) { |
| const t = threads.get(ctx.threadId); |
| if (!t) return; |
| - const target = replyTarget(((ctx.getState() || {}).msgs) || [], m); |
| + const target = threadTarget(((ctx.getState() || {}).msgs) || [], m); |
| if (!target.id) return; |
| t.open(target.id, target, anchor && anchor.tabIndex >= 0 ? anchor : null); |
| } |
| @@ -390,53 +408,60 @@ 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; |
| +// threadRootOf: the sid of the thread a message BELONGS to, or 0 for a message |
| +// that's in no thread. A thread exists only because someone chose "Reply in |
| +// thread", so membership is exactly the wire flag: a reply carrying |
| +// `reply.thread` points DIRECTLY at its thread's root (every thread send |
| +// derives its target from the root — see threadReplyTarget), so this is a |
| +// straight read, never a walk. Plain quote replies — the ordinary Reply verb, |
| +// and every reply in the history from before the two verbs split — belong to |
| +// no thread at all: an inline chain is a conversation in the feed, not a |
| +// thread nobody opened. |
| +function threadRootOf(m) { |
| + return m.reply && m.reply.thread && m.reply.id ? m.reply.id : 0; |
| +} |
| + |
| +// quoteTarget: the replyTo a PLAIN reply to `m` carries — the exact message |
| +// clicked, nothing resolved. Replying to a reply quotes that reply; the chain |
| +// stacks inline in the feed and starts no thread. Pure and exported for tests |
| +// (mirrored natively by ChatFeedModel.quoteTarget — pinned by the |
| +// reply_thread_grouping vector's expected_reply_targets.quote). |
| +export function quoteTarget(m) { |
| + return { id: m.sid, name: m.name, snippet: replySnippet(m.raw) }; |
| } |
| -// 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); |
| +// threadTarget: the replyTo a "Reply in thread" on `m` carries — the ROOT of |
| +// the thread the reply belongs in. A message already IN a thread continues |
| +// that thread (its root, snapshotted fresh when loaded, the quoted snapshot |
| +// until then); anything else — a plain message, a quote reply, a whole inline |
| +// chain — roots a NEW thread on ITSELF, because until someone chose this verb |
| +// there was no thread to join. Pure and exported for tests (mirrored natively |
| +// by ChatFeedModel.threadTarget). The `thread: true` wire flag is added at the |
| +// compose seam (threadReplyTarget), never here. |
| +export function threadTarget(msgs, m) { |
| + const rootSid = threadRootOf(m); |
| + if (!rootSid) return quoteTarget(m); |
| + const root = msgs.find((x) => x.sid === rootSid); |
| return root |
| ? { id: root.sid, name: root.name, snippet: replySnippet(root.raw) } |
| - : { id: ref.id || 0, name: ref.name || "", snippet: ref.snippet || "" }; |
| + : { id: rootSid, name: (m.reply && m.reply.name) || "", snippet: (m.reply && m.reply.snippet) || "" }; |
| } |
| // threadItems: the reply thread rooted at `rootSid` — the root message (when |
| -// loaded) followed by every message whose RESOLVED thread root is that sid, in |
| -// server order. Legacy chains (a reply quoting another reply) resolve |
| -// transitively via rootRef, so they land in the same thread as their ancestors; |
| -// when the root itself has scrolled out of the loaded window the replies still |
| -// fold (the view renders the quoted snapshot as its header instead). Pure and |
| -// exported for tests — the thread view re-derives this from current state on |
| -// every fold, never a cached list. |
| +// loaded) followed by every message whose thread root is that sid, in server |
| +// order. Thread membership is the flag and a direct pointer (threadRootOf): |
| +// an inline quote chain hanging off the same root is NOT part of the thread, |
| +// it's ordinary feed conversation. When the root itself has scrolled out of |
| +// the loaded window the replies still fold (the view renders the quoted |
| +// snapshot as its header instead). Pure and exported for tests — the thread |
| +// view re-derives this from current state on every fold, never a cached list. |
| export function threadItems(msgs, rootSid) { |
| - const bySid = new Map(msgs.map((m) => [m.sid, m])); |
| + if (!rootSid) return []; // 0 is the pending-copy key, never a thread root |
| const out = []; |
| - const root = bySid.get(rootSid); |
| + const root = msgs.find((m) => m.sid === rootSid); |
| if (root) out.push(root); |
| for (const m of msgs) { |
| - if (m.reply && m.reply.id && rootRef(bySid, m.reply).id === rootSid) out.push(m); |
| + if (threadRootOf(m) === rootSid) out.push(m); |
| } |
| // Same order as the feed: confirmed by sid, a still-pending optimistic reply |
| // (sid 0) LAST. A plain by-sid sort put the pending copy first, ahead of the |
| @@ -447,10 +472,10 @@ export function threadItems(msgs, rootSid) { |
| return out.sort(bySidPendingLast); |
| } |
| -// bySidIndex: the loaded window keyed by sid — what rootRef walks and what |
| -// inMainFeed checks a thread reply's root against. Exported so callers |
| -// outside viewItems (jump fallback, hotkeys, ↑-to-edit, tests) build the |
| -// same index rather than each re-deriving "is the root loaded". |
| +// bySidIndex: the loaded window keyed by sid — what inMainFeed checks a thread |
| +// reply's root against. Exported so callers outside viewItems (jump fallback, |
| +// hotkeys, ↑-to-edit, tests) build the same index rather than each re-deriving |
| +// "is the root loaded". |
| export function bySidIndex(msgs) { |
| return new Map(msgs.map((m) => [m.sid, m])); |
| } |
| @@ -462,9 +487,9 @@ export function bySidIndex(msgs) { |
| // (the flag absent — every message from before the flag, and the ordinary |
| // Reply verb) stay inline exactly as they always did. |
| // |
| -// ROOT-AWARE: the hide only holds while the reply's resolved root is in the |
| -// loaded window (`bySid` — the index viewItems already builds). The tally |
| -// lives on the root's bubble, so with the root paged out (a fresh reply to an |
| +// ROOT-AWARE: the hide only holds while the reply's root is in the loaded |
| +// window (`bySid` — the index viewItems already builds). The tally lives on |
| +// the root's bubble, so with the root paged out (a fresh reply to an |
| // old message, cold-opening a busy chat) hiding the reply would leave it |
| // rendered NOWHERE until the reader blind-scrolled back past the root. So it |
| // degrades to an ordinary inline bubble — quote card and all — and collapses |
| @@ -474,12 +499,13 @@ export function bySidIndex(msgs) { |
| // thread_reply_root_unloaded). |
| export function inMainFeed(m, bySid) { |
| // Flagged but pointing nowhere resolvable is a feed bubble, same as |
| - // unflagged: id 0 is the pending-copy key, so without this guard a |
| - // ref-less thread reply would hide itself behind any in-flight send — |
| + // unflagged: id 0 is the pending-copy key, so without threadRootOf's guard |
| + // a ref-less thread reply would hide itself behind any in-flight send — |
| // and no root tally would carry it. (Native's threadRoots skips id 0 |
| // for the same reason; the two predicates must agree.) |
| - if (!(m.reply && m.reply.thread && m.reply.id)) return true; |
| - return !bySid.has(rootRef(bySid, m.reply).id); |
| + const root = threadRootOf(m); |
| + if (!root) return true; |
| + return !bySid.has(root); |
| } |
| // Message order: confirmed messages by server id (the stable identity), with any |
| @@ -497,17 +523,20 @@ function bySidPendingLast(a, b) { |
| // what's actually shown, so a hidden reply never breaks a run or strands a |
| // day separator. The thread view calls it plain (every reply shows there). |
| export function viewItems(msgs, { mainFeed = false } = {}) { |
| - // 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. |
| - // Tallies run over EVERY message — a thread-only reply counts even though |
| + // The "N Replies" tally counts a root's THREAD — only the replies someone |
| + // deliberately made in it (threadRootOf: the `reply.thread` flag). An |
| + // ordinary inline reply contributes nothing: it's already visible right |
| + // there in the feed, so a tally on top of it would be counting the same |
| + // conversation twice and would read as a thread nobody started. Each thread |
| + // reply carries its root sid (`replyRoot`) so taps/jumps land on the thread; |
| + // tallies run over EVERY message, because a thread reply counts even though |
| // the transcript never lists it (the tally is its only trace there). |
| const bySid = bySidIndex(msgs); |
| const replyCounts = new Map(); // thread root sid → messages in its thread |
| - const roots = new Map(); // reply sid → its thread's root sid |
| + const roots = new Map(); // thread reply sid → its thread's root sid |
| for (const m of msgs) { |
| - if (m.reply && m.reply.id) { |
| - const root = rootRef(bySid, m.reply).id; |
| + const root = threadRootOf(m); |
| + if (root) { |
| roots.set(m.sid, root); |
| replyCounts.set(root, (replyCounts.get(root) || 0) + 1); |
| } |
| @@ -524,10 +553,11 @@ export function viewItems(msgs, { mainFeed = false } = {}) { |
| } |
| const cont = !timeSep && sameSender(prev, m); |
| const replyCount = replyCounts.get(m.sid) || 0; |
| - // A reply's own view of its thread's tally: the SAME number the root's |
| - // "N Replies" shows, so a quote card that doubles as a thread link can |
| - // never disagree with the root it points at (threadLinkCount decides |
| - // whether it wears one). 0 for anything that isn't a reply. |
| + // A THREAD reply's own view of its thread's tally: the SAME number the |
| + // root's "N Replies" shows, so a quote card that doubles as a thread link |
| + // can never disagree with the root it points at (threadLinkCount decides |
| + // whether it wears one). 0 for a plain inline reply — its card is a quote |
| + // and a jump, never a way into a thread. |
| const threadCount = replyCounts.get(roots.get(m.sid)) || 0; |
| // The soft rev is derived purely from reactions + tally (never myReactions — |
| // viewItems stays pure/exported/tested); a chip's count moves on every |
| @@ -555,6 +585,34 @@ export function viewItems(msgs, { mainFeed = false } = {}) { |
| return out; |
| } |
| +// How one history page's result reads — the distinction every backward walk |
| +// turns on, pulled out pure so it's pinned by a test rather than re-decided at |
| +// each call site. `n` is host.loadOlder's return: rows folded, 0 = the SERVER |
| +// says history ends here, -1 = the FETCH FAILED. |
| +// |
| +// "end" and "error" must never be conflated. Only "end" may latch `exhausted` |
| +// (which kills scroll-back for the rest of the mount) or answer a jump with |
| +// "that message isn't available any more" — an offline blip or a 502 during a |
| +// deploy is neither the start of history nor a deletion, so it stays |
| +// retryable. |
| +export function pageResult(n) { |
| + if (n < 0) return "error"; |
| + if (n === 0) return "end"; |
| + return "page"; |
| +} |
| + |
| +// The inline wording for a jump that couldn't land, by how the backward walk |
| +// ended (pageBackTo's outcome). "" for "found" — nothing to say. Three |
| +// genuinely different answers, because "gone" is final and "capped" means |
| +// keep scrolling; the reader is never left tapping a control that does |
| +// nothing. Pure + exported so the Node suite pins it against its Swift twin, |
| +// ChatFeedModel.jumpReachNote — web, Mac and iOS must say the same thing. |
| +export function jumpReachNote(reach) { |
| + if (reach === "capped") return "That message is further back than this jump reaches — keep scrolling up for it."; |
| + if (reach === "gone") return "That message isn't available any more."; |
| + return ""; |
| +} |
| + |
| // The one wording for a thread's size, wherever it's shown: the root's tally |
| // and the quote-card thread link read as the same thing because they ARE the |
| // same string. Pluralised, and grouped for the locale (1,204 Replies). |
| @@ -563,12 +621,13 @@ export function replyCountLabel(n) { |
| } |
| // Does this reply's quote card double as a link into its thread, and with what |
| -// count? A reply whose thread holds MORE than just it has conversation to go |
| -// and see, so the card says so; a lone quote-reply's card stays exactly as it |
| -// was — a quote, no chip, no noise. The number is the thread's WHOLE tally |
| -// (the same one the root wears), never "the others besides me": two people |
| -// looking at the same thread from different bubbles must read the same size. |
| -// Returns 0 for "no thread link". |
| +// count? Only a THREAD reply ever wears one (an inline reply's threadCount is |
| +// 0 — its card quotes and jumps, nothing more), and only while its thread |
| +// holds more than just it: rendered inline because its root is paged out, its |
| +// card is the one way back into the conversation it belongs to. The number is |
| +// the thread's WHOLE tally (the same one the root wears), never "the others |
| +// besides me": two people looking at the same thread from different bubbles |
| +// must read the same size. Returns 0 for "no thread link". |
| export function threadLinkCount(m) { |
| const n = (m && m.threadCount) || 0; |
| return n > 1 ? n : 0; |
| @@ -789,6 +848,23 @@ export const app = defineApp({ |
| flex: 1 1 auto; min-height: 0; min-width: 0; position: relative; |
| display: flex; flex-direction: column; |
| } |
| +/* The feed's inline status line — the transcript's twin of the thread view's |
| + .chatapp-thread-note, and deliberately NOT a floating toast: it sits in |
| + flow above the feed and takes real layout space (CLAUDE.md's no-toast |
| + rule). Today it answers a jump that has to page history back for its |
| + target, so a tap is never a silent no-op. */ |
| +.chatapp-feed-note { |
| + flex: 0 0 auto; padding: 8px 18px; |
| + font-size: 12px; color: var(--muted); |
| + border-bottom: 1px solid var(--border); |
| +} |
| +/* Empty = no layout, but deliberately NOT display:none. It's a role="status" |
| + live region: display:none takes the node out of the accessibility tree, and |
| + a region that is hidden when its text is written then revealed in the same |
| + tick is announced unreliably (often not at all). Collapsing to nothing keeps |
| + it in the tree, so the next update is announced from a node that was already |
| + there. */ |
| +.chatapp-feed-note:empty { padding: 0; border-bottom: 0; } |
| /* Jump-to-latest: bottom-right of the feed, shown once the reader has |
| scrolled a moderate distance up (see JUMP_BUTTON_GAP). Sits below the |
| reply-thread overlay's z-index (30) so that view still covers it. */ |
| @@ -1221,8 +1297,9 @@ function renderItem(m, ctx) { |
| wrap.dataset.id = m.sid || ""; // its server id (empty while pending) — lets a reply quote scroll back here |
| wrap.dataset.t = m.ts; |
| wrap.dataset.sender = m.name; |
| - // Tag with the resolved thread root (not the quoted id) so the "N Replies" |
| - // jump on the root finds legacy chain replies too. |
| + // Tag with the thread root for a thread reply (replyRoot), the quoted id for |
| + // a plain one — so the "N Replies" jump belt on a root finds its thread's |
| + // replies, and a plain quote still points at what it quotes. |
| if (m.reply && m.reply.id) wrap.dataset.replyTo = m.replyRoot || m.reply.id; |
| // The header shows the sender's CURRENT roster name (looked up by the uid |
| @@ -1271,15 +1348,17 @@ function renderItem(m, ctx) { |
| q.append(who, txt); |
| paintQuoteThreadLink(q, m); |
| q.onclick = () => { |
| - // A quote tap opens the whole thread, anchored on its resolved ROOT (the |
| - // quoted ref may name a mid-chain parent on a legacy reply). replyTarget |
| - // also hands us the root's name/snippet — the header fallback if the root |
| - // has scrolled out of the loaded window. The card is the opener — focus |
| - // returns to it when the view closes. Old scroll-jump kept as a belt |
| - // for a mount without thread controls. |
| + // Two verbs, two cards. A THREAD reply's card is the way into its thread |
| + // — it's only rendered inline because its root is paged out, so opening |
| + // the thread (threadTarget also hands us the root's name/snippet as the |
| + // header fallback) is the whole point; the card is the opener, so focus |
| + // returns to it when the view closes. A PLAIN reply's card is a quote, |
| + // and tapping a quote jumps to the message it quotes — the behaviour |
| + // that predates threads, and the one a reader expects when nothing here |
| + // said "thread". Scroll-jump also covers a mount with no thread controls. |
| const t = threads.get(ctx.threadId); |
| - const ref = replyTarget(((ctx.getState() || {}).msgs) || [], m); |
| - if (t && ref.id) t.open(ref.id, ref, q); |
| + const ref = m.reply.thread ? threadTarget(((ctx.getState() || {}).msgs) || [], m) : null; |
| + if (t && ref && ref.id) t.open(ref.id, ref, q); |
| else scrollToMessage(wrap, m.reply.id); |
| }; |
| content.appendChild(q); |
| @@ -1496,15 +1575,16 @@ function buildReactionsRow(m, ctx) { |
| return row.childElementCount ? row : null; |
| } |
| -// A reply that renders inline in the main feed (a quote reply, and all the |
| -// history from before thread-only replies existed) has always opened its whole |
| -// thread when you tap its quote card — but nothing on the card said so, so the |
| -// thread was undiscoverable from there. When the thread holds more than this |
| -// one reply, the card wears the SAME "N Replies" the root wears: the vocabulary |
| -// members already read as "there's a conversation through here", in the card |
| -// rather than beside it, so it reads as one control. A lone quote-reply's card |
| -// is untouched. Create / update / remove in one place — buildMessageEl calls it |
| -// as the card is built, patchBubble when a sibling reply lands or is deleted. |
| +// The "N Replies" line inside a quote card, marking it as a way INTO a thread |
| +// rather than just a quote. Only a thread reply rendered inline (its root |
| +// paged out) ever wears one — see threadLinkCount, which is the whole gate — |
| +// and then only while its thread holds more than it: the card carries the |
| +// SAME wording the root wears, the vocabulary members already read as |
| +// "there's a conversation through here", inside the card so the whole thing |
| +// reads as one control. A plain quote reply's card never gets one; tapping it |
| +// jumps to the message it quotes. Create / update / remove in one place — |
| +// buildMessageEl calls it as the card is built, patchBubble when a sibling |
| +// reply lands or is deleted. |
| function paintQuoteThreadLink(q, m) { |
| const n = threadLinkCount(m); |
| let line = q.querySelector(":scope > .reply-quote-thread"); |
| @@ -1642,28 +1722,41 @@ function agentTag(kind) { |
| return t; |
| } |
| +// The landing itself: bring a rendered bubble into view and wave it. Shared |
| +// so every route to a message (the transcript, the thread view, the paged-in |
| +// one) ends the same way. |
| +function flashMessage(target) { |
| + target.scrollIntoView({ behavior: "smooth", block: "center" }); |
| + target.classList.remove("flash"); |
| + void target.offsetWidth; // restart the animation if it's still mid-flash |
| + target.classList.add("flash"); |
| +} |
| + |
| // Exported for the shell's search-result jump (#586): `near` is any node |
| // inside the target feed (the feed itself works). |
| export function scrollToMessage(near, sid) { |
| const feed = near.closest(".chatapp-feed"); |
| - let target = feed && feed.querySelector(`.msg[data-id="${sid}"]`); |
| - if (!target) { |
| - // Not in the transcript. A reply made in a thread lives only in that |
| - // thread (its root shows just the tally — inMainFeed), so a jump to one |
| - // (search, a permalink) opens the thread on its root and lands on the |
| - // bubble inside the view rather than scrolling to nothing. The mount's |
| - // thread controls resolve the root from state; the view renders |
| - // synchronously on open, so its body already holds the bubble. |
| - const root = near.closest(".chatapp-root"); |
| - const t = root && threads.get(root.dataset.thread); |
| - if (!t || !t.openForMessage(Number(sid))) return; |
| - target = root.querySelector(`.chatapp-thread-msgs .msg[data-id="${sid}"]`); |
| - if (!target) return; |
| + const target = feed && feed.querySelector(`.msg[data-id="${sid}"]`); |
| + if (target) return flashMessage(target); |
| + const root = near.closest(".chatapp-root"); |
| + const t = root && threads.get(root.dataset.thread); |
| + if (!t) return; |
| + // Not in the transcript, for one of two reasons. |
| + // |
| + // It's loaded but lives only in a thread (a reply made there — inMainFeed |
| + // false): open the thread on its root and land on the bubble inside the |
| + // view. The view renders synchronously on open, so its body already holds |
| + // the bubble. |
| + if (t.openForMessage(Number(sid))) { |
| + const inThread = root.querySelector(`.chatapp-thread-msgs .msg[data-id="${sid}"]`); |
| + if (inThread) flashMessage(inThread); |
| + return; |
| } |
| - target.scrollIntoView({ behavior: "smooth", block: "center" }); |
| - target.classList.remove("flash"); |
| - void target.offsetWidth; // restart the animation if it's still mid-flash |
| - target.classList.add("flash"); |
| + // Or it simply isn't loaded — older than the page we hold. Page history |
| + // back for it (revealMessage), which lands on it or says why it can't. |
| + // Doing nothing here is what made a plain reply's quote card a dead control |
| + // on a cold-opened chat. |
| + t.reveal(Number(sid)); |
| } |
| // "Copy link" (issue #893): mint the permalink through the host (it holds |
| @@ -3458,8 +3551,10 @@ function buildComposerBlock(ctx, host, cfg) { |
| refreshSend, |
| startReply(m) { |
| editing = null; |
| - // Replying to a reply continues its thread: target the TOP message. |
| - replyTo = replyTarget(((ctx.getState() || {}).msgs) || [], m); |
| + // Plain Reply quotes the EXACT message tapped — never a root walk. |
| + // Replying to a reply stacks another quote in the feed; joining a thread |
| + // is the other verb's job ("Reply in thread"), and only that verb's. |
| + replyTo = quoteTarget(m); |
| refreshBanners(); |
| // Embedded: the native shell draws the reply banner and focuses its own |
| // composer (this one is hidden). |
| @@ -3527,7 +3622,28 @@ function buildSkeleton(el, ctx) { |
| // the extra level is invisible to the rest of the shell + native probes. |
| const feedWrap = document.createElement("div"); |
| feedWrap.className = "chatapp-feedwrap"; |
| - feedWrap.appendChild(feed); |
| + // The feed's inline status line, above the transcript and in flow (never a |
| + // floating toast). role="status" so a screen reader hears it politely when |
| + // it changes — a jump that has to page history back announces itself, and |
| + // one that can't reach its target says so instead of doing nothing. |
| + const feedNote = document.createElement("div"); |
| + feedNote.className = "chatapp-feed-note"; |
| + feedNote.setAttribute("role", "status"); |
| + // The note answers something the reader just did, so it clears itself |
| + // rather than going stale across thread switches — the same transient-status |
| + // convention the native busy captions use (FEED_NOTE_MS). A note that's |
| + // still true while work is in flight ("Looking for that message…") passes |
| + // `sticky`: it's replaced by the outcome, never timed out from under it. |
| + const FEED_NOTE_MS = 6000; |
| + let feedNoteTimer = 0; |
| + const setFeedNote = (text, sticky) => { |
| + // Text only — the node stays put and stays in the accessibility tree; CSS |
| + // collapses it when empty (.chatapp-feed-note:empty). |
| + feedNote.textContent = text || ""; |
| + clearTimeout(feedNoteTimer); |
| + if (text && !sticky) feedNoteTimer = setTimeout(() => setFeedNote(""), FEED_NOTE_MS); |
| + }; |
| + feedWrap.append(feedNote, feed); |
| // Jump-to-latest: the classic chat affordance for getting back to the |
| // newest message without hunting for it — shown/hidden by the feed's |
| @@ -3674,15 +3790,16 @@ function buildSkeleton(el, ctx) { |
| composers.set(ctx.threadId, { |
| refreshSend: api.refreshSend, |
| // Under the OVERLAY the main composer IS the open thread's reply box, so a |
| - // Reply tapped on a bubble inside the view (every bubble there is in the |
| - // thread) stages the root WITH the thread flag — the same staging the |
| - // view's open uses — not a plain quote reply to the root, which would |
| - // render inline in the transcript. Docked, pane bubbles route to the |
| - // pane's own composer (composerFor), so this only ever sees the |
| - // transcript's replies. Closures over consts declared further down, run |
| - // on user input only — the hoisted-handlers pattern buildSkeleton uses. |
| + // Reply tapped on a bubble inside the view (the root, or one of its thread |
| + // replies — threadTarget resolves both to this thread) stages the root |
| + // WITH the thread flag, the same staging the view's open uses. Without |
| + // this the plain verb would quote that bubble and land the reply in the |
| + // transcript, out of the thread the reader is standing in. Docked, pane |
| + // bubbles route to the pane's own composer (composerFor), so this only |
| + // ever sees the transcript's replies. Closures over consts declared |
| + // further down, run on user input only — buildSkeleton's hoisted pattern. |
| startReply: (m) => { |
| - if (threadSid && !threadDocked && replyTarget(((ctx.getState() || {}).msgs) || [], m).id === threadSid) { |
| + if (threadSid && !threadDocked && threadTarget(((ctx.getState() || {}).msgs) || [], m).id === threadSid) { |
| stageThreadReply(); |
| if (!inEmbed) composer.focus(); // a deliberate Reply tap wants the caret, touch included (startReply's rule) |
| return; |
| @@ -3953,10 +4070,15 @@ function buildSkeleton(el, ctx) { |
| if (!oldest || !host || !host.loadOlder) return; |
| loadingOlder = true; |
| const prevHeight = feed.scrollHeight; |
| - const n = await host.loadOlder(ctx.threadId, oldest); |
| - if (n === 0) exhausted = true; |
| - feed.scrollTop += feed.scrollHeight - prevHeight; |
| - loadingOlder = false; |
| + try { |
| + // ONLY an empty page is the start of history. A failed fetch must not |
| + // latch `exhausted`, or one offline moment leaves scroll-back dead for |
| + // the rest of this mount (see pageResult). |
| + if (pageResult(await host.loadOlder(ctx.threadId, oldest)) === "end") exhausted = true; |
| + } catch { /* errored page — retryable, like -1 */ } finally { |
| + feed.scrollTop += feed.scrollHeight - prevHeight; |
| + loadingOlder = false; |
| + } |
| }); |
| // ---- reply-thread view: tap a quote or a "N Replies" tally to lift that |
| @@ -3979,14 +4101,16 @@ function buildSkeleton(el, ctx) { |
| // The reply target for a message composed IN this thread: the root (a |
| // fresh snapshot when loaded, the quoted fallback until then) plus the |
| - // thread flag — the wire marker that keeps the reply out of the transcript |
| - // (inMainFeed). replyTarget itself stays pure and flag-free; the flag is |
| - // added here, at the one seam every thread-view send passes through. |
| + // thread flag — the wire marker that makes this a thread reply at all, and |
| + // keeps it out of the transcript (inMainFeed). The pure targets stay |
| + // flag-free; the flag is added here, at the one seam every thread-view send |
| + // passes through. quoteTarget of the loaded root IS the root's own ref (a |
| + // root is never itself in a thread), so no resolution is needed. |
| const threadReplyTarget = () => { |
| const msgs = ((ctx.getState() || {}).msgs) || []; |
| const rootMsg = msgs.find((m) => m.sid === threadSid); |
| const target = rootMsg |
| - ? replyTarget(msgs, rootMsg) |
| + ? quoteTarget(rootMsg) |
| : { id: threadSid, name: (threadFallback && threadFallback.name) || "", snippet: (threadFallback && threadFallback.snippet) || "" }; |
| return { ...target, thread: true }; |
| }; |
| @@ -4182,33 +4306,75 @@ function buildSkeleton(el, ctx) { |
| }; |
| root._refreshThread = refreshThread; |
| - // Page history backward until the thread's root is loaded — bounded, so a |
| - // reply deep in a huge history can't spin the loader forever. Shares the |
| - // scroll-back path's loadingOlder/exhausted guards and keeps the dimmed |
| - // transcript's scroll anchored across each prepend, like scroll-back does. |
| - const THREAD_PAGE_CAP = 5; |
| - const pageInRoot = async () => { |
| - if (threadPaging || loadingOlder) return; |
| - const sid = threadSid; |
| - threadPaging = true; |
| - refreshThread(); // show the inline loading note straight away |
| - for (let i = 0; i < THREAD_PAGE_CAP && threadSid === sid && !exhausted; i++) { |
| + // Page history backward until message `sid` is in the loaded window. Two |
| + // callers want exactly this walk: the thread view fetching its root, and a |
| + // jump whose target is older than the loaded page (revealMessage). Bounded |
| + // by PAGE_BACK_CAP so a target deep in a huge history can't spin the loader |
| + // forever, and it shares the scroll-back path's loadingOlder/exhausted |
| + // guards while keeping the feed's scroll anchored across each prepend, like |
| + // scroll-back does. `stillWanted()` lets a caller abandon the walk (the |
| + // thread closed, another jump started). |
| + // |
| + // Terminates, always: every iteration either returns or performs exactly one |
| + // loadOlder, and `i` climbs unconditionally to the cap. Four more exits sit |
| + // inside it — the target turning up, paging PAST the target (pages are |
| + // contiguous, so a loaded window that starts below `sid` without holding it |
| + // means the message was deleted), a page coming back EMPTY (the start of |
| + // history, latched in `exhausted` for the whole mount), and a page that |
| + // FAILED to fetch. |
| + // |
| + // Empty and failed are deliberately different (see pageResult): an offline |
| + // blip or a 502 mid-deploy is not the start of history, so it must neither |
| + // latch `exhausted` — which would kill scroll-back for the rest of the mount |
| + // — nor answer "gone", which tells the reader a message was deleted when it |
| + // is merely unreachable this second. It answers "capped", the retryable one. |
| + // |
| + // Returns "found" | "gone" (deleted / history exhausted) | "capped" (still |
| + // more history — further back than one jump walks, or a fetch that failed). |
| + // |
| + // PAGE_BACK_CAP × the server's 100-row page is the REACH of one jump, and |
| + // it's pinned to the native walk's (ChatFeedModel.jumpPageCap) so the same |
| + // message gets the same answer everywhere: native passes no `limit` and |
| + // takes the same server default, so both sides walk ~800 messages. |
| + const PAGE_BACK_CAP = 8; |
| + const loaded = (sid) => (((ctx.getState() || {}).msgs) || []).some((m) => m.sid === sid); |
| + const pageBackTo = async (sid, stillWanted) => { |
| + for (let i = 0; i < PAGE_BACK_CAP; i++) { |
| + if (!stillWanted()) return "capped"; // abandoned — the caller decides what that means |
| const msgs = ((ctx.getState() || {}).msgs) || []; |
| - if (msgs.some((m) => m.sid === sid)) break; |
| + if (msgs.some((m) => m.sid === sid)) return "found"; |
| const oldest = msgs.length ? msgs[0].sid : 0; |
| - // Pages are contiguous: once we've loaded past the root's sid without |
| - // finding it, the root was deleted — more paging can't surface it. |
| - if (!oldest || oldest < sid || !host || !host.loadOlder) break; |
| + if (exhausted || !oldest || oldest < sid) return "gone"; |
| + if (!host || !host.loadOlder) return "capped"; |
| loadingOlder = true; // keep the feed's own scroll-back handler out |
| const prevHeight = feed.scrollHeight; |
| - const n = await host.loadOlder(ctx.threadId, oldest); |
| - feed.scrollTop += feed.scrollHeight - prevHeight; |
| - loadingOlder = false; |
| - if (n === 0) { |
| + let n = -1; |
| + try { |
| + n = await host.loadOlder(ctx.threadId, oldest); |
| + } catch { |
| + n = -1; // a host that rejects is an errored page, not the end of history |
| + } finally { |
| + // Always re-anchor and release the flag: a throw here used to latch |
| + // loadingOlder forever, killing scroll-back AND every later walk. |
| + feed.scrollTop += feed.scrollHeight - prevHeight; |
| + loadingOlder = false; |
| + } |
| + const page = pageResult(n); |
| + if (page === "error") return loaded(sid) ? "found" : "capped"; // retryable — exhausted stays untouched |
| + if (page === "end") { |
| exhausted = true; |
| - break; |
| + return loaded(sid) ? "found" : "gone"; |
| } |
| } |
| + return loaded(sid) ? "found" : "capped"; |
| + }; |
| + |
| + const pageInRoot = async () => { |
| + if (threadPaging || loadingOlder) return; |
| + const sid = threadSid; |
| + threadPaging = true; |
| + refreshThread(); // show the inline loading note straight away |
| + await pageBackTo(sid, () => threadSid === sid); |
| threadPaging = false; |
| if (threadSid !== sid) return; // closed / re-opened elsewhere while paging |
| refreshThread(); |
| @@ -4218,6 +4384,80 @@ function buildSkeleton(el, ctx) { |
| if (!api.editing() && staged && staged.id === sid) stageThreadReply(); |
| }; |
| + // revealMessage(sid): a jump whose target isn't rendered anywhere — the |
| + // quote card above a plain reply whose parent is older than the loaded page |
| + // (the DEFAULT for every reply in existing history, since none of it |
| + // carries the thread flag), a search hit, a permalink. It used to be a |
| + // silent no-op: tap the card on a cold-opened chat and nothing happened at |
| + // all. Now the walk pages back for it and lands on it — and when it can't, |
| + // the reader is told, inline in the feed, rather than left tapping a dead |
| + // control. |
| + let revealSid = 0; // the target of the walk in flight (0 = none) |
| + const revealMessage = async (sid) => { |
| + if (!sid || revealSid === sid) return; // already walking for this one |
| + // A DIFFERENT target supersedes: the walk in flight sees stillWanted() go |
| + // false, drops out, and leaves the note to us (its own `revealSid !== sid` |
| + // check keeps it from writing one). Tapping a second card must not be |
| + // answered by flashing the first one's target. |
| + revealSid = sid; |
| + setFeedNote("Looking for that message…", true); // sticky: the outcome replaces it |
| + let outcome = "capped"; |
| + try { |
| + // The reader may already have been scroll-loading history when they |
| + // tapped (or the superseded walk may still be mid-fetch). Both paths |
| + // drive host.loadOlder and each re-anchors scrollTop against its OWN |
| + // pre-prepend height, so overlapping them would jerk the feed — wait the |
| + // in-flight page out instead, bounded so a wedged fetch can't hold the |
| + // walk open. Still busy after that reads as "couldn't get there from |
| + // here", which is what the capped note already says. |
| + for (let i = 0; i < 20 && loadingOlder && revealSid === sid; i++) { |
| + await new Promise((r) => setTimeout(r, 100)); |
| + } |
| + if (revealSid !== sid) return; |
| + if (!loadingOlder) outcome = await pageBackTo(sid, () => revealSid === sid); |
| + if (revealSid !== sid) return; // superseded; whoever took over owns the note |
| + // Found and rendered: land on it. The page folded through |
| + // host.loadOlder, which repaints before it resolves, so the bubble |
| + // exists by now. A hit that folded but still isn't in the transcript (it |
| + // turned out to live in a thread) hands off to the thread view, the same |
| + // as a loaded one. |
| + const target = outcome === "found" && feed.querySelector(`.msg[data-id="${sid}"]`); |
| + const t = threads.get(ctx.threadId); |
| + const inThread = !target && outcome === "found" && t && t.openForMessage(sid) |
| + ? root.querySelector(`.chatapp-thread-msgs .msg[data-id="${sid}"]`) |
| + : null; |
| + if (target || inThread) { |
| + setFeedNote(""); |
| + // STOP THE FEED FOLLOWING BEFORE SCROLLING. Every page we just folded |
| + // re-rendered the feed, and render() decides "was at bottom" BEFORE |
| + // the prepend — so from the bottom of a busy chat each page started a |
| + // glide, and a live glide both overwrites scrollTop every frame |
| + // (cancelling the smooth scrollIntoView below on its very next frame) |
| + // and reports "at bottom" to the next render, re-arming itself. The |
| + // symptom was the note flashing and the feed never moving — and only |
| + // with Reduce Motion OFF, since the reduced-motion path jumps instead |
| + // of gliding. feedAtBottom goes with it so the ResizeObserver's |
| + // re-pin bails too (the note's row vanishing is a resize). |
| + feedCancelGlide(feed); |
| + feedAtBottom = false; |
| + flashMessage(target || inThread); |
| + return; |
| + } |
| + // Never silent. A "found" that still has nothing to land on reads the |
| + // same as gone from where the reader sits; "capped" is the one answer |
| + // that isn't final (further back than one jump walks, or a fetch that |
| + // failed), so it says so. |
| + setFeedNote(jumpReachNote(outcome === "capped" ? "capped" : "gone")); |
| + } finally { |
| + // Never leave the walk (or its sticky note) latched: without this a |
| + // throw anywhere above would block every future reveal for the mount. |
| + if (revealSid === sid) { |
| + revealSid = 0; |
| + if (feedNote.textContent === "Looking for that message…") setFeedNote(jumpReachNote("capped")); |
| + } |
| + } |
| + }; |
| + |
| const openThread = (rootSid, ref, opener) => { |
| if (!rootSid) return; |
| if (threadSid === rootSid && threadEl) { |
| @@ -4281,7 +4521,7 @@ function buildSkeleton(el, ctx) { |
| paneComposer: () => (threadDocked && paneBlock ? paneBlock.api : null), |
| // openForMessage(sid): a message that lives only in its thread (a reply |
| // made there — inMainFeed false) has no transcript bubble to scroll to, |
| - // so a jump to it opens the thread on the resolved root instead |
| + // so a jump to it opens the thread on its root instead |
| // (scrollToMessage's fallback). False when the message isn't loaded or |
| // belongs in the transcript — then it's a plain scroll miss, not ours. |
| // (A thread reply whose root is paged out renders inline, so the |
| @@ -4290,11 +4530,17 @@ function buildSkeleton(el, ctx) { |
| const msgs = ((ctx.getState() || {}).msgs) || []; |
| const m = msgs.find((x) => x.sid === sid); |
| if (!m || inMainFeed(m, bySidIndex(msgs))) return false; |
| - const ref = replyTarget(msgs, m); |
| + const ref = threadTarget(msgs, m); |
| if (!ref.id) return false; |
| openThread(ref.id, ref, null); |
| return true; |
| }, |
| + // reveal(sid): the other half of the same jump — a target that isn't |
| + // loaded at all. Pages history back for it and lands on it, or says |
| + // inline why it can't (revealMessage). Mount-scoped like openForMessage, |
| + // which is why it rides this registry rather than being a feed concern of |
| + // its own. |
| + reveal: revealMessage, |
| }); |
| // Keyboard shortcuts, mirroring the original feed: with an empty composer, |
diff --git a/web/static/shell.js b/web/static/shell.js| index 05aa68d..199bf1c 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -5657,20 +5657,26 @@ async function chatAppUpload(threadId, file, onProgress) { |
| // chatAppLoadOlder folds one older history page into the chat app (scroll-back |
| // pagination). Messages arrive ascending; each folds with its reactions and |
| // edited flag, and any mini-app events in the page fold to their own apps too. |
| -// Returns how many messages the page held (0 = start of history). |
| +// |
| +// Returns how many messages the page held: 0 means the SERVER said there are |
| +// none left (the start of history), and **-1 means the fetch failed**. Callers |
| +// latch "exhausted" on 0 and must not on -1 — an offline blip or a 502 mid |
| +// deploy is not the start of history, and treating it as one both kills |
| +// scroll-back for the rest of the mount and lets a jump tell the reader a |
| +// message was deleted when it's simply unreachable right now. |
| async function chatAppLoadOlder(threadId, beforeSid) { |
| const key = threadKeys[threadId]; |
| if (!key) return 0; |
| let msgs = []; |
| try { |
| - const r = await ( |
| - await fetch( |
| - `/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(threadId)}&before=${beforeSid}&limit=100`, |
| - ) |
| - ).json(); |
| + const res = await fetch( |
| + `/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(threadId)}&before=${beforeSid}&limit=100`, |
| + ); |
| + if (!res.ok) return -1; // 5xx during a deploy, 401 on a stale token, … |
| + const r = await res.json(); |
| msgs = r.messages || []; |
| } catch { |
| - return 0; |
| + return -1; // offline, DNS, aborted, malformed JSON |
| } |
| if (!embed) storePutMessages(threadId, msgs).catch(() => {}); |
| for (const m of msgs) await chatAppFoldMessage(threadId, m); |