web+core: a quoted reply's card says how big its thread is (#1479)
* web: a quoted reply's card says how big its thread is
A reply that renders inline in the main feed — a quote reply, and all the
history from before the replyTo.thread marker existed — has always opened its
WHOLE thread when you tap its quote card. Nothing on the card said so, so the
thread was undiscoverable from there: it just reads as a quote.
When the resolved thread holds MORE than this one reply, the card now wears the
same link-coloured "N Replies" the root wears, appended inside the card (not a
trailing chip: the card's one narrow row is already the name line plus an
ellipsized snippet, and a badge stuck on the side reads as decoration rather
than as the way in). A lone quote-reply — its root has exactly this one reply —
keeps today's card untouched: no chip, no noise.
The number is the thread's WHOLE tally, sourced from the same replyCounts map
the root's tally comes from (new viewItems field `threadCount`), so the card and
the root can never disagree. Two pure exported helpers carry the decision and
the wording — `threadLinkCount(m)` (0 = no link) and `replyCountLabel(n)`, now
the one source of "1 Reply" / "N Replies" for the tally too, with the count
localised (toLocaleString). Both Node-tested, boundary included.
The link rides the SOFT rev, so a sibling reply landing repaints the card's text
in place rather than rebuilding the bubble (a photo must never reload). The
segment is appended only when a reply actually carries a thread link, so a
root's signature is unchanged.
The card is already a real <button>; it now gets the tally's focus ring and an
accessible name that says what it does ("Open thread, 4 Replies — quoting …"),
keeping the visible label inside the spoken one (WCAG 2.5.3).
No wire, fold, inMainFeed or event-vector changes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* core: the same thread link on a quoted reply's card (Mac + iOS)
The native twin of the web change: MessageBubble's quote card wears the
thread's "N Replies" when the thread holds more than that one reply, so the
thread is discoverable from an inline reply on both platforms at once.
One change serves both apps — every surface (Mac NativeFeedView, iOS
StockChatFeed/FlippedChatFeed, the thread panes) draws its bubbles through
MessageBubble, and the pane rows already suppress the card via inReplyThread,
exactly as the web's CSS does.
The count comes from where the root's tally comes from: refreshReplyCounts now
also stamps each reply with `threadReplyCount` (its root's tally), the twin of
viewItems' `threadCount`. ChatFeedModel gains the two pure statics mirroring
chat.js — `threadLinkCount(_:)` (0 = no link) and `replyCountLabel(_:)`, now the
one source of "1 Reply" / "N Replies" for repliesTag too, with digits
locale-grouped (formatted(.number)). Tested in ReplyThreadVectorTests, boundary
included: one reply → plain card, a second landing → the link, always the
root's own number.
The card carries an accessibility label that says what tapping it does
("Open thread, 4 Replies — quoting …"); the plain card keeps VoiceOver's
default reading. The new field is part of Message's synthesized equality, so
the iOS list's diff reconfigures (and re-measures) the row when a thread grows.
No wire, fold or event-vector changes. Changelog holds for the next native mint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+243
−7
changelog/2026-08-28-thread-pane.md+4 −1ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+25 −0ios/LChatCore/Sources/LChatCore/MessageBubble.swift+45 −2ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift+49 −0test/apps.test.mjs+35 −1web/static/apps/chat.js+70 −3web/static/style.css+15 −0
diff --git a/changelog/2026-08-28-thread-pane.md b/changelog/2026-08-28-thread-pane.md| index 0d8f2f9..e642dd3 100644 |
| --- a/changelog/2026-08-28-thread-pane.md |
| +++ b/changelog/2026-08-28-thread-pane.md |
| @@ -8,4 +8,7 @@ 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. |
| +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. |
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift| index 4900549..590e5b7 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| @@ -43,6 +43,11 @@ public final class ChatFeedModel: ObservableObject { |
| // For a reply: the resolved sid of its thread's TOP message (the web's |
| // `replyRoot`) — one hop for new replies, chased for legacy chains. |
| public var threadRootID: Int64? |
| + // 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. |
| + 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). |
| public var raw = MessagePayload() |
| @@ -1909,6 +1914,26 @@ public final class ChatFeedModel: ObservableObject { |
| list[i].replyCount = counts[list[i].id] ?? 0 |
| list[i].firstReplyID = first[list[i].id] |
| list[i].threadRootID = roots[list[i].id] |
| + // A reply reads its thread's size off the same tally its root wears. |
| + list[i].threadReplyCount = roots[list[i].id].flatMap { counts[$0] } ?? 0 |
| } |
| } |
| + |
| + /// The one wording for a thread's size, wherever it's shown: the root's |
| + /// tally and a quoted reply's thread link read as the same thing because |
| + /// they ARE the same string. Pluralised, and grouped for the locale |
| + /// (1,204 Replies). Mirrors chat.js's `replyCountLabel`. |
| + nonisolated public static func replyCountLabel(_ n: Int) -> String { |
| + n == 1 ? "1 Reply" : "\(n.formatted(.number)) Replies" |
| + } |
| + |
| + /// 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 |
| + /// (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 { |
| + m.threadReplyCount > 1 ? m.threadReplyCount : 0 |
| + } |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift| index 2a1fa46..179194c 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| @@ -429,8 +429,18 @@ public struct MessageBubble: View { |
| // The tappable quote of the parent, above the bubble (à la Messages) — |
| // 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. |
| private func replyQuote(_ reply: ReplyRef) -> some View { |
| - Button { |
| + 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 |
| @@ -452,6 +462,13 @@ public struct MessageBubble: View { |
| .foregroundStyle(.secondary) |
| .lineLimit(1) |
| .frame(maxWidth: 220, alignment: message.mine ? .trailing : .leading) |
| + if threadLink > 0 { |
| + Text(ChatFeedModel.replyCountLabel(threadLink)) |
| + .font(.system(size: 11, weight: .semibold)) // the tally's own type |
| + .foregroundStyle(theme.accent.resolved(scheme)) |
| + .monospacedDigit() |
| + .padding(.top, 1) |
| + } |
| } |
| .padding(EdgeInsets(top: 6, leading: 11, bottom: 6, trailing: 11)) |
| .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous) |
| @@ -459,6 +476,32 @@ public struct MessageBubble: View { |
| .contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) |
| } |
| .buttonStyle(.plain) |
| + // The card's own name says what tapping it does — "4 Replies" alone is |
| + // a number read aloud. It keeps the visible label inside the spoken one |
| + // (WCAG 2.5.3) and carries the quote it replaced. |
| + .modifier(QuoteLabel(text: threadLink > 0 |
| + ? quoteThreadAccessibilityLabel(reply, count: threadLink) : nil)) |
| + } |
| + |
| + // Applied only when there's something to say: an unconditional |
| + // .accessibilityLabel would replace the plain card's default reading |
| + // (its name + snippet) with whatever we handed it. |
| + private struct QuoteLabel: ViewModifier { |
| + let text: String? |
| + func body(content: Content) -> some View { |
| + if let text { |
| + content.accessibilityLabel(Text(text)) |
| + } else { |
| + content |
| + } |
| + } |
| + } |
| + |
| + // "Open thread, 4 Replies — quoting Al: see you at six". |
| + private func quoteThreadAccessibilityLabel(_ reply: ReplyRef, count: Int) -> String { |
| + let opener = "Open thread, \(ChatFeedModel.replyCountLabel(count))" |
| + let quoted = [reply.name, reply.snippet].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ": ") |
| + return quoted.isEmpty ? opener : "\(opener) — quoting \(quoted)" |
| } |
| // "N Replies" under a message that's been replied to — jumps to the first |
| @@ -474,7 +517,7 @@ public struct MessageBubble: View { |
| onJump?(id) |
| } |
| } label: { |
| - Text(message.replyCount == 1 ? "1 Reply" : "\(message.replyCount) Replies") |
| + Text(ChatFeedModel.replyCountLabel(message.replyCount)) |
| .font(.system(size: 11, weight: .semibold)) |
| .foregroundStyle(theme.accent.resolved(scheme)) |
| .padding(.horizontal, 6) |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift| index 9182c6d..482037f 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift |
| @@ -56,6 +56,55 @@ final class ReplyThreadVectorTests: XCTestCase { |
| XCTAssertEqual(list.first { $0.id == 101 }?.replyCount, 0, "a mid-thread reply never tallies") |
| } |
| + // ---- 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. |
| + |
| + func testQuoteCardThreadLinkIsTheRootsOwnTally() { |
| + var list = vectorMessages() |
| + ChatFeedModel.refreshReplyCounts(&list) |
| + let root = list.first { $0.id == 100 }! |
| + for id: Int64 in [101, 102, 103, 104] { |
| + 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(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, |
| + at: 1, edited: false)), 0, |
| + "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))) |
| + ChatFeedModel.refreshReplyCounts(&list) |
| + XCTAssertEqual(ChatFeedModel.threadLinkCount(list[1]), 2) |
| + XCTAssertEqual(list[0].replyCount, 2, "the same number the root's own tally shows") |
| + } |
| + |
| + func testReplyCountLabelPluralisesAndLocalises() { |
| + XCTAssertEqual(ChatFeedModel.replyCountLabel(1), "1 Reply", "never '1 Replies'") |
| + XCTAssertEqual(ChatFeedModel.replyCountLabel(2), "2 Replies") |
| + XCTAssertEqual(ChatFeedModel.replyCountLabel(1204), "\(1204.formatted(.number)) Replies", |
| + "digits are locale-grouped") |
| + } |
| + |
| // ---- 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 |
diff --git a/test/apps.test.mjs b/test/apps.test.mjs| index f8abac0..5d3abcb 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 } from "../web/static/apps/chat.js"; // the chat app |
| +import { app as chatApp, viewItems, threadItems, inMainFeed, bySidIndex, newlyDirtyMentionSids, threadLinkCount, replyCountLabel } 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 |
| @@ -1328,6 +1328,40 @@ 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", () => { |
| + 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). |
| + 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(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. |
| + 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"); |
| +}); |
| + |
| +await test("chat: replyCountLabel pluralises and localises — one wording for the tally and the quote card's thread link", () => { |
| + assert.equal(replyCountLabel(1), "1 Reply", "never '1 Replies'"); |
| + assert.equal(replyCountLabel(2), "2 Replies"); |
| + assert.equal(replyCountLabel(0), "0 Replies", "the zero case reads as a plural too (never rendered — the tally hides)"); |
| + assert.equal(replyCountLabel(1204), (1204).toLocaleString() + " Replies", "digits are locale-grouped"); |
| +}); |
| + |
| await test("chat: reactions, edits, deletes fold as host translations of server frames", () => { |
| const reduce = chatApp.reduce; |
| let s = reduce(undefined, { type: "chat.msg", text: "hello", name: "Al", ts: 1, uid: "alice" }, { id: 20 }); |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index f3f6486..ef1890a 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -524,6 +524,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. |
| + 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 |
| // react/unreact, so this signature captures mine-flips too. |
| @@ -533,18 +538,42 @@ export function viewItems(msgs, { mainFeed = false } = {}) { |
| timeSep, |
| cont, |
| replyCount, |
| + threadCount, |
| replyRoot: roots.get(m.sid) || 0, |
| vrev: `${m.rev}|${timeSep ? (timeSep.withDay ? "D" : "t") : ""}|${cont ? 1 : 0}`, |
| // Delivery state rides the SOFT rev: the pending→slow→confirmed transitions |
| // patch the bubble in place (data-id, the "sending…" chip, action affordances) |
| // without rebuilding it — so an optimistic photo never flashes on confirm. |
| - srev: `${reactSig}|${replyCount}|${m.pending ? (m.slow ? "slow" : "send") : "ok"}|${m.sid ? 1 : 0}`, |
| + // The quote card's thread link is soft too (patchBubble repaints it in |
| + // place), and only a reply ever carries one — so the segment stays absent |
| + // on everything else and a root's signature is unchanged. |
| + srev: `${reactSig}|${replyCount}|${m.pending ? (m.slow ? "slow" : "send") : "ok"}|${m.sid ? 1 : 0}` + |
| + (threadCount ? `|t${threadCount}` : ""), |
| }); |
| prev = m; |
| } |
| return out; |
| } |
| +// 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). |
| +export function replyCountLabel(n) { |
| + return n === 1 ? "1 Reply" : `${n.toLocaleString()} Replies`; |
| +} |
| + |
| +// 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". |
| +export function threadLinkCount(m) { |
| + const n = (m && m.threadCount) || 0; |
| + return n > 1 ? n : 0; |
| +} |
| + |
| export const app = defineApp({ |
| name: "chat", |
| title: "Chat", |
| @@ -1240,6 +1269,7 @@ function renderItem(m, ctx) { |
| txt.className = "reply-quote-text"; |
| txt.textContent = m.reply.snippet; |
| 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 |
| @@ -1452,6 +1482,37 @@ 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. |
| +function paintQuoteThreadLink(q, m) { |
| + const n = threadLinkCount(m); |
| + let line = q.querySelector(":scope > .reply-quote-thread"); |
| + if (!n) { |
| + if (line) line.remove(); |
| + q.removeAttribute("aria-label"); |
| + return; |
| + } |
| + const label = replyCountLabel(n); |
| + if (!line) { |
| + line = document.createElement("div"); |
| + line.className = "reply-quote-thread"; |
| + q.appendChild(line); |
| + } |
| + line.textContent = label; |
| + // The card's own name says what tapping it does — "4 Replies" alone is a |
| + // number read aloud. It keeps the visible label inside the spoken one |
| + // (WCAG 2.5.3) and carries the quote it replaced. |
| + const quoted = [m.reply && m.reply.name, m.reply && m.reply.snippet].filter(Boolean).join(": "); |
| + q.setAttribute("aria-label", quoted ? `Open thread, ${label} — quoting ${quoted}` : `Open thread, ${label}`); |
| +} |
| + |
| // The "N Replies" tally below a bubble, tappable to open the thread view. The |
| // tally only ever renders under a loaded root (it IS this message), so no |
| // paging is needed on this path. Old jump-to-first-reply kept as a belt for a |
| @@ -1462,7 +1523,7 @@ function buildReplyCount(m, wrap, ctx) { |
| const rc = document.createElement("button"); |
| rc.type = "button"; |
| rc.className = "reply-count"; |
| - rc.textContent = m.replyCount === 1 ? "1 Reply" : m.replyCount + " Replies"; |
| + rc.textContent = replyCountLabel(m.replyCount); |
| rc.onclick = () => { |
| const t = threads.get(ctx.threadId); |
| if (t) { |
| @@ -1503,6 +1564,12 @@ function patchBubble(node, m, ctx) { |
| attachActions(wrap, content, m, ctx); |
| } |
| } |
| + // The quote card's thread link: a sibling reply landing (or being deleted) |
| + // moves this reply's thread size, so repaint it. Surgical by design — the |
| + // card's own text node, nothing else in .content is read or replaced, so the |
| + // photo/embed rule above still holds. |
| + const quote = content && content.querySelector(":scope > .reply-quote"); |
| + if (quote) paintQuoteThreadLink(quote, m); |
| // Reactions: rebuild the row and reinsert it right after .content, so it stays |
| // above the reply/edited tags regardless of which of them are present. |
| const oldRow = wrap.querySelector(":scope > .reactions"); |
| @@ -1513,7 +1580,7 @@ function patchBubble(node, m, ctx) { |
| // deleted, dropping the parent's tally on the next viewItems). |
| let rc = wrap.querySelector(":scope > .reply-count"); |
| if (m.replyCount) { |
| - if (rc) rc.textContent = m.replyCount === 1 ? "1 Reply" : m.replyCount + " Replies"; |
| + if (rc) rc.textContent = replyCountLabel(m.replyCount); |
| else { |
| rc = buildReplyCount(m, wrap, ctx); |
| const edited = wrap.querySelector(":scope > .edited-tag"); |
diff --git a/web/static/style.css b/web/static/style.css| index ac7ee69..25b8ad6 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -1290,6 +1290,21 @@ select:disabled { opacity: 0.5; cursor: default; } |
| white-space: nowrap; |
| } |
| .msg.me .reply-quote-text { margin-left: auto; } |
| +/* The card's thread link: when the thread behind a quoted reply holds more than |
| + that one reply, the card wears the same link-coloured "N Replies" the root |
| + wears — appended inside the card (not a trailing chip, which would fight the |
| + name line and the ellipsized snippet for the card's one narrow row), so the |
| + whole card reads as one way in rather than a quote with a badge stuck on. */ |
| +.reply-quote-thread { |
| + font-size: 11px; |
| + font-weight: 600; |
| + color: var(--link); |
| + margin-top: 2px; |
| + font-variant-numeric: tabular-nums; |
| +} |
| +/* The card has always been the thread's other opener — give it the same visible |
| + focus ring the tally has, so keyboard travel through the feed shows it. */ |
| +.reply-quote:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } |
| /* "N Replies" tag under a message that's been replied to (opens the thread view). |
| A <button> like the quote card — keyboard-reachable, and the opener the view |
| hands focus back to — so the base pill chrome is reset to the old tag look. */ |