web+native: Convert to thread — a chain that got long in the chat becomes a thread (#1493)
* web: Convert to thread — take a long inline reply chain out of the feed
Inline replies and threads are separate verbs, and every reply in the
history from before that split is inline: the only way a long chain
becomes a thread is after the fact. The messages can't be rewritten to
say so — only their author may edit one, only for fifteen minutes, and
rewriting N messages would churn N authorships and timestamps to change
a display rule — so a conversion is its own encrypted event, exactly as
a reaction is: an ordinary message carrying { type: "threadconvert",
root, ids } that every client folds client-side. The server stores one
more opaque blob and learns nothing; a client too old to know the type
drops it and goes on showing the chain inline.
The fold is a claim map (sid → { root, at }) projected onto the loaded
window as a `movedTo` stamp, which threadRootOf reads beside the wire
flag — so the tally, the transcript filter, the thread view, the ▤
Threads list and both compose verbs all work unchanged. The merge rule
is a pure function of the SET of events, never their arrival order: the
oldest conversion owns a message, a repeat is a no-op, a claim whose
root was itself converted flattens onto the outermost root (threads
never nest), and a cycle — which only a made-up payload can produce —
leaves everything inline.
The verb is offered only where there IS a chain to move (inlineChainIds,
the transitive quote walk back in the one narrow place that needs it —
on the converting client, at menu time, never as a membership rule), so
never on a message nobody answered and never inside a thread pane. It
confirms, because it reorganises the chat for everyone and v1 has no
undo.
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+ios+mac: Convert to thread on native, and the shared fold
The native half of "Convert to thread" — same encrypted event, same fold
rules, pinned against the same vectors as the web.
ChatFeedModel gains the conversion state (a claim map: message id → the
thread it moved into, plus the convert event's own server id as the
tie-break) and its projection onto the loaded window (`movedTo`, which
threadRootID now reads beside the wire flag). One rollup seam —
refreshRollup — applies the conversions and re-derives the tallies, so
no fold path (history page, live frame, edit, delete, optimistic send)
can publish a list that forgot one. mainFeedItems now asks threadRootID
rather than re-checking the flag itself, which is what let a converted
message stay in the transcript.
Conversions reach the model three ways and settle identically: a live
"threadconvert" frame, a history page, and the local store (the NSE and
background sync park rows while the app is suspended) — so a cold open
shows a converted chain already in its thread instead of inline for the
moment before the network page re-folds it.
The verb rides MessageActions, so it appears in the iOS context menu,
the iOS long-press sheet and the Mac right-click NSMenu from one gate:
`hasInlineChain`, folded once per publish because the containers build a
menu for EVERY row on screen and the chain walk is O(list). The vectors
pin the flag as exactly `!inlineChainIDs(…).isEmpty`. It confirms
(ChatFeedModel.convertConfirm — one wording for all three platforms) and
is nil inside a thread view, where nothing inline hangs off a bubble.
The notification extension ships a conversion as a silent, passive entry
rather than "New message": nobody said anything. It still ingests the
row, so the app opens with the chain already tucked away. Older installed
builds have no such branch — they will show a plain banner for a
conversion until they update.
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)
* web+core: an unloaded converted root is named by nothing, never by the wrong message
threadTarget's paged-out-root fallback assumed the reply's frozen ref IS
the root's snapshot. That holds for a FLAGGED thread reply (every thread
send derives its ref from the root) but not for a converted one, whose
ref is whatever it quoted. So a thread whose root had scrolled out could
be headed — in the ▤ Threads list, and in the thread view — by the name
and text of a message that isn't its root.
Now the id travels alone in that case: the reply still lands in the
right thread, and the view says the original isn't available rather than
naming the wrong message. Flagged replies are unchanged.
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)
* web: keep Convert to thread out of the thread view's own menus
A thread's ROOT renders inside the thread view as its first message, and
it can still have inline replies out in the feed — so the chain under it
is genuinely non-empty and the verb was appearing on that one bubble.
Tidying feed replies is something you do while looking at the feed, so
the menus opened from inside a thread panel don't offer it. Matches what
the native containers already do (onConvertToThread = nil in the
pane/thread catalogues).
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)
* web: a converted reply's quote card opens the thread it advertises
Rendered inline (its root paged out), a converted reply's card wears the
same "N Replies" thread link a flagged one does — paintQuoteThreadLink
reads the same rollup — but tapping it jumped to the message it quoted,
because the open was gated on the wire flag rather than on threadRootOf.
So the card advertised a thread and didn't open it.
Now it asks the same question everything else asks, which is also the
question native already asked (MessageBubble's replyQuote →
threadRootID) — so this is the web catching up to the Mac and iOS.
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)
* docs: map ICON_FOLD_IN in the icon table
Convert to thread's glyph, and which of its four surfaces actually wear
one (the web picker's rows are icon-only; the iOS context menu and the
Mac NSMenu are text by construction).
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)
* web: comment accuracy — the iOS extension can't drop a push, it silences one
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)
* ios: title-case the Convert to Thread alert, matching its menu item
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)
* ios: hold the conversion's count so the confirm doesn't flash '0 replies' on dismiss
The alert's message is re-evaluated as it dismisses, by which time the
target is already nil.
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)
* web+core: a failed conversion says so, and the fold stops paying per message
Four findings from review, all real:
**A conversion that can't be sent was silent on both platforms.** Nothing
about a failed one is visible — the chain simply stays where it was,
which is exactly what it looks like when nobody converted anything — so
after confirming a one-way act the reader was left believing it
happened. convertToThread now reports (@discardableResult -> Bool
natively), and both surfaces say so inline: a private "only visible to
you" notice on web, the FeedNoteBar on Mac and iOS. Never a toast.
**The web re-resolved every claim once per folded message.** applyConverts
walks the whole claim map, and the chat.msg fold called it for every
arrival — so a hundred-row history page did a hundred full resolutions.
It now asks one O(1) question first (does a conversion name THIS sid?),
which is exact: a claim resolves against the claim map alone, never
against the loaded window, so an arrival can't change any other
message's stamp.
**Nothing bounded a conversion's id list.** The server caps a blob at
64KB and can't read it, so a hostile client could name thousands per
event into a map that is never pruned. Both folds take a generous slice
(2000 — far above any real chain), pinned by a test on each side.
**Two native paths, from the same review:** a live convert frame skipped
tamper-evidence gossip that the same event arriving in a history page
gets; and foldPage had grown to THREE decrypts per ordinary row (inside
the loop Perf watches at 250ms) — one now serves both event checks.
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)
* web: a Threads row whose root scrolled out says so, instead of a blank line
A converted chain has no truthful stand-in when its root is paged out
(only a flagged reply's ref is its root's own snapshot), so the row
rendered an empty line above its tally and read 'Open thread, 3 Replies,'
to a screen reader. It now says what the thread view says in the same
case.
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)
* web+core: a conversion moves a CHAIN, and web validates it the way Swift does
Three findings from independent review of #1493.
**Web coerced where Swift type-checks, so the two platforms folded the
same event differently.** MessagePayload types root/ids as Int64, so
`{root:"400", ids:["401","402"]}` fails to decode natively and the whole
payload is dropped — while web's `Number(x) || 0` performed a full
working conversion. Same messages, a different transcript per platform,
which the cross-platform contract calls a hard error. And `root: -1`
stamped movedTo=-1: a phantom tally, a nameless row in the ▤ Threads
list, and a "Reply in thread" that would persist a real message anchored
to an id that can never exist. Web now validates with Number.isSafeInteger
and mirrors Swift's guards (root/at > 0, each id > 0 and != root), whole
payload or single id.
**The tie-break wasn't pinned by any vector.** In
convert_overlapping_and_repeated both competing events name the SAME
root, so oldest-wins and newest-wins produce identical output — flipping
the rule failed 0 of 8 assertions. convert_conflicting_claims_oldest_wins
has two conversions disagree about the root, with roots in different
threads so the flatten can't mask it; flipping the rule now fails two
vector tests and a unit test.
**The shipped primitive was "move any N messages under any root".** A
receiver took the id list at face value, and a conversion renders no
bubble, the oldest claim wins forever, and deleting the convert row is
not an undo — so one crafted payload could quietly hide a couple of
hundred unrelated messages under an old root, unattributably. A claim
now holds only when the message's own quote is the root or another
claimed message, checked against the message itself on every client. It
stays order-independent and needs no loaded window: a claimed message
that hasn't arrived has no quote to check and stays inline until it
does, the same degradation as a thread reply whose root is paged out.
Also: the iOS confirm's count is now set in the same update as its
target (an onChange could not seed it before the alert's first render);
the chat-list line counts what the fold took, not what the payload asked
for; and the "threads don't nest" comment now says what is actually
guaranteed — claim-rooted nesting flattens, but a wire-flagged reply is
authoritative and can end up one level inside a converted thread if a
"Reply in thread" is in flight while someone converts the chain.
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>
23 files changed,
+2360
−67
changelog/2026-08-28-thread-pane.md+8 −0docs/icons.md+6 −0ios/LChat/Views/NativeChatScreen.swift+78 −0ios/LChat/Views/StockChatFeed.swift+7 −0ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift+11 −0ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+347 −25ios/LChatCore/Sources/LChatCore/MessageBubble.swift+13 −0ios/LChatCore/Sources/LChatCore/MessageRightClick.swift+6 −0ios/LChatCore/Sources/LChatCore/Models.swift+22 −0ios/LChatCore/Sources/LChatCore/ThreadStore.swift+4 −4ios/LChatCore/Tests/LChatCoreTests/ChatVectors.swift+28 −0ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift+28 −0ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift+227 −2ios/LChatMac/NativeFeedView.swift+49 −0ios/LChatNSE/NotificationService.swift+32 −0test/apps.test.mjs+147 −1test/event-vectors.test.mjs+47 −13test/event-vectors/chat.json+897 −0test/format.test.mjs+10 −1web/static/apps/chat.js+309 −14web/static/format.js+12 −0web/static/icons.js+12 −0web/static/shell.js+60 −7
diff --git a/changelog/2026-08-28-thread-pane.md b/changelog/2026-08-28-thread-pane.md| index e09fb77..c54821c 100644 |
| --- a/changelog/2026-08-28-thread-pane.md |
| +++ b/changelog/2026-08-28-thread-pane.md |
| @@ -15,6 +15,14 @@ reply arrow, drawn as two speech bubbles so the two are easy to tell apart. |
| A thread reads as a small chat of its own: the message it's about is simply |
| the first message in it, with the replies underneath, all scrolling together. |
| +And a conversation that already got long in the chat can be moved into one. |
| +Pick **Convert to thread** on the message a reply chain hangs off, and the |
| +whole chain tucks itself into a thread on that message — everyone sees it |
| +there instead of strung down the chat, with the usual "N Replies" tally left |
| +in its place. It's offered only where there's actually a chain to move, and |
| +it's one-way, so it asks first. This is how the conversations you had before |
| +threads existed become threads. |
| + |
| 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 |
diff --git a/docs/icons.md b/docs/icons.md| index cbfb590..8772c53 100644 |
| --- a/docs/icons.md |
| +++ b/docs/icons.md |
| @@ -42,6 +42,7 @@ Three kinds of mark, three sources, one rule about emoji. |
| |---|---|---| |
| | Reply | `ICON_REPLY` (hand-drawn, pre-Lucide) | `arrowshape.turn.up.left` | |
| | Reply in thread | `ICON_THREAD` (messages-square) | `bubble.left.and.bubble.right` | |
| +| Convert to thread | `ICON_FOLD_IN` (fold-vertical) | `arrow.down.right.and.arrow.up.left` | |
| | Close / dismiss | `ICON_X` | `xmark` | |
| | Edit | `ICON_EDIT` | `pencil` | |
| | React | `ICON_REACT` | `face.smiling` | |
| @@ -89,3 +90,8 @@ second `arrowshape.turn.up.left` (`StockChatFeed.swift`, `MessageBubble.swift`) |
| — tolerable there because those rows carry text, whereas the web hover strip is |
| icon-only and two arrows read as one control drawn twice. Swap them to |
| `bubble.left.and.bubble.right` on the next native touch. |
| + |
| +**Convert to thread** carries a glyph on exactly two of its four surfaces: the |
| +web's picker (icon-only rows, so it needs one) and the iOS long-press sheet. |
| +The iOS context menu and the Mac `NSMenu` are text-only rows by construction, |
| +so there is nothing to map there. |
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift| index 1b154e3..1fb116b 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -232,6 +232,19 @@ struct NativeChatScreen: View { |
| let evidence: ReportEvidence |
| var id: Int64 { evidence.sid } |
| } |
| + // A message's "Convert to thread" action: the chain to move, held while the |
| + // confirm is up. The ids are snapshotted at the tap, so what moves is what |
| + // the person was looking at — not a set re-derived after the fact. |
| + @State private var convertTarget: ConvertTarget? |
| + // Set in the SAME update as convertTarget: the alert's message closure is |
| + // evaluated before any onChange could seed it, and again as it dismisses |
| + // (target already nil), so the count can't be read through the optional. |
| + @State private var convertCount = 0 |
| + struct ConvertTarget: Identifiable { |
| + let root: Int64 |
| + let ids: [Int64] |
| + var id: Int64 { root } |
| + } |
| // The header/composer overlay heights (probes above) — the feed extends |
| // under both and rests content between them via content insets. |
| @State private var headerHeight: CGFloat = 0 |
| @@ -472,6 +485,20 @@ struct NativeChatScreen: View { |
| threadName: thread.title, evidence: target.evidence) |
| .presentationDetents([.medium, .large]) |
| } |
| + // "Convert to thread" confirms, because it reorganises the chat for |
| + // EVERYONE and, in v1, can't be undone. As its own modifier: this |
| + // body is a long chain, and the alert inline pushed the type-checker |
| + // over its budget. |
| + .modifier(ConvertToThreadConfirm(target: $convertTarget, count: convertCount) { root, ids in |
| + // A conversion that can't reach the server has to SAY so: |
| + // nothing about a failed one is visible — the chain stays |
| + // where it was, exactly as if nobody had converted anything — |
| + // so after confirming a one-way act the reader would be left |
| + // believing it happened. The inline note bar, never a toast. |
| + if !model.convertToThread(root: root, ids: ids) { |
| + showFeedNote("Those replies didn't move — you're offline.") |
| + } |
| + }) |
| // An iPad multitasking resize can flip the size class with a |
| // thread open — the presentation follows live (threadDocked is |
| // derived), and this hands the thread's composer state across so |
| @@ -1072,6 +1099,21 @@ struct NativeChatScreen: View { |
| onDelete: { model.deleteMessage(m.id) }, |
| onCopy: { UIPasteboard.general.string = m.text }, |
| onReact: { model.toggleReaction(m.id, emoji: $0) }) |
| + // "Convert to thread" — only where there IS an inline chain under this |
| + // message to move, which is also what keeps it off a message nobody |
| + // answered and off the thread view's bubbles (nothing inline hangs off |
| + // one). Anyone in the chat may convert: it reorganises where messages |
| + // render, it doesn't touch what anyone wrote. The gate is the folded |
| + // flag (this runs for every row on screen); the chain itself is walked |
| + // at the tap, so what moves is what's on screen at that moment. |
| + if m.hasInlineChain { |
| + actions.onConvertToThread = { |
| + let ids = ChatFeedModel.inlineChainIDs(in: model.messages, root: m.id) |
| + guard !ids.isEmpty else { return } |
| + convertCount = ids.count // before the target: the alert reads it immediately |
| + convertTarget = ConvertTarget(root: m.id, ids: ids) |
| + } |
| + } |
| if !m.mine, let uid = m.raw.uid, !uid.isEmpty { |
| let muted = model.mutedPeople.contains(uid) |
| actions.senderMuted = muted |
| @@ -1101,6 +1143,11 @@ struct NativeChatScreen: View { |
| composerFocused = true |
| } |
| actions.onReplyInThread = nil |
| + // Nor "Convert to thread": a bubble in here is already in a thread, so |
| + // inlineChainIDs finds nothing under it anyway — except on the thread's |
| + // own ROOT, which can still have inline replies out in the feed. Tidying |
| + // those is a thing you do while looking at the feed, not from in here. |
| + actions.onConvertToThread = nil |
| return actions |
| } |
| @@ -2742,3 +2789,34 @@ private struct PlayerSurface: UIViewControllerRepresentable { |
| } |
| } |
| } |
| + |
| +// 🤖 The "Convert to thread" confirm, as its own modifier: the chat screen's |
| +// body is a long modifier chain and an inline alert pushed the Swift |
| +// type-checker past its budget. It confirms at all because a conversion |
| +// reorganises the chat for EVERYONE and, in v1, can't be undone — and the |
| +// wording is ChatFeedModel.convertConfirm, so web, Mac and iOS ask the same |
| +// question. |
| +private struct ConvertToThreadConfirm: ViewModifier { |
| + @Binding var target: NativeChatScreen.ConvertTarget? |
| + /// The count, held by the PARENT and set in the same update as `target` — |
| + /// so it is already right when SwiftUI first evaluates the message closure, |
| + /// and it survives the dismiss (by which time `target` is nil). Reading |
| + /// through the optional instead flashed "these 0 replies" at one end or the |
| + /// other. |
| + let count: Int |
| + let convert: (Int64, [Int64]) -> Void |
| + |
| + func body(content: Content) -> some View { |
| + content.alert("Convert to Thread", isPresented: Binding( |
| + get: { target != nil }, set: { if !$0 { target = nil } }) |
| + ) { |
| + Button("Cancel", role: .cancel) { target = nil } |
| + Button("Move") { |
| + if let t = target { convert(t.root, t.ids) } |
| + target = nil |
| + } |
| + } message: { |
| + Text(ChatFeedModel.convertConfirm(count)) |
| + } |
| + } |
| +} |
diff --git a/ios/LChat/Views/StockChatFeed.swift b/ios/LChat/Views/StockChatFeed.swift| index 1c4af78..7e619ee 100644 |
| --- a/ios/LChat/Views/StockChatFeed.swift |
| +++ b/ios/LChat/Views/StockChatFeed.swift |
| @@ -1071,6 +1071,13 @@ struct StockChatFeed: UIViewControllerRepresentable { |
| items.append(.init(title: "Reply in thread…", |
| icon: "arrowshape.turn.up.left", handler: inThread)) |
| } |
| + // Only where there's an inline chain under this message to move |
| + // (ChatFeedModel.inlineChainIDs decides; the action is nil |
| + // otherwise). Arrows folding toward a line — the web's ICON_FOLD_IN. |
| + if let convert = actions.onConvertToThread { |
| + items.append(.init(title: "Convert to thread…", |
| + icon: "arrow.down.right.and.arrow.up.left", handler: convert)) |
| + } |
| if let copy = actions.onCopy { |
| items.append(.init(title: "Copy", icon: "doc.on.doc", handler: copy)) |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift b/ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift| index 69e3ea3..3cbee3a 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift |
| @@ -82,6 +82,17 @@ public func reactionLine(_ who: String, _ payload: MessagePayload, max: Int = 40 |
| return snip.isEmpty ? head : "\(head) \(off ? "from" : "to") “\(snip)”" |
| } |
| +/// format.js convertLine: the chat-list line for a "Convert to thread" event — |
| +/// "James moved 3 replies into a thread". Same reasoning as reactionLine: a |
| +/// conversion is an ordinary encrypted message with no text of its own, so it |
| +/// can be the newest thing in a chat and a row that said nothing would go |
| +/// blank. `who` is resolved by the caller (so it can say "You"); the count |
| +/// pluralises and groups for the locale. |
| +public func convertLine(_ who: String, _ n: Int) -> String { |
| + let actor = who.trimmingCharacters(in: .whitespaces).isEmpty ? "Someone" : who |
| + return "\(actor) moved \(n == 1 ? "a reply" : "\(n.formatted(.number)) replies") into a thread" |
| +} |
| + |
| /// format.js typingSummary: who's typing, as a subtle one-liner that never |
| /// grows unbounded past two names. |
| public func typingSummary(_ names: [String]) -> String { |
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift| index a9bbb92..79f60fd 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| @@ -58,6 +58,19 @@ public final class ChatFeedModel: ObservableObject { |
| // never disagree with the root it points at. 0 for anything that isn't |
| // a thread reply. |
| public var threadReplyCount: Int = 0 |
| + // "Convert to thread" (chat.js's `movedTo`): the thread a CONVERSION |
| + // moved this message into, after the fact. Derived, never authoritative |
| + // — the claim map on the model is the state, and applyConverts stamps |
| + // this from it on every publish, so a message that arrives after its |
| + // own conversion is stamped on arrival. nil for everything else. |
| + public var movedTo: Int64? |
| + // Whether an inline reply chain hangs under this message — the whole |
| + // gate on offering "Convert to thread", and exactly |
| + // `!inlineChainIDs(in:root:).isEmpty` (the vectors pin the two |
| + // together). Folded once per publish rather than asked per bubble, |
| + // because the containers build a MessageActions for every row on |
| + // screen and the walk itself is O(list). |
| + public var hasInlineChain: Bool = false |
| // 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() |
| @@ -134,6 +147,12 @@ public final class ChatFeedModel: ObservableObject { |
| /// thread reply arriving never scrolls, pins or regroups the transcript. |
| /// The rule itself is the pure mainFeedItems(_:), vector-pinned. |
| @Published public private(set) var feedMessages: [Message] = [] |
| + /// "Convert to thread": every conversion this chat has folded, as claims |
| + /// (message id → the thread it moved into, plus the convert event's own id |
| + /// as the tie-break). This is the STATE — `movedTo` on each message is its |
| + /// projection, re-derived by refreshRollup on every publish, so a message |
| + /// arriving after its own conversion still lands in the thread. |
| + private var converts: [Int64: ThreadConvert] = [:] |
| @Published public private(set) var status = "loading…" |
| /// The roster's current decrypted names by uid, seeded and refreshed by |
| /// NativeFeedModelCache from each ThreadStore fold — the bubble-header |
| @@ -889,6 +908,57 @@ public final class ChatFeedModel: ObservableObject { |
| applyReactionEvent(payload) |
| } |
| + /// "Convert to thread": move the inline reply chain hanging under `root` |
| + /// out of the transcript and into a thread rooted on it. Like a reaction |
| + /// this is an end-to-end encrypted event sent as an ordinary message — the |
| + /// server never sees the root, the ids, or that a thread exists at all; |
| + /// every client folds the event and derives the same feed. |
| + /// |
| + /// `ids` is derived by the CALLER from inlineChainIDs, so what moved is |
| + /// fixed at the moment of the tap rather than re-derived differently on |
| + /// each device. No optimistic fold (the reaction path's local echo exists |
| + /// because a tap must feel instant; a conversion is a confirmed, one-way |
| + /// act and can wait for its own echo, which keeps the claim map keyed |
| + /// purely on real server ids). |
| + /// |
| + /// Returns false on a down socket, and the caller MUST say so: nothing |
| + /// about a failed conversion is visible — the chain simply stays where it |
| + /// was, which is exactly what it looks like when nobody converted anything |
| + /// — so after confirming a one-way act the reader would be left believing |
| + /// it happened. |
| + @discardableResult |
| + public func convertToThread(root: Int64, ids: [Int64]) -> Bool { |
| + guard root > 0, !ids.isEmpty, key != nil, let socket, socket.isLive else { return false } |
| + var payload = MessagePayload(type: "threadconvert", |
| + name: myScreenName, |
| + ts: Int64(Date().timeIntervalSince1970 * 1000)) |
| + payload.root = root |
| + payload.ids = Array(ids.prefix(Self.maxConvertIDs)) |
| + if !myUserId.isEmpty { payload.uid = myUserId } |
| + // The guard above IS the check: the encrypt + write happen off-main and |
| + // report nothing back, exactly as every other send here does. |
| + encryptAndSend(payload, via: socket) |
| + return true |
| + } |
| + |
| + /// Fold one convert event (live or from history). The claim map is the |
| + /// state; where each message renders is derived from it on every publish, |
| + /// so folds in any order — and repeated folds of the same event — settle on |
| + /// the same feed. `at` is the convert MESSAGE's own server id, which is the |
| + /// tie-break between two people converting overlapping chains. |
| + func applyConvertEvent(_ p: MessagePayload, at: Int64) { |
| + // Tamper-evidence, like every other live payload — and like the SAME |
| + // event arriving in a history page, which gossips via `stored`. Before |
| + // the guards below: a malformed conversion is still a message that |
| + // reached us, and its chain head still counts. |
| + noteGossip(p, own: p.uid == myUserId) |
| + guard p.isThreadConvertEvent, let root = p.root, let ids = p.ids else { return } |
| + guard Self.claimConverts(&converts, root: root, ids: ids, at: at) else { return } |
| + var list = messages |
| + refreshRollup(&list) |
| + messages = list |
| + } |
| + |
| /// Delete your own message. The server verifies authorship; the "delete" |
| /// broadcast removes it from every feed, ours included. |
| public func deleteMessage(_ id: Int64) { |
| @@ -1152,11 +1222,24 @@ public final class ChatFeedModel: ObservableObject { |
| // the length-scaled stall). |
| // Map a stored (already-decrypted) page into view messages and publish. |
| private func publishStored(_ stored: [MessageStore.StoredMessage]) { |
| + claimStoredConverts(stored) |
| var list: [Message] = stored.compactMap(viewMessage) |
| - Self.refreshReplyCounts(&list) |
| + refreshRollup(&list) |
| messages = list |
| } |
| + // Conversions ("Convert to thread") reach the store like any other row — |
| + // they're ordinary messages — but render as nothing, so viewMessage drops |
| + // them. Fold their claims before the list publishes and a cold open from |
| + // disk shows a converted chain already tucked into its thread, rather than |
| + // inline for the moment before the network page re-folds it. |
| + private func claimStoredConverts(_ stored: [MessageStore.StoredMessage]) { |
| + for s in stored where s.payload.isThreadConvertEvent { |
| + guard let root = s.payload.root, let ids = s.payload.ids else { continue } |
| + Self.claimConverts(&converts, root: root, ids: ids, at: s.id) |
| + } |
| + } |
| + |
| // One stored row → one view message (nil for non-chat payloads). |
| private func viewMessage(_ s: MessageStore.StoredMessage) -> Message? { |
| guard s.payload.isRenderableChat else { return nil } |
| @@ -1187,9 +1270,15 @@ public final class ChatFeedModel: ObservableObject { |
| list.append(msg) |
| changed = true |
| } |
| + // A conversion the NSE or a background sync stored while we were |
| + // suspended counts as a change too, even when no new message came with |
| + // it: it moves messages already on screen. |
| + let before = converts |
| + claimStoredConverts(stored) |
| + if converts != before { changed = true } |
| guard changed else { return } |
| list.sort { $0.id < $1.id } |
| - Self.refreshReplyCounts(&list) |
| + refreshRollup(&list) |
| messages = list |
| } |
| @@ -1214,7 +1303,7 @@ public final class ChatFeedModel: ObservableObject { |
| var pruned = messages |
| pruned.removeAll { evict.contains($0.id) } |
| if pruned.count != messages.count { |
| - Self.refreshReplyCounts(&pruned) |
| + refreshRollup(&pruned) |
| messages = pruned |
| } |
| var unfolded = false |
| @@ -1232,29 +1321,44 @@ public final class ChatFeedModel: ObservableObject { |
| let screenName = self.myScreenName |
| let wantGroupApps = threadKind == "group" |
| let foldSpan = Perf.begin("feed.foldPage", slowMs: 250) |
| - let (fresh, stored, appEvents, reactionRows, unprojectedRows): |
| + let (fresh, stored, appEvents, reactionRows, convertRows, unprojectedRows): |
| ([Message], [MessageStore.StoredMessage], |
| - [(Int64, GroupApps.Event)], [MessagePayload], |
| + [(Int64, GroupApps.Event)], [MessagePayload], [(Int64, MessagePayload)], |
| [(Int64, Int64, AppChatProjection.UnprojectedAppEvent)]) = |
| await Task.detached(priority: .userInitiated) { |
| var out: [Message] = [] |
| var toStore: [MessageStore.StoredMessage] = [] |
| var events: [(Int64, GroupApps.Event)] = [] |
| var reacts: [MessagePayload] = [] |
| + var converted: [(Int64, MessagePayload)] = [] |
| var unproj: [(Int64, Int64, AppChatProjection.UnprojectedAppEvent)] = [] |
| for row in rows { |
| if (row.tombstone_of ?? 0) > 0 { continue } // evicted above; empty blob, nothing to fold |
| if wantGroupApps, let e = Self.groupAppsEvent(row.blob, key: key) { |
| events.append((row.id, e)) |
| } |
| - // Reaction events live in history as ordinary encrypted rows — |
| - // collect them here (row order = chronological) and fold them |
| - // after the page's messages have merged. |
| - if let p = try? key.decryptJSON(MessagePayload.self, blob: row.blob), |
| - p.isReactionEvent { |
| + // Reaction and conversion events live in history as ordinary |
| + // encrypted rows. ONE decrypt for both questions (and the |
| + // renderable projection below re-reads the blob only for rows |
| + // that are neither) — this loop is the one Perf watches, and |
| + // every extra decrypt here is paid per row of every page. |
| + let event = try? key.decryptJSON(MessagePayload.self, blob: row.blob) |
| + // Collected in row order (chronological) and folded after the |
| + // page's messages have merged. |
| + if let p = event, p.isReactionEvent { |
| reacts.append(p) |
| continue |
| } |
| + // A conversion renders as nothing, so it's kept for its claims — |
| + // and mirrored to the store, so a cold open from disk shows a |
| + // converted chain already in its thread instead of inline for |
| + // the moment before the network page re-folds it. |
| + if let p = event, p.isThreadConvertEvent { |
| + converted.append((row.id, p)) |
| + toStore.append(.init(id: row.id, at: row.created_at, edited: false, |
| + reactions: [], payload: p)) |
| + continue |
| + } |
| guard let p = Self.renderable(row.blob, key: key, group: wantGroupApps, |
| myUserId: myUserId) |
| else { |
| @@ -1280,7 +1384,7 @@ public final class ChatFeedModel: ObservableObject { |
| attachments: p.attachments ?? [], reply: p.replyTo, |
| reactions: [], raw: p)) |
| } |
| - return (out, toStore, events, reacts, unproj) |
| + return (out, toStore, events, reacts, converted, unproj) |
| }.value |
| foldSpan.end("\(rows.count) rows") |
| // Tamper-evidence (history): compare gossip from every message + reaction |
| @@ -1338,8 +1442,15 @@ public final class ChatFeedModel: ObservableObject { |
| } |
| } |
| } |
| + // The page's conversions, before the rollup that projects them: the |
| + // claim map is order-independent, so a page carrying a conversion whose |
| + // members are still further back settles the same way when they land. |
| + for (id, p) in convertRows { |
| + guard let root = p.root, let ids = p.ids else { continue } |
| + Self.claimConverts(&converts, root: root, ids: ids, at: id) |
| + } |
| list.sort { $0.id < $1.id } |
| - Self.refreshReplyCounts(&list) |
| + refreshRollup(&list) |
| messages = list |
| noteUnprojected(unprojectedRows) |
| } |
| @@ -1358,14 +1469,13 @@ public final class ChatFeedModel: ObservableObject { |
| let at = frame.created_at ?? 0 |
| let wantGroupApps = threadKind == "group" |
| let myUserId = self.myUserId // captured — the decrypt closure runs off-main |
| - // A stored frame is either an encrypted reaction event or a |
| + // A stored frame is an encrypted reaction event, a conversion, or a |
| // chat/app message (with an optional group-apps projection) — |
| // decrypt off-main either way, then fold the right shape on main. |
| decryptThenFold({ () -> InboundStored? in |
| - if let p = try? key.decryptJSON(MessagePayload.self, blob: blob), |
| - p.isReactionEvent { |
| - return .reaction(p) |
| - } |
| + let event = try? key.decryptJSON(MessagePayload.self, blob: blob) |
| + if let p = event, p.isReactionEvent { return .reaction(p) } |
| + if let p = event, p.isThreadConvertEvent { return .convert(p) } |
| let payload = Self.renderable(blob, key: key, group: wantGroupApps, |
| myUserId: myUserId) |
| return .message(payload, |
| @@ -1375,6 +1485,13 @@ public final class ChatFeedModel: ObservableObject { |
| switch inbound { |
| case .reaction(let p): |
| model.applyReactionEvent(p) |
| + case .convert(let p): |
| + // The frame's own id is the tie-break between overlapping |
| + // conversions — never the payload's, which anyone could |
| + // make up. |
| + model.applyConvertEvent(p, at: id) |
| + model.storeMirror(.init(id: id, at: at, edited: false, |
| + reactions: [], payload: p)) |
| case .message(let payload, let groupApps, let unprojected): |
| model.foldDecrypted(id: id, at: at, payload: payload, |
| edited: false, reactions: []) |
| @@ -1406,7 +1523,7 @@ public final class ChatFeedModel: ObservableObject { |
| if groupAppsEvents.removeValue(forKey: id) != nil { recomputeGroupApps() } |
| var list = messages |
| list.removeAll { $0.id == id } |
| - Self.refreshReplyCounts(&list) |
| + refreshRollup(&list) |
| // The implosion: rows carry a scale+fade removal transition, and |
| // deletes are the one mutation that publishes inside an animation |
| // (arrivals/prepends stay unanimated by design). |
| @@ -1428,10 +1545,12 @@ public final class ChatFeedModel: ObservableObject { |
| } |
| // What a stored "msg" frame decrypts to: a reaction event folds into its |
| - // target's tally; anything else is a message (or app-event projection), |
| - // possibly carrying a group-apps event alongside. |
| + // target's tally, a conversion into the claim map that moves an inline |
| + // chain into a thread; anything else is a message (or app-event |
| + // projection), possibly carrying a group-apps event alongside. |
| private enum InboundStored: Sendable { |
| case reaction(MessagePayload) |
| + case convert(MessagePayload) |
| case message(MessagePayload?, GroupApps.Event?, AppChatProjection.UnprojectedAppEvent?) |
| } |
| @@ -1600,7 +1719,7 @@ public final class ChatFeedModel: ObservableObject { |
| var list = messages |
| fold(id: id, at: at, payload: payload, |
| edited: edited, reactions: reactions, into: &list) |
| - Self.refreshReplyCounts(&list) |
| + refreshRollup(&list) |
| messages = list |
| if let payload { |
| storeMirror(.init(id: id, at: at, edited: edited, |
| @@ -1805,7 +1924,7 @@ public final class ChatFeedModel: ObservableObject { |
| var list = messages |
| fold(id: localID, at: ts / 1000, payload: p, edited: false, reactions: [], |
| into: &list, cid: cid, pending: true) |
| - Self.refreshReplyCounts(&list) |
| + refreshRollup(&list) |
| messages = list |
| // Stay-pending on failure (web parity): we never remove the copy, only |
| // surface a delay. After 5s unconfirmed, flip it to the slow state. |
| @@ -1838,8 +1957,177 @@ public final class ChatFeedModel: ObservableObject { |
| /// 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 |
| + if isThreadOnly(m), let id = m.reply?.id, id > 0 { return id } |
| + // …or the thread a CONVERSION moved it into (movedTo). The wire flag is |
| + // read first on purpose: a reply someone composed in a thread belongs |
| + // to that thread, and no conversion can pull it out of one. |
| + if let moved = m.movedTo, moved > 0 { return moved } |
| + return nil |
| + } |
| + |
| + // ---- converting an inline chain into a thread --------------------------- |
| + // |
| + // Reply and Reply in Thread are separate verbs, and every reply in the |
| + // history from before that split is inline — so a long chain only ever |
| + // becomes a thread after the fact. The messages can't be rewritten to say |
| + // so (only their author may edit one, only for fifteen minutes, and it |
| + // would churn N authorships to change a display rule), so a conversion is |
| + // its own encrypted event that every client folds, exactly like a reaction. |
| + // Web twin: chat.js's chat.convert fold. Pinned by the convert_* vectors in |
| + // test/event-vectors/chat.json. |
| + |
| + /// How many ids one conversion may claim (web twin: MAX_CONVERT_IDS). |
| + static let maxConvertIDs = 2000 |
| + |
| + /// One conversion's claim on a message: the thread it moved into, and the |
| + /// server id of the CONVERT EVENT that said so (the tie-break). |
| + public struct ThreadConvert: Equatable, Sendable { |
| + public let root: Int64 |
| + public let at: Int64 |
| + public init(root: Int64, at: Int64) { |
| + self.root = root |
| + self.at = at |
| + } |
| + } |
| + |
| + /// Merge one convert event's claims into the map. Two people can convert |
| + /// overlapping chains, and the same event folds twice routinely (a history |
| + /// page and the live echo, a reconnect catch-up), so the merge is a pure |
| + /// function of the SET of events and never of their arrival order: the |
| + /// OLDEST conversion (lowest `at`) owns a message, and re-folding one |
| + /// changes nothing. Ties are impossible — `at` is a server message id. |
| + /// Returns false when nothing changed. Mirrors chat.js's claimConverts. |
| + /// |
| + /// `ids` is capped: a hostile client could name thousands in one payload |
| + /// (the server caps a blob at 64KB and can't read it) and the claim map is |
| + /// never pruned. The cap is far above any real chain — a conversation with |
| + /// more than this many inline replies to one message doesn't exist. |
| + @discardableResult |
| + nonisolated static func claimConverts(_ claims: inout [Int64: ThreadConvert], |
| + root: Int64, ids: [Int64], at: Int64) -> Bool { |
| + guard root > 0, at > 0 else { return false } |
| + var changed = false |
| + for sid in ids.prefix(maxConvertIDs) { |
| + guard sid > 0, sid != root else { continue } // a root is never inside its own thread |
| + if let prev = claims[sid], prev.at <= at { continue } // an older conversion owns it |
| + claims[sid] = ThreadConvert(root: root, at: at) |
| + changed = true |
| + } |
| + return changed |
| + } |
| + |
| + /// The claims resolved to the thread each message actually lands in — a |
| + /// pure function of the map, so every client and every arrival order |
| + /// derives the same answer. |
| + /// |
| + /// Claims can nest: one conversion moves [b, c] under root a while another |
| + /// moves [c] under root b. A claimed root is followed through to the thread |
| + /// IT ended up in, so claim-rooted nesting flattens onto the outermost |
| + /// root. (That is what's guaranteed — NOT "threads never nest". A reply |
| + /// composed in a thread carries the wire flag, which is authoritative and |
| + /// can't be re-pointed, so if a conversion moves the message that reply's |
| + /// thread is rooted on, its thread ends up inside the converted one. Benign |
| + /// and reachable — it renders from its root's bubble in the outer thread — |
| + /// and only a race produces it: a "Reply in Thread" in flight while someone |
| + /// converts the chain it lives in.) |
| + /// |
| + /// A cycle can only come from a malformed or hostile payload (a real |
| + /// reply's id is always higher than the message it quotes) and resolves to |
| + /// no conversion at all rather than hanging. Mirrors chat.js's |
| + /// convertedRoots. |
| + nonisolated static func convertedRoots(_ claims: [Int64: ThreadConvert]) -> [Int64: Int64] { |
| + var out: [Int64: Int64] = [:] |
| + for (sid, claim) in claims { |
| + var cur = claim.root |
| + var seen: Set<Int64> = [sid] |
| + while let next = claims[cur], !seen.contains(cur) { |
| + seen.insert(cur) |
| + cur = next.root |
| + } |
| + if claims[cur] != nil { continue } // cycled — leave every message in it inline |
| + if cur > 0, cur != sid { out[sid] = cur } |
| + } |
| + return out |
| + } |
| + |
| + /// Does a conversion's claim on `m` actually hold? The sender's id list is |
| + /// a CLAIM, not a fact, and the receiver checks it against the message |
| + /// itself: a conversion moves a reply CHAIN, so each message it names must |
| + /// really hang off the thread it's being moved into — its own quote is |
| + /// either the root, or another message the conversions claimed. |
| + /// |
| + /// Without this the shipped primitive would be "move any N messages under |
| + /// any root", which is worse than it sounds: a conversion renders no bubble |
| + /// (its only trace is a transient chat-list line), the oldest claim wins |
| + /// forever so no later event can undo it, and deleting the convert row |
| + /// isn't an undo either — so one crafted payload could quietly hide a |
| + /// couple of hundred unrelated messages under some old root, |
| + /// unattributably. Now such an id is ignored and the message stays put. |
| + /// |
| + /// Order-independent, and it needs no loaded window beyond the message |
| + /// itself: a claimed message that hasn't arrived has no quote to check, so |
| + /// it stays inline until it does — the same degradation as a thread reply |
| + /// whose root is paged out. Mirrors chat.js's convertHolds. |
| + nonisolated static func convertHolds(_ m: Message, _ claims: [Int64: ThreadConvert]) -> Bool { |
| + guard let claim = claims[m.id] else { return false } |
| + let quoted = m.reply?.id ?? 0 |
| + return quoted == claim.root || claims[quoted] != nil |
| + } |
| + |
| + /// Stamp each message with the thread a conversion moved it into. Derived, |
| + /// never authoritative: the claim map is the state and this is its |
| + /// projection onto the loaded window, re-run on every publish so a message |
| + /// that arrives AFTER its own conversion (an older page paging in, an |
| + /// out-of-order live frame) lands in the thread just the same. |
| + /// Mirrors chat.js's applyConverts. |
| + nonisolated static func applyConverts(_ list: inout [Message], |
| + _ claims: [Int64: ThreadConvert]) { |
| + guard !claims.isEmpty else { return } |
| + let roots = convertedRoots(claims) |
| + for i in list.indices { |
| + list[i].movedTo = convertHolds(list[i], claims) ? roots[list[i].id] : nil |
| + } |
| + } |
| + |
| + /// The inline reply chain hanging under `rootID` — every message that |
| + /// quotes it, everything that quotes those, and so on — which is exactly |
| + /// what "Convert to thread" moves. Empty when there's nothing to move, |
| + /// which is also the whole gate on offering the verb. |
| + /// |
| + /// This is the transitive quote walk the two-verb split deleted, back in |
| + /// the one narrow place that needs it: deciding what a conversion covers. |
| + /// It is NOT a membership rule — nothing reads it to decide where a message |
| + /// renders (that's threadRootID, still a straight read) — and it runs on |
| + /// the converting client only, at menu time, to build the event's id list. |
| + /// |
| + /// One forward pass suffices because `list` is in server order and a |
| + /// reply's id is always higher than the message it quotes. Messages already |
| + /// living in a thread are skipped, so the chain is only ever inline |
| + /// conversation — and a bubble inside a thread has no chain at all, which |
| + /// is what keeps the verb off the thread view's menus. |
| + /// Mirrors chat.js's inlineChainIds. |
| + nonisolated public static func inlineChainIDs(in list: [Message], root rootID: Int64) -> [Int64] { |
| + guard rootID > 0, |
| + let root = list.first(where: { $0.id == rootID }), |
| + threadRootID(of: root) == nil else { return [] } // unloaded, or already in a thread |
| + var chain: Set<Int64> = [rootID] |
| + var out: [Int64] = [] |
| + for m in list { |
| + guard !m.pending, threadRootID(of: m) == nil, |
| + let quoted = m.reply?.id, chain.contains(quoted) else { continue } |
| + chain.insert(m.id) |
| + out.append(m.id) |
| + } |
| + return out |
| + } |
| + |
| + /// The one wording for the conversion's confirm, so web, Mac and iOS ask |
| + /// the same question — pluralised and locale-grouped like every displayed |
| + /// count. It says what everyone will see and that it's one-way, because it |
| + /// is: v1 has no undo. Mirrors chat.js's convertConfirm. |
| + nonisolated public static func convertConfirm(_ n: Int) -> String { |
| + let what = n == 1 ? "this reply" : "these \(n.formatted(.number)) replies" |
| + return "Move \(what) into a thread? Everyone will see the conversation there instead of in the chat, and this can't be undone." |
| } |
| // Every thread reply's root (message id → root id). Mirrors chat.js's |
| @@ -1880,6 +2168,14 @@ public final class ChatFeedModel: ObservableObject { |
| if let root = list.first(where: { $0.id == rootID }) { |
| return ReplyRef(id: root.id, name: root.name, snippet: replySnippet(root.raw)) |
| } |
| + // Root paged out. The frozen snapshot a FLAGGED reply carries is the |
| + // root's own (every thread send derives its ref from the root), so it |
| + // stands in. A CONVERTED message's ref is the message it quoted — |
| + // which is not the root — so there is nothing truthful to fall back on |
| + // and the id travels alone: the reply still lands in the right thread, |
| + // and the view says the original isn't available rather than naming |
| + // the wrong message. |
| + guard isThreadOnly(m) else { return ReplyRef(id: rootID) } |
| return ReplyRef(id: rootID, name: m.reply?.name, snippet: m.reply?.snippet) |
| } |
| @@ -1988,7 +2284,11 @@ public final class ChatFeedModel: ObservableObject { |
| for m in list { loaded.insert(m.id) } |
| return list.filter { m in |
| // Unflagged, or flagged but pointing nowhere resolvable: a feed bubble. |
| - guard isThreadOnly(m), let root = roots[m.id] else { return true } |
| + // In no thread — unflagged and unconverted, or flagged but pointing |
| + // nowhere resolvable: a feed bubble. (`roots` is threadRootID's |
| + // answer, which covers both ways in, so there's no second flag |
| + // check here to fall out of step with it.) |
| + guard let root = roots[m.id] else { return true } |
| // Hidden only while its root is on screen to carry the tally. |
| return !loaded.contains(root) |
| } |
| @@ -2020,6 +2320,16 @@ public final class ChatFeedModel: ObservableObject { |
| return unread.last?.id |
| } |
| + /// Every publish's rollup: apply the conversions this chat has folded, then |
| + /// re-derive the thread tallies over the result. One seam for both, so no |
| + /// fold path (history, live frame, edit, delete, optimistic send) can |
| + /// publish a list that forgot a conversion. The static halves stay pure so |
| + /// the vector tests can drive them directly. |
| + private func refreshRollup(_ list: inout [Message]) { |
| + Self.applyConverts(&list, converts) |
| + Self.refreshReplyCounts(&list) |
| + } |
| + |
| // Re-derive each message's reply-thread tally (chat.js's replyCounts fold): |
| // the "N Replies" on a root counts its THREAD — only the replies someone |
| // deliberately made in it (threadRootID(of:)). An ordinary inline reply |
| @@ -2031,7 +2341,16 @@ public final class ChatFeedModel: ObservableObject { |
| let roots = Self.threadRoots(list) |
| var counts: [Int64: Int] = [:] |
| var first: [Int64: Int64] = [:] |
| + // Which messages have an INLINE reply hanging off them — "Convert to |
| + // thread"'s gate. A direct inline reply is all it takes: the chain |
| + // under a message is non-empty exactly when one exists (the walk from |
| + // there finds at least it). Pending copies don't count — the server has |
| + // never heard of their ids, so no other client could resolve one. |
| + var answered = Set<Int64>() |
| for m in list { |
| + if roots[m.id] == nil, !m.pending, let quoted = m.reply?.id, quoted > 0 { |
| + answered.insert(quoted) |
| + } |
| guard let root = roots[m.id], root > 0 else { continue } |
| counts[root, default: 0] += 1 |
| if first[root] == nil { first[root] = m.id } |
| @@ -2042,6 +2361,9 @@ public final class ChatFeedModel: ObservableObject { |
| 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 |
| + // A message already in a thread has no inline chain under it — its |
| + // answers are in the thread, not the transcript. |
| + list[i].hasInlineChain = roots[list[i].id] == nil && answered.contains(list[i].id) |
| } |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift| index e4f91cc..76f9181 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| @@ -72,6 +72,13 @@ public struct MessageActions { |
| /// than staging a reply on the channel bar. Same gating as onReply; nil |
| /// on surfaces where it's redundant (the docked pane's own bubbles). |
| public var onReplyInThread: (() -> Void)? |
| + /// "Convert to thread": take the inline reply chain hanging under this |
| + /// message out of the transcript and into a thread rooted on it. Set ONLY |
| + /// where there is a chain to move (ChatFeedModel.inlineChainIDs) — never on |
| + /// a message nobody answered, and never inside a thread view, where nothing |
| + /// inline hangs off a bubble. Confirms before it fires: it reorganises the |
| + /// chat for everyone and, in v1, can't be undone. |
| + public var onConvertToThread: (() -> Void)? |
| public var onEdit: (() -> Void)? |
| public var onDelete: (() -> Void)? |
| public var onCopy: (() -> Void)? |
| @@ -379,6 +386,12 @@ public struct MessageBubble: View { |
| if let inThread = actions.onReplyInThread { |
| Button("Reply in Thread…", action: inThread) |
| } |
| + // Right after the thread verb: the two are about the same place, one |
| + // for what comes next and one for what already happened. Present only |
| + // when there's a chain under this message to move. |
| + if let convert = actions.onConvertToThread { |
| + Button("Convert to Thread…", action: convert) |
| + } |
| if let copy = actions.onCopy { |
| Button("Copy", action: copy) |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/MessageRightClick.swift b/ios/LChatCore/Sources/LChatCore/MessageRightClick.swift| index a10e3f0..d535271 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/MessageRightClick.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/MessageRightClick.swift |
| @@ -70,6 +70,12 @@ struct MessageRightClick: NSViewRepresentable { |
| if let inThread = actions.onReplyInThread { |
| menu.addItem(ClosureMenuItem(title: "Reply in Thread…", handler: inThread)) |
| } |
| + // Only when there IS an inline chain under this message to move — the |
| + // same gate the SwiftUI menu uses, so the two can't offer different |
| + // things on the same bubble. |
| + if let convert = actions.onConvertToThread { |
| + menu.addItem(ClosureMenuItem(title: "Convert to Thread…", handler: convert)) |
| + } |
| if let copy = actions.onCopy { |
| menu.addItem(ClosureMenuItem(title: "Copy", handler: copy)) |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift| index b9f55ac..e0b8581 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Models.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Models.swift |
| @@ -175,6 +175,15 @@ public struct MessagePayload: Codable, Equatable, Sendable { |
| // only to the author; nil for legacy uid-less targets |
| public var emoji: String? // the reaction emoji |
| public var on: Bool? // true to add, false to remove |
| + // "Convert to thread" event fields (type == "threadconvert"): an inline |
| + // reply chain moved out of the transcript into a thread rooted at `root`. |
| + // The messages themselves are never rewritten (only their author may edit |
| + // one, and only briefly), so the conversion is its own encrypted event that |
| + // every client folds — the web twin is chat.js's chat.convert. `ids` is the |
| + // explicit set that moved, derived by the converting client from the quote |
| + // chain it could see, so no two clients disagree about what moved. |
| + public var root: Int64? |
| + public var ids: [Int64]? |
| public var targetText: String? // a replySnippet of the reacted-to message, stamped by the |
| // reacting client (inside the ciphertext) so the sidebar can |
| // say "reacted 👍 to “buy milk”" for a thread built from |
| @@ -203,6 +212,11 @@ public struct MessagePayload: Codable, Equatable, Sendable { |
| /// Reaction event: type == "reaction" with targetSid, emoji, uid, and on |
| public var isReactionEvent: Bool { type == "reaction" } |
| + |
| + /// "Convert to thread": type == "threadconvert" with root and ids. Like a |
| + /// reaction it is neither renderable chat nor an app event, so a client |
| + /// that predates it drops the payload and keeps showing the chain inline. |
| + public var isThreadConvertEvent: Bool { type == "threadconvert" } |
| } |
| // The quoted-message reference riding inside a reply's payload — chat.js builds |
| @@ -446,6 +460,14 @@ public func previewText(kind: String, payload: MessagePayload, mine: Bool = fals |
| let who = actor.isEmpty ? (payload.name ?? "") : actor |
| return reactionLine(mine ? "You" : who, payload) |
| } |
| + // A conversion has no text either — same treatment, same reasons. The count |
| + // is what the FOLD would take, not what the payload asked for: a huge |
| + // crafted list is capped there, and the line must not claim more than moved. |
| + if payload.isThreadConvertEvent { |
| + let who = actor.isEmpty ? (payload.name ?? "") : actor |
| + return convertLine(mine ? "You" : who, |
| + min(payload.ids?.count ?? 0, ChatFeedModel.maxConvertIDs)) |
| + } |
| // Through the markdown plain projection, never raw: raw text leaks the |
| // composer's opt-out escapes as visible backslashes (\*lit\*), and shows |
| // the marks the bubble renders as styling. |
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift| index e71dbfc..6b3f45a 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| @@ -655,11 +655,11 @@ public final class ThreadStore: ObservableObject { |
| } else if let n = p.name, !n.isEmpty { |
| lastMine = n == rosterLabels[myUserId] |
| } |
| - if p.isRenderableChat || p.isReactionEvent { |
| + if p.isRenderableChat || p.isReactionEvent || p.isThreadConvertEvent { |
| // displayLabels: my name for them (#577) — the same by-uid |
| - // resolution the web's previewText does, so a reaction from |
| - // a device that stamped no screen name still names its |
| - // sender instead of "Someone". |
| + // resolution the web's previewText does, so a reaction (or |
| + // a conversion) from a device that stamped no screen name |
| + // still names its sender instead of "Someone". |
| let actor = p.uid.flatMap { displayLabels[$0] } ?? "" |
| preview = previewText(kind: t.kind, payload: p, mine: lastMine, actor: actor) |
| } else { |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ChatVectors.swift b/ios/LChatCore/Tests/LChatCoreTests/ChatVectors.swift| index aa7e56e..33ed50c 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/ChatVectors.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/ChatVectors.swift |
| @@ -48,6 +48,34 @@ enum ChatVectors { |
| return (id, at) |
| } |
| + /// A vector's whole message_sequence, folded the way a client folds it: |
| + /// ordinary payloads become feed messages, "threadconvert" payloads become |
| + /// claims in the conversion map — keyed on the CONVERT ROW's own server id, |
| + /// which is the tie-break between two people converting overlapping chains. |
| + /// `skipConverts` folds the same sequence the way a client too old to know |
| + /// the payload type does: it drops the event and the chain stays inline. |
| + static func sequence(_ v: [String: Any], skipConverts: Bool = false) throws |
| + -> (messages: [ChatFeedModel.Message], converts: [Int64: ChatFeedModel.ThreadConvert]) { |
| + let steps = try XCTUnwrap(v["message_sequence"] as? [[String: Any]], |
| + "vector has no message_sequence") |
| + var list: [ChatFeedModel.Message] = [] |
| + var claims: [Int64: ChatFeedModel.ThreadConvert] = [:] |
| + for step in steps { |
| + let p = try payload(step) |
| + if p.isThreadConvertEvent { |
| + guard !skipConverts else { continue } |
| + let meta = try serverMessage(step) |
| + ChatFeedModel.claimConverts(&claims, root: p.root ?? 0, ids: p.ids ?? [], at: meta.id) |
| + continue |
| + } |
| + list.append(try message(step)) |
| + } |
| + // Server order, however the steps were delivered — the model sorts the |
| + // same way, so an out-of-order vector folds to the same list. |
| + list.sort { $0.id < $1.id } |
| + return (list, claims) |
| + } |
| + |
| /// A vector folded into the feed's Message the way ChatFeedModel.fold does |
| /// (reply = payload.replyTo; raw = the payload). `mine` is false — the |
| /// vectors are everyone else's messages. |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift| index 820c59e..544e96c 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift |
| @@ -514,6 +514,34 @@ final class EventVectorTests: XCTestCase { |
| "a thread reply with no loaded root stays in the feed") |
| } |
| + func testThreadConvertPayloadDecodesAndIsNotChat() throws { |
| + // The wire shape of "Convert to thread", straight off the vector file: a |
| + // typed payload carrying {root, ids} and no text. It is neither renderable |
| + // chat nor an app event, which is exactly what makes it SAFE for an older |
| + // client — one that predates the type drops it and keeps showing the chain |
| + // inline (the degradation expected_main_feed_unconverted pins). |
| + let v = try ChatVectors.vector("convert_inline_chain_to_thread") |
| + let steps = try XCTUnwrap(v["message_sequence"] as? [[String: Any]]) |
| + let step = try XCTUnwrap(steps.first { ($0["decrypted_payload"] as? [String: Any])?["type"] as? String == "threadconvert" }, |
| + "the vector must carry a threadconvert step") |
| + let p = try ChatVectors.payload(step) |
| + XCTAssertTrue(p.isThreadConvertEvent, "type: threadconvert") |
| + XCTAssertFalse(p.isRenderableChat, "never a bubble — an older client drops it") |
| + XCTAssertFalse(p.isReactionEvent) |
| + XCTAssertNil(p.text, "a conversion says nothing; it moves what was already said") |
| + XCTAssertEqual(p.root, 400) |
| + XCTAssertEqual(p.ids, [401, 402]) |
| + // Round-trips: the payload we'd SEND encodes to the same fields. |
| + var sent = MessagePayload(type: "threadconvert", uid: p.uid, name: p.name, ts: p.ts) |
| + sent.root = p.root |
| + sent.ids = p.ids |
| + let data = try JSONEncoder().encode(sent) |
| + let back = try JSONDecoder().decode(MessagePayload.self, from: data) |
| + XCTAssertEqual(back.root, 400) |
| + XCTAssertEqual(back.ids, [401, 402]) |
| + XCTAssertTrue(back.isThreadConvertEvent) |
| + } |
| + |
| func testInlineReplyVectorStaysInFeed() throws { |
| // The pre-thread wire shape — replyTo with no flag — is an inline quote |
| // reply: it decodes thread == nil (never a false positive) and stays a |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift| index 57d7a26..5a228d6 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift |
| @@ -155,8 +155,12 @@ final class ReplyThreadVectorTests: XCTestCase { |
| 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) |
| + // Conversions fold from the same sequence: the claim map, then its |
| + // projection onto the loaded window (applyConverts), then the tallies — |
| + // the model's own rollup order. |
| + let folded = try ChatVectors.sequence(v) |
| + var list = folded.messages |
| + ChatFeedModel.applyConverts(&list, folded.converts) |
| ChatFeedModel.refreshReplyCounts(&list) |
| // expected_threads: which messages are in a thread (0 = none), the |
| @@ -214,7 +218,228 @@ final class ReplyThreadVectorTests: XCTestCase { |
| assertRef(ChatFeedModel.threadTarget(in: list, for: m), want, |
| "\(name): threadTarget(\(sid)) — Reply in Thread targets the thread's root") |
| } |
| + |
| + // expected_inline_chain: what "Convert to thread" would move from here — |
| + // the same question the menus ask to decide whether to offer the verb at |
| + // all. Empty means nothing hangs under that message (or it's already in |
| + // a thread), which is also what a converted chain leaves behind. |
| + for (key, want) in (v["expected_inline_chain"] as? [String: [NSNumber]]) ?? [:] { |
| + guard let root = Int64(key) else { continue } |
| + XCTAssertEqual(ChatFeedModel.inlineChainIDs(in: list, root: root), |
| + want.map(\.int64Value), "\(name): inlineChainIDs(\(key))") |
| + } |
| + // The folded `hasInlineChain` flag is a per-row shortcut for exactly that |
| + // walk (the containers build a menu for every row on screen and can't |
| + // afford it): they must agree on EVERY message, or a menu offers a verb |
| + // that does nothing — or hides one that would work. |
| + for m in list { |
| + XCTAssertEqual(m.hasInlineChain, |
| + !ChatFeedModel.inlineChainIDs(in: list, root: m.id).isEmpty, |
| + "\(name): hasInlineChain must equal the walk for \(m.id)") |
| + } |
| + |
| + // expected_main_feed_unconverted: the SAME sequence with the conversion |
| + // dropped — precisely what a client too old to know the payload type |
| + // does. It keeps showing the chain inline, the behaviour before this |
| + // shipped, and nothing breaks. |
| + if let want = v["expected_main_feed_unconverted"] as? [String: Bool] { |
| + var plain = try ChatVectors.sequence(v, skipConverts: true).messages |
| + ChatFeedModel.refreshReplyCounts(&plain) |
| + let visible = Set(ChatFeedModel.mainFeedItems(plain).map(\.id)) |
| + for m in plain { |
| + let shown = try XCTUnwrap(want[String(m.id)], |
| + "\(name): expected_main_feed_unconverted must name \(m.id)") |
| + XCTAssertEqual(visible.contains(m.id), shown, |
| + "\(name): an old client's feed — visibility of \(m.id)") |
| + } |
| + } |
| + } |
| + } |
| + |
| + // ---- "Convert to thread": the rules, hand-driven ------------------------ |
| + // Web twins in test/apps.test.mjs ("chat: inlineChainIds …" and the three |
| + // that follow). The JSON-driven twin is the loop above. |
| + |
| + private func chainFixture() -> [Message] { |
| + func raw(_ text: String) -> MessagePayload { |
| + var p = MessagePayload() |
| + p.text = text |
| + return p |
| } |
| + return [ |
| + Message(id: 1, name: "Al", text: "root", mine: false, at: 1, edited: false, raw: raw("root")), |
| + Message(id: 2, name: "Bo", text: "re", mine: false, at: 2, edited: false, |
| + reply: ReplyRef(id: 1, name: "Al", snippet: "root"), raw: raw("re")), |
| + Message(id: 3, name: "Cy", text: "re-re", mine: false, at: 3, edited: false, |
| + reply: ReplyRef(id: 2, name: "Bo", snippet: "re"), raw: raw("re-re")), |
| + Message(id: 4, name: "Di", text: "aside", mine: false, at: 4, edited: false, raw: raw("aside")), |
| + ] |
| + } |
| + |
| + func testInlineChainIDsGathersTheChainAndOnlyThat() { |
| + let msgs = chainFixture() |
| + XCTAssertEqual(ChatFeedModel.inlineChainIDs(in: msgs, root: 1), [2, 3], |
| + "the reply and the reply to it; the unrelated aside stays put") |
| + XCTAssertEqual(ChatFeedModel.inlineChainIDs(in: msgs, root: 2), [3], |
| + "from mid-chain, only what hangs below") |
| + XCTAssertEqual(ChatFeedModel.inlineChainIDs(in: msgs, root: 3), [], |
| + "nothing under the last reply — so the verb isn't offered") |
| + XCTAssertEqual(ChatFeedModel.inlineChainIDs(in: msgs, root: 4), [], |
| + "and none at all under a message nobody answered") |
| + XCTAssertEqual(ChatFeedModel.inlineChainIDs(in: msgs, root: 99), [], |
| + "an unloaded message has no chain we can see") |
| + XCTAssertEqual(ChatFeedModel.inlineChainIDs(in: msgs, root: 0), [], "0 is never a root") |
| + // A reply made IN a thread is not inline conversation — it's already |
| + // somewhere — and a bubble inside a thread has no chain at all, which is |
| + // what keeps the verb off the thread view's menus. |
| + var withThread = msgs |
| + withThread.append(Message(id: 5, name: "Al", text: "in thread", mine: false, at: 5, edited: false, |
| + reply: ReplyRef(id: 4, name: "Di", snippet: "aside", thread: true))) |
| + withThread.append(Message(id: 6, name: "Bo", text: "quotes it", mine: false, at: 6, edited: false, |
| + reply: ReplyRef(id: 5, name: "Al", snippet: "in thread"))) |
| + XCTAssertEqual(ChatFeedModel.inlineChainIDs(in: withThread, root: 4), [], |
| + "the thread reply under it is in a thread already") |
| + XCTAssertEqual(ChatFeedModel.inlineChainIDs(in: withThread, root: 5), [], |
| + "and a message inside a thread has no inline chain to convert") |
| + // A pending optimistic copy can't be converted: the server has never heard |
| + // of its id, so no other client could resolve it. |
| + var pending = Message(id: 1 << 62, name: "Me", text: "sending", mine: true, at: 9, edited: false, |
| + reply: ReplyRef(id: 3, name: "Cy", snippet: "re-re")) |
| + pending.pending = true |
| + XCTAssertEqual(ChatFeedModel.inlineChainIDs(in: msgs + [pending], root: 1), [2, 3], |
| + "the in-flight reply isn't in the chain until it confirms") |
| + // …and the folded flag agrees, including about the pending copy's target: |
| + // offering a verb that would move nothing is the bug this pins. |
| + var withPending = msgs + [pending] |
| + ChatFeedModel.refreshReplyCounts(&withPending) |
| + XCTAssertEqual(withPending.map(\.hasInlineChain), [true, true, false, false, false], |
| + "only 1 and 2 have an inline chain under them") |
| + } |
| + |
| + func testClaimConvertsIsOrderIndependentAndIdempotent() { |
| + var claims: [Int64: ChatFeedModel.ThreadConvert] = [:] |
| + XCTAssertTrue(ChatFeedModel.claimConverts(&claims, root: 1, ids: [2, 3], at: 50)) |
| + XCTAssertEqual(claims[2], ChatFeedModel.ThreadConvert(root: 1, at: 50)) |
| + XCTAssertFalse(ChatFeedModel.claimConverts(&claims, root: 1, ids: [2, 3], at: 50), |
| + "re-folding the same event changes nothing (history + live echo)") |
| + XCTAssertFalse(ChatFeedModel.claimConverts(&claims, root: 9, ids: [2], at: 70), |
| + "a LATER conversion can't steal a message from an earlier one") |
| + XCTAssertTrue(ChatFeedModel.claimConverts(&claims, root: 9, ids: [2], at: 40), |
| + "an EARLIER one wins, whichever order they arrive in") |
| + XCTAssertEqual(claims[2], ChatFeedModel.ThreadConvert(root: 9, at: 40)) |
| + XCTAssertEqual(claims[3], ChatFeedModel.ThreadConvert(root: 1, at: 50), |
| + "…and leaves its neighbours alone") |
| + var fresh: [Int64: ChatFeedModel.ThreadConvert] = [:] |
| + XCTAssertFalse(ChatFeedModel.claimConverts(&fresh, root: 1, ids: [1], at: 50), |
| + "a root is never inside its own thread") |
| + XCTAssertFalse(ChatFeedModel.claimConverts(&fresh, root: 1, ids: [], at: 50), |
| + "an empty conversion is a no-op") |
| + } |
| + |
| + func testAConversionOnlyMovesMessagesThatHangOffItsRoot() { |
| + // The sender's id list is a CLAIM, checked by the receiver against the |
| + // messages themselves. Otherwise the event would be "move any N messages |
| + // under any root" — and since a conversion renders no bubble, the oldest |
| + // claim wins forever, and deleting the convert row is not an undo, one |
| + // crafted payload could quietly hide unrelated messages under an old root. |
| + // Web twin: "a conversion can only move messages that actually hang off its |
| + // root" in apps.test.mjs; JSON twin: convert_unrelated_ids_are_ignored. |
| + var list = chainFixture() // 1 root, 2→1, 3→2, 4 unrelated |
| + list.append(Message(id: 5, name: "Al", text: "elsewhere", mine: false, at: 5, edited: false)) |
| + var claims: [Int64: ChatFeedModel.ThreadConvert] = [:] |
| + ChatFeedModel.claimConverts(&claims, root: 1, ids: [2, 3, 4, 5], at: 50) |
| + ChatFeedModel.applyConverts(&list, claims) |
| + ChatFeedModel.refreshReplyCounts(&list) |
| + XCTAssertEqual(ChatFeedModel.threadItems(in: list, root: 1).map(\.id), [1, 2, 3], |
| + "only the real chain moved") |
| + XCTAssertEqual(ChatFeedModel.mainFeedItems(list).map(\.id), [1, 4, 5], |
| + "the unrelated messages never left the feed") |
| + XCTAssertEqual(list.first { $0.id == 1 }?.replyCount, 2, |
| + "and the tally counts only what moved") |
| + // A claimed message that hasn't arrived yet has no quote to check, so it |
| + // stays inline until it does — the same degradation as an unloaded root. |
| + var partial = Array(chainFixture().prefix(2)) // 3 not loaded |
| + ChatFeedModel.applyConverts(&partial, claims) |
| + ChatFeedModel.refreshReplyCounts(&partial) |
| + XCTAssertEqual(ChatFeedModel.threadItems(in: partial, root: 1).map(\.id), [1, 2], |
| + "3 isn't here to be checked") |
| + } |
| + |
| + func testClaimConvertsCapsOneEventsIDs() { |
| + // The server caps a blob at 64KB and can't read it, so a hostile client |
| + // could name thousands of ids in one event — and the claim map is never |
| + // pruned. Web twin: "a conversion's id list is capped…" in apps.test.mjs. |
| + var claims: [Int64: ChatFeedModel.ThreadConvert] = [:] |
| + ChatFeedModel.claimConverts(&claims, root: 1, ids: Array(1000..<6000), at: 9) |
| + XCTAssertEqual(claims.count, ChatFeedModel.maxConvertIDs, |
| + "capped, far above any real chain") |
| + XCTAssertEqual(ChatFeedModel.maxConvertIDs, 2000, "and the same cap the web uses") |
| + } |
| + |
| + func testConvertedRootsFlattensNestingAndDropsCycles() { |
| + // Threads don't nest: a claim whose root was itself converted resolves |
| + // through to the outermost root. |
| + let nested: [Int64: ChatFeedModel.ThreadConvert] = [ |
| + 2: .init(root: 1, at: 10), 3: .init(root: 2, at: 20), 4: .init(root: 3, at: 30), |
| + ] |
| + XCTAssertEqual(ChatFeedModel.convertedRoots(nested), [2: 1, 3: 1, 4: 1]) |
| + // A cycle can only come from a made-up payload (a real reply's id is |
| + // always higher than what it quotes). It terminates, and nothing moves. |
| + XCTAssertEqual(ChatFeedModel.convertedRoots([1: .init(root: 2, at: 10), |
| + 2: .init(root: 1, at: 20)]), [:]) |
| + XCTAssertEqual(ChatFeedModel.convertedRoots([:]), [:]) |
| + } |
| + |
| + func testApplyConvertsMovesTheChainIntoAThread() { |
| + var list = chainFixture() |
| + var claims: [Int64: ChatFeedModel.ThreadConvert] = [:] |
| + ChatFeedModel.claimConverts(&claims, root: 1, ids: [2, 3], at: 50) |
| + ChatFeedModel.applyConverts(&list, claims) |
| + ChatFeedModel.refreshReplyCounts(&list) |
| + XCTAssertEqual(ChatFeedModel.threadItems(in: list, root: 1).map(\.id), [1, 2, 3], |
| + "the chain reads as a thread, root first, in server order") |
| + XCTAssertEqual(ChatFeedModel.mainFeedItems(list).map(\.id), [1, 4], |
| + "the replies leave the transcript; the root keeps its place") |
| + XCTAssertEqual(list.first { $0.id == 1 }?.replyCount, 2, |
| + "and wears the tally they moved into") |
| + // Conversion never rewrites a message: the converted replies keep their own |
| + // quote cards, so the chain's internal "who answered whom" stays legible. |
| + XCTAssertEqual(list.first { $0.id == 3 }?.reply?.id, 2) |
| + let moved = try? XCTUnwrap(list.first { $0.id == 3 }) |
| + XCTAssertEqual(ChatFeedModel.threadTarget(in: list, for: moved!).id, 1, |
| + "Reply in Thread on a converted message continues the thread it moved into") |
| + // Root paged out: they degrade back to inline bubbles, exactly as a |
| + // flagged thread reply does — the tally lives on a loaded root. |
| + var rootless = Array(list.dropFirst()) |
| + ChatFeedModel.refreshReplyCounts(&rootless) |
| + XCTAssertEqual(ChatFeedModel.mainFeedItems(rootless).map(\.id), [2, 3, 4], |
| + "with no root loaded to carry the tally, they show inline") |
| + // …and the unloaded root is named by nothing rather than by the wrong |
| + // message: a flagged reply's ref IS its root's snapshot, but a converted |
| + // message's ref is whatever it quoted. |
| + let stranded = ChatFeedModel.threadTarget(in: rootless, for: rootless[1]) |
| + XCTAssertEqual(stranded.id, 1) |
| + XCTAssertNil(stranded.name, "a converted message carries the root's id alone") |
| + XCTAssertNil(stranded.snippet) |
| + let flagged = [Message(id: 9, name: "Cy", text: "in thread", mine: false, at: 9, edited: false, |
| + reply: ReplyRef(id: 1, name: "Al", snippet: "root", thread: true))] |
| + XCTAssertEqual(ChatFeedModel.threadTarget(in: flagged, for: flagged[0]).name, "Al", |
| + "a flagged reply still stands in for its unloaded root") |
| + } |
| + |
| + func testConvertConfirmSaysWhatMovesAndThatItIsOneWay() { |
| + XCTAssertEqual(ChatFeedModel.convertConfirm(1), |
| + "Move this reply into a thread? Everyone will see the conversation there instead of in the chat, and this can't be undone.") |
| + XCTAssertTrue(ChatFeedModel.convertConfirm(3).contains("these 3 replies"), "never 'these 3 reply'") |
| + XCTAssertTrue(ChatFeedModel.convertConfirm(1204).contains("these \(1204.formatted(.number)) replies"), |
| + "digits are locale-grouped, like every displayed count") |
| + } |
| + |
| + func testConvertLineNamesTheActorAndPluralises() { |
| + // The chat-list line: web twin convertLine in test/format.test.mjs. |
| + XCTAssertEqual(convertLine("James", 3), "James moved 3 replies into a thread") |
| + XCTAssertEqual(convertLine("You", 1), "You moved a reply into a thread", "never '1 replies'") |
| + XCTAssertEqual(convertLine("", 2), "Someone moved 2 replies into a thread") |
| } |
| private func assertRef(_ got: ReplyRef, _ want: [String: Any], _ label: String) { |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index a93a6f5..217f4e5 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -255,6 +255,15 @@ struct NativeFeedView: View { |
| // the server (model.send returns false on a down socket, keeping the text). |
| @State private var draft = "" |
| @State private var integrityReport: String? // /integrity read-out (tamper evidence) |
| + // A message's "Convert to thread" action: the chain to move, held while the |
| + // confirm is up. The ids are snapshotted at the click, so what moves is what |
| + // the person was looking at — not a set re-derived after the fact. |
| + @State private var convertTarget: ConvertTarget? |
| + struct ConvertTarget: Identifiable { |
| + let root: Int64 |
| + let ids: [Int64] |
| + var id: Int64 { root } |
| + } |
| // Plain @State: MacComposerField drives first responder from it directly. |
| @State private var composerFocused = false |
| // Programmatic edits (typeahead splice, panel inserts) act on the view via |
| @@ -396,6 +405,28 @@ struct NativeFeedView: View { |
| } message: { |
| Text(integrityReport ?? "") |
| } |
| + // "Convert to thread" confirms, because it reorganises the chat for |
| + // EVERYONE and, in v1, can't be undone. One wording for all three |
| + // platforms (ChatFeedModel.convertConfirm). |
| + .alert("Convert to Thread", isPresented: .init( |
| + get: { convertTarget != nil }, set: { if !$0 { convertTarget = nil } }), |
| + presenting: convertTarget |
| + ) { target in |
| + Button("Cancel", role: .cancel) { convertTarget = nil } |
| + Button("Move") { |
| + // A conversion that can't reach the server has to SAY so: |
| + // nothing about a failed one is visible — the chain stays where |
| + // it was, exactly as if nobody had converted anything — so |
| + // after confirming a one-way act the reader would be left |
| + // believing it happened. The inline note bar, never a toast. |
| + if !model.convertToThread(root: target.root, ids: target.ids) { |
| + showFeedNote("Those replies didn't move — you're offline.") |
| + } |
| + convertTarget = nil |
| + } |
| + } message: { target in |
| + Text(ChatFeedModel.convertConfirm(target.ids.count)) |
| + } |
| .task(id: instance?.id) { |
| if let instance { await NativeFeedModelCache.shared.pollIntegrity(for: instance) } |
| } |
| @@ -2099,6 +2130,19 @@ struct NativeFeedView: View { |
| NSPasteboard.general.setString(m.text, forType: .string) |
| }, |
| onReact: { emoji in model.toggleReaction(m.id, emoji: emoji) }) |
| + // "Convert to thread" — only where there IS an inline chain under this |
| + // message to move, which is also what keeps it off a message nobody |
| + // answered and off the pane's bubbles (nothing inline hangs off one). |
| + // Anyone in the chat may convert: it reorganises where messages render, |
| + // it doesn't touch what anyone wrote. The gate is the folded flag (this |
| + // runs for every row on screen); the chain itself is walked at the |
| + // click, so what moves is what's on screen at that moment. |
| + if m.hasInlineChain { |
| + actions.onConvertToThread = { |
| + let ids = ChatFeedModel.inlineChainIDs(in: model.messages, root: m.id) |
| + if !ids.isEmpty { convertTarget = ConvertTarget(root: m.id, ids: ids) } |
| + } |
| + } |
| if times { |
| actions.timesShown = timesShown |
| actions.onToggleTimes = { |
| @@ -2135,6 +2179,11 @@ struct NativeFeedView: View { |
| // menus omit it. (The web keeps it as a harmless refocus; the Mac |
| // NSMenu is denser, so it drops.) |
| actions.onReplyInThread = nil |
| + // Nor "Convert to thread": a bubble in the pane is already in a thread, |
| + // so inlineChainIDs finds nothing under it anyway — except on the |
| + // thread's own ROOT, which can still have inline replies out in the |
| + // feed. Tidying those is a thing you do while looking at the feed. |
| + actions.onConvertToThread = nil |
| actions.onEdit = { startPaneEdit(m) } |
| return actions |
| } |
diff --git a/ios/LChatNSE/NotificationService.swift b/ios/LChatNSE/NotificationService.swift| index b66c2a2..9aa2446 100644 |
| --- a/ios/LChatNSE/NotificationService.swift |
| +++ b/ios/LChatNSE/NotificationService.swift |
| @@ -108,6 +108,38 @@ final class NotificationService: UNNotificationServiceExtension { |
| return |
| } |
| + // "Convert to thread": nobody said anything — an inline reply chain |
| + // moved into a thread. There is nothing to announce, but without |
| + // Apple's notification-filtering entitlement the extension must ship |
| + // SOMETHING, so it takes the same passive, silent shape a non-author |
| + // reaction does: no sound, no banner, no lit screen, only a list entry. |
| + // It is still ingested, so the app opens with the chain already tucked |
| + // into its thread rather than inline until the next history pull. |
| + if payload.isThreadConvertEvent { |
| + content.title = entry.title |
| + content.threadIdentifier = thread |
| + content.body = convertLine(senderName(payload.uid, payload.name) ?? "Someone", |
| + payload.ids?.count ?? 0) |
| + content.sound = nil |
| + content.interruptionLevel = .passive |
| + let id = (info["id"] as? NSNumber)?.int64Value ?? 0 |
| + let at = (info["at"] as? NSNumber)?.int64Value ?? 0 |
| + guard id > 0, at > 0 else { |
| + ship(content) |
| + return |
| + } |
| + let final: UNNotificationContent = content |
| + Task { |
| + await MessageStore.shared.resume() |
| + await MessageStore.shared.upsert( |
| + instance: entry.origin, thread: thread, |
| + [.init(id: id, at: at, edited: false, reactions: [], payload: payload)]) |
| + await MessageStore.shared.suspend() |
| + ship(final) |
| + } |
| + return |
| + } |
| + |
| let sender = senderName(payload.uid, payload.name) ?? "Someone" |
| // Plain projection, not raw text — a notification body shows the |
| // composer's opt-out escapes and markdown marks otherwise (same rule |
diff --git a/test/apps.test.mjs b/test/apps.test.mjs| index 07229f0..88547b8 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, threadReplyCount, threadListItems, threadListSignature, inMainFeed, bySidIndex, newlyDirtyMentionSids, threadLinkCount, replyCountLabel, quoteTarget, threadTarget, jumpReachNote, pageResult, feedNearBottom, feedGlideToBottom, feedCancelGlide } from "../web/static/apps/chat.js"; // the chat app |
| +import { app as chatApp, viewItems, threadItems, threadReplyCount, threadListItems, threadListSignature, inMainFeed, bySidIndex, newlyDirtyMentionSids, threadLinkCount, replyCountLabel, quoteTarget, threadTarget, jumpReachNote, pageResult, feedNearBottom, feedGlideToBottom, feedCancelGlide, inlineChainIds, claimConverts, convertedRoots, applyConverts, convertConfirm } 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 |
| @@ -1236,6 +1236,152 @@ await test("chat: an inline reply chain is NOT a thread — no absorption, no ta |
| assert.deepEqual(v.map((m) => threadLinkCount(m)), [0, 0, 0], "no quote card doubles as a thread link"); |
| }); |
| +// ---- "Convert to thread": an inline chain moved into a thread after the fact. |
| +// The messages can't be rewritten (only their author may edit one, and only |
| +// briefly), so the conversion is a separate encrypted fold event and the whole |
| +// rule set lives in these four pure helpers. |
| + |
| +const convertChainFixture = () => [ |
| + { id: "m1", sid: 1, name: "Al", text: "root", ts: 1, reply: null, raw: { text: "root" }, rev: 0 }, |
| + { id: "m2", sid: 2, name: "Bo", text: "re", ts: 2, reply: { id: 1, name: "Al", snippet: "root" }, raw: { text: "re" }, rev: 0 }, |
| + { id: "m3", sid: 3, name: "Cy", text: "re-re", ts: 3, reply: { id: 2, name: "Bo", snippet: "re" }, raw: { text: "re-re" }, rev: 0 }, |
| + { id: "m4", sid: 4, name: "Di", text: "aside", ts: 4, reply: null, raw: { text: "aside" }, rev: 0 }, |
| +]; |
| + |
| +await test("chat: inlineChainIds gathers the chain under a message — and only that", () => { |
| + const msgs = convertChainFixture(); |
| + assert.deepEqual(inlineChainIds(msgs, 1), [2, 3], "the reply and the reply to it; the unrelated aside stays put"); |
| + assert.deepEqual(inlineChainIds(msgs, 2), [3], "from mid-chain, only what hangs below"); |
| + assert.deepEqual(inlineChainIds(msgs, 3), [], "nothing under the last reply — so the verb isn't offered"); |
| + assert.deepEqual(inlineChainIds(msgs, 4), [], "and none at all under a message nobody answered"); |
| + assert.deepEqual(inlineChainIds(msgs, 99), [], "an unloaded message has no chain we can see"); |
| + assert.deepEqual(inlineChainIds(msgs, 0), [], "sid 0 is the pending-copy key, never a root"); |
| + // A reply someone made IN a thread is not inline conversation: it's already |
| + // somewhere, so it never gets swept up — and a bubble inside a thread has no |
| + // chain at all, which is what keeps the verb off the thread pane's menus. |
| + const withThread = [ |
| + ...msgs, |
| + { id: "m5", sid: 5, name: "Al", text: "in thread", ts: 5, reply: { id: 4, name: "Di", snippet: "aside", thread: true }, raw: { text: "in thread" }, rev: 0 }, |
| + { id: "m6", sid: 6, name: "Bo", text: "quotes it", ts: 6, reply: { id: 5, name: "Al", snippet: "in thread" }, raw: { text: "quotes it" }, rev: 0 }, |
| + ]; |
| + assert.deepEqual(inlineChainIds(withThread, 4), [], "the thread reply under it is in a thread already"); |
| + assert.deepEqual(inlineChainIds(withThread, 5), [], "and a message inside a thread has no inline chain to convert"); |
| + // Pending copies (no sid yet) can't be converted — the server has never |
| + // heard of them, so no other client could resolve the id. |
| + const pending = [...msgs, { id: "cx", sid: 0, cid: "cx", pending: true, name: "Al", text: "sending", ts: 9, reply: { id: 3, name: "Cy", snippet: "re-re" }, raw: {}, rev: 0 }]; |
| + assert.deepEqual(inlineChainIds(pending, 1), [2, 3], "the in-flight reply isn't in the chain until it confirms"); |
| +}); |
| + |
| +await test("chat: claimConverts — the oldest conversion owns a message, repeats are no-ops", () => { |
| + const first = claimConverts({}, 1, [2, 3], 50); |
| + assert.deepEqual(first, { 2: { root: 1, at: 50 }, 3: { root: 1, at: 50 } }); |
| + assert.equal(claimConverts(first, 1, [2, 3], 50), null, "re-folding the same event changes nothing (history + live echo)"); |
| + assert.equal(claimConverts(first, 9, [2], 70), null, "a LATER conversion can't steal a message from an earlier one"); |
| + const older = claimConverts(first, 9, [2], 40); |
| + assert.deepEqual(older[2], { root: 9, at: 40 }, "an EARLIER one wins, whichever order they arrive in"); |
| + assert.deepEqual(older[3], { root: 1, at: 50 }, "…and leaves its neighbours alone"); |
| + assert.equal(claimConverts({}, 1, [1], 50), null, "a root is never inside its own thread"); |
| + assert.equal(claimConverts({}, 1, [], 50), null, "an empty conversion is a no-op"); |
| +}); |
| + |
| +await test("chat: a conversion validates ids the way Swift's decoder does — no coercion", () => { |
| + // The cross-platform contract's hardest rule: web and native must fold the |
| + // SAME event into the same feed. Swift types root/ids as Int64, so a stringy |
| + // or fractional id fails to decode and native drops the whole payload — and |
| + // a `Number()` coercion here would convert on web and do nothing there. |
| + const fold = (payload, id = 9) => chatApp.reduce({ msgs: [] }, { type: "chat.convert", ...payload }, { id }); |
| + assert.equal(fold({ root: "400", ids: ["401", "402"] }).converts, undefined, "a stringy payload folds to nothing, as it does natively"); |
| + assert.equal(fold({ root: 400.5, ids: [401] }).converts, undefined, "…and a fractional root"); |
| + assert.equal(fold({ root: -1, ids: [401] }).converts, undefined, "…and a negative one: -1 would stamp a thread rooted on an id that can never exist"); |
| + assert.equal(fold({ root: 0, ids: [401] }).converts, undefined); |
| + assert.equal(fold({ root: 400, ids: [401] }, 0).converts, undefined, "no server id for the event itself — nothing to break ties with"); |
| + // Junk ids inside an otherwise valid event are dropped one by one. |
| + const mixed = fold({ root: 400, ids: [401, "402", 403.5, -404, null, [405], true, 406] }); |
| + assert.deepEqual(Object.keys(mixed.converts), ["401", "406"], "only the real ids claim anything"); |
| + // And the good case still works, so the guards aren't just rejecting. |
| + assert.deepEqual(Object.keys(fold({ root: 400, ids: [401, 402] }).converts), ["401", "402"]); |
| +}); |
| + |
| +await test("chat: a conversion's id list is capped, so one payload can't blow up the claim map", () => { |
| + // The server caps a blob at 64KB and can't read it, so a hostile client could |
| + // name thousands of ids in one event — and the claim map is never pruned. |
| + const huge = { type: "chat.convert", root: 1, ids: Array.from({ length: 5000 }, (_, i) => 1000 + i) }; |
| + const state = chatApp.reduce({ msgs: [] }, huge, { id: 9 }); |
| + assert.equal(Object.keys(state.converts).length, 2000, "capped, far above any real chain"); |
| + // A normal conversion is untouched by the cap. |
| + const ok = chatApp.reduce({ msgs: [] }, { type: "chat.convert", root: 1, ids: [2, 3] }, { id: 9 }); |
| + assert.deepEqual(Object.keys(ok.converts), ["2", "3"]); |
| +}); |
| + |
| +await test("chat: convertedRoots flattens nesting and drops cycles", () => { |
| + // Threads don't nest: a claim whose root was itself converted resolves |
| + // through to the outermost root. |
| + const nested = { 2: { root: 1, at: 10 }, 3: { root: 2, at: 20 }, 4: { root: 3, at: 30 } }; |
| + assert.deepEqual([...convertedRoots(nested).entries()], [[2, 1], [3, 1], [4, 1]]); |
| + // A cycle can only come from a made-up payload (a real reply's sid is always |
| + // higher than what it quotes). It terminates, and nothing moves. |
| + assert.deepEqual([...convertedRoots({ 1: { root: 2, at: 10 }, 2: { root: 1, at: 20 } }).entries()], []); |
| + assert.deepEqual([...convertedRoots({}).entries()], [], "no conversions, no claims"); |
| + assert.deepEqual([...convertedRoots(null).entries()], [], "and nothing to resolve before the first one"); |
| +}); |
| + |
| +await test("chat: applyConverts moves a chain into a thread — tally, feed and thread view together", () => { |
| + const msgs = convertChainFixture(); |
| + const claims = claimConverts({}, 1, [2, 3], 50); |
| + const moved = applyConverts(msgs, claims); |
| + assert.deepEqual(threadItems(moved, 1).map((m) => m.sid), [1, 2, 3], "the chain reads as a thread, root first, in server order"); |
| + const feed = viewItems(moved, { mainFeed: true }); |
| + assert.deepEqual(feed.map((m) => m.sid), [1, 4], "the replies leave the transcript; the root keeps its place"); |
| + assert.equal(feed[0].replyCount, 2, "and wears the tally they moved into"); |
| + assert.deepEqual(moved.map((m) => inMainFeed(m, bySidIndex(moved))), [true, false, false, true]); |
| + // The converted replies keep their own quote cards, so the chain's internal |
| + // "who answered whom" is still legible inside the thread. |
| + assert.deepEqual(moved[2].reply, { id: 2, name: "Bo", snippet: "re" }, "conversion never rewrites a message"); |
| + assert.deepEqual(threadTarget(moved, moved[2]), { id: 1, name: "Al", snippet: "root" }, "Reply in thread on a converted message continues the thread it moved into"); |
| + // Messages nothing moved keep their identity, so nothing else re-renders. |
| + assert.equal(applyConverts(moved, claims), moved, "re-applying the same claims is a no-op"); |
| + assert.equal(moved[3], msgs[3], "an untouched message is the very same object"); |
| + // A converted reply's root paging OUT degrades it back to an inline bubble, |
| + // exactly as a flagged thread reply does — the tally lives on the root. |
| + const rootless = moved.slice(1); |
| + assert.deepEqual(viewItems(rootless, { mainFeed: true }).map((m) => m.sid), [2, 3, 4], "with no root loaded to carry the tally, they show inline"); |
| + // …and the unloaded root is named by nothing rather than by the wrong |
| + // message: a flagged reply's ref IS its root's snapshot, but a converted |
| + // message's ref is whatever it quoted. |
| + assert.deepEqual(threadTarget(rootless, rootless[1]), { id: 1, name: "", snippet: "" }, "a converted message carries the root's id alone"); |
| + const flagged = [{ id: "m9", sid: 9, name: "Cy", text: "in thread", ts: 9, reply: { id: 1, name: "Al", snippet: "root", thread: true }, raw: { text: "in thread" }, rev: 0 }]; |
| + assert.deepEqual(threadTarget(flagged, flagged[0]), { id: 1, name: "Al", snippet: "root" }, "a flagged reply still stands in for its unloaded root"); |
| +}); |
| + |
| +await test("chat: a conversion can only move messages that actually hang off its root", () => { |
| + // The sender's id list is a CLAIM, checked by the receiver against the |
| + // messages themselves. Otherwise the event would be "move any N messages |
| + // under any root" — and since a conversion renders no bubble, the oldest |
| + // claim wins forever, and deleting the convert row is not an undo, one |
| + // crafted payload could quietly hide unrelated messages under an old root. |
| + const msgs = [ |
| + ...convertChainFixture(), |
| + { id: "m5", sid: 5, name: "Al", text: "elsewhere", ts: 5, reply: null, raw: { text: "elsewhere" }, rev: 0 }, |
| + ]; |
| + // 4 and 5 quote nothing; 2 quotes the root and 3 quotes 2. |
| + const claims = claimConverts({}, 1, [2, 3, 4, 5], 50); |
| + const moved = applyConverts(msgs, claims); |
| + assert.deepEqual(threadItems(moved, 1).map((m) => m.sid), [1, 2, 3], "only the real chain moved"); |
| + assert.deepEqual(viewItems(moved, { mainFeed: true }).map((m) => m.sid), [1, 4, 5], "the unrelated messages never left the feed"); |
| + assert.equal(viewItems(moved, { mainFeed: true })[0].replyCount, 2, "and the tally counts only what moved"); |
| + // A claimed message that hasn't arrived yet has no quote to check, so it |
| + // stays inline until it does — the same degradation as an unloaded root. |
| + const late = applyConverts(msgs.slice(0, 2), claims); // 3 not loaded |
| + assert.deepEqual(threadItems(late, 1).map((m) => m.sid), [1, 2], "3 isn't here to be checked"); |
| + assert.deepEqual(threadItems(applyConverts(msgs, claims), 1).map((m) => m.sid), [1, 2, 3], "…and joins the moment it arrives"); |
| +}); |
| + |
| +await test("chat: convertConfirm says what moves, pluralised, and that it's one-way", () => { |
| + assert.equal(convertConfirm(1), "Move this reply into a thread? Everyone will see the conversation there instead of in the chat, and this can't be undone."); |
| + assert.match(convertConfirm(3), /these 3 replies/, "never 'these 3 reply'"); |
| + assert.match(convertConfirm(1204), /these 1,204 replies/, "digits are locale-grouped, like every displayed count"); |
| +}); |
| + |
| 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 }, |
diff --git a/test/event-vectors.test.mjs b/test/event-vectors.test.mjs| index b3c4b62..8668093 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, quoteTarget, threadTarget, threadItems, inMainFeed, bySidIndex } from "../web/static/apps/chat.js"; |
| +import { viewItems, quoteTarget, threadTarget, threadItems, inMainFeed, bySidIndex, claimConverts, applyConverts, inlineChainIds } 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"; |
| @@ -85,6 +85,32 @@ function testChatMsgFold(state, event, meta) { |
| return { msgs }; |
| } |
| +// Fold a vector's message_sequence exactly as shell.js routes it: an ordinary |
| +// payload folds as chat.msg, a "threadconvert" payload as chat.convert — the |
| +// claim map (the REAL claimConverts, so the oldest-wins rule can't fork here) |
| +// projected onto the loaded window after EVERY step, which is what makes a |
| +// message arriving after its own conversion land in the thread anyway. |
| +// |
| +// `skipConverts` folds the same sequence the way a client too old to know the |
| +// payload type does: it drops the event entirely and the chain stays inline. |
| +function foldSequence(sequence, { skipConverts = false } = {}) { |
| + let state = { msgs: [] }; |
| + let claims = {}; |
| + for (const step of sequence) { |
| + const payload = step.decrypted_payload; |
| + const meta = { id: step.server_message.id, ts: step.server_message.created_at * 1000 }; |
| + if (payload.type === "threadconvert") { |
| + if (skipConverts) continue; |
| + const next = claimConverts(claims, payload.root, payload.ids || [], meta.id); |
| + if (next) claims = next; |
| + } else { |
| + state = testChatMsgFold(state, { type: "chat.msg", ...payload }, meta); |
| + } |
| + state = { ...state, msgs: applyConverts(state.msgs, claims) }; |
| + } |
| + return state; |
| +} |
| + |
| function testChatReactionFold(state, event, meta, ctx) { |
| if (event.targetSid === undefined || event.emoji === undefined || event.uid === undefined || event.on === undefined) return state; |
| const me = ctx && ctx.me; |
| @@ -329,12 +355,7 @@ allVectors.chat.forEach((vector) => { |
| // inline; every other message shows. |
| if (vector.message_sequence && vector.expected_main_feed) { |
| test(`chat main feed: ${vector.name} — thread replies hide only under a loaded root`, () => { |
| - let state = { msgs: [] }; |
| - for (const step of vector.message_sequence) { |
| - const event = { type: "chat.msg", ...step.decrypted_payload }; |
| - const meta = { id: step.server_message.id, ts: step.server_message.created_at * 1000 }; |
| - state = testChatMsgFold(state, event, meta); |
| - } |
| + const state = foldSequence(vector.message_sequence); |
| const bySid = bySidIndex(state.msgs); |
| const main = viewItems(state.msgs, { mainFeed: true }); |
| const shown = new Set(main.map((i) => i.sid)); |
| @@ -353,6 +374,17 @@ allVectors.chat.forEach((vector) => { |
| assert.strictEqual(row.replyCount, count, `root ${root}'s transcript tally should count its thread replies (and only those)`); |
| } |
| } |
| + // The degradation an OLD client shows: it doesn't know the payload type, |
| + // so it drops the conversion and keeps rendering the chain inline — |
| + // exactly the feed it rendered before this shipped. Nothing breaks, and |
| + // it collapses the moment that client updates. |
| + if (vector.expected_main_feed_unconverted) { |
| + const old = foldSequence(vector.message_sequence, { skipConverts: true }); |
| + const oldShown = new Set(viewItems(old.msgs, { mainFeed: true }).map((i) => i.sid)); |
| + for (const [sid, visible] of Object.entries(vector.expected_main_feed_unconverted)) { |
| + assert.strictEqual(oldShown.has(Number(sid)), visible, `ignoring the conversion, message ${sid} should be ${visible ? "shown" : "hidden"} — an old client's feed`); |
| + } |
| + } |
| }); |
| } |
| @@ -362,12 +394,7 @@ allVectors.chat.forEach((vector) => { |
| // two compose verbs (quoteTarget = the exact bubble, threadTarget = the root). |
| if (vector.expected_threads) { |
| 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 }; |
| - const meta = { id: step.server_message.id, ts: step.server_message.created_at * 1000 }; |
| - state = testChatMsgFold(state, event, meta); |
| - } |
| + const state = foldSequence(vector.message_sequence); |
| 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)) { |
| @@ -385,6 +412,13 @@ allVectors.chat.forEach((vector) => { |
| if (root === "note") continue; |
| assert.deepStrictEqual(threadItems(state.msgs, Number(root)).map((m) => m.sid), ids, `threadItems(${root}) should be exactly ${JSON.stringify(ids)}`); |
| } |
| + // What "Convert to thread" would move from here — the same question the |
| + // menus ask to decide whether to offer the verb at all. Empty means the |
| + // message has no inline chain under it (nothing to convert), which is |
| + // also the state a converted chain leaves behind. |
| + for (const [root, ids] of Object.entries(vector.expected_inline_chain || {})) { |
| + assert.deepStrictEqual(inlineChainIds(state.msgs, Number(root)), ids, `inlineChainIds(${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). |
diff --git a/test/event-vectors/chat.json b/test/event-vectors/chat.json| index ceb55e1..a101dc3 100644 |
| --- a/test/event-vectors/chat.json |
| +++ b/test/event-vectors/chat.json |
| @@ -345,6 +345,14 @@ |
| "100": [100, 104], |
| "101": [101] |
| }, |
| + "expected_inline_chain": { |
| + "100": [ |
| + 101, |
| + 102, |
| + 103 |
| + ], |
| + "104": [] |
| + }, |
| "expected_main_feed": { |
| "100": true, |
| "101": true, |
| @@ -436,6 +444,16 @@ |
| "301": [301], |
| "302": [302] |
| }, |
| + "expected_inline_chain": { |
| + "300": [ |
| + 301, |
| + 302 |
| + ], |
| + "301": [ |
| + 302 |
| + ], |
| + "302": [] |
| + }, |
| "expected_main_feed": { |
| "300": true, |
| "301": true, |
| @@ -737,6 +755,885 @@ |
| "warn": true, |
| "isRenderableChat": true |
| } |
| + }, |
| + { |
| + "name": "convert_inline_chain_to_thread", |
| + "description": "'Convert to thread': an inline reply chain that got long is moved out of the transcript into a thread rooted at the message it hangs off. The conversion is its own encrypted event (type='threadconvert', {root, ids}) — no message is rewritten — and every client folds it into the same feed: 401 and 402 leave the transcript, 400 keeps its place wearing a '2 Replies' tally, and the unrelated 403 never moves.", |
| + "note": "The messages CANNOT be rewritten to carry replyTo.thread after the fact (only their author may edit one, only for 15 minutes, and it would churn N authorships to change a display rule), so the conversion is a separate fold event, exactly like a reaction. The fold is a pure claim map (sid → {root, at}) projected onto the loaded window, so it is order-independent and idempotent — see convert_folds_before_its_members_arrive and convert_overlapping_and_repeated. expected_main_feed_unconverted is the SAME sequence with the convert step dropped, which is precisely what a client too old to know the payload type does: it keeps showing the chain inline, the behaviour before this shipped. expected_inline_chain pins what the verb would move NEXT — empty here, because the chain now lives in a thread, which is also why the verb stops being offered on 400.", |
| + "message_sequence": [ |
| + { |
| + "server_message": { |
| + "id": 400, |
| + "created_at": 1700000400 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Alice", |
| + "text": "Where should we do dinner?", |
| + "ts": 1700000400000, |
| + "uid": "user-alice-1" |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 401, |
| + "created_at": 1700000401 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Bob", |
| + "text": "The place by the bridge?", |
| + "ts": 1700000401000, |
| + "uid": "user-bob-1", |
| + "replyTo": { |
| + "id": 400, |
| + "name": "Alice", |
| + "snippet": "Where should we do dinner?" |
| + } |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 402, |
| + "created_at": 1700000402 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Charlie", |
| + "text": "They shut on Mondays", |
| + "ts": 1700000402000, |
| + "uid": "user-charlie-1", |
| + "replyTo": { |
| + "id": 401, |
| + "name": "Bob", |
| + "snippet": "The place by the bridge?" |
| + } |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 403, |
| + "created_at": 1700000403 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Dana", |
| + "text": "Unrelated: who has the keys?", |
| + "ts": 1700000403000, |
| + "uid": "user-dana-1" |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 410, |
| + "created_at": 1700000410 |
| + }, |
| + "decrypted_payload": { |
| + "type": "threadconvert", |
| + "root": 400, |
| + "ids": [ |
| + 401, |
| + 402 |
| + ], |
| + "name": "Bob", |
| + "uid": "user-bob-1", |
| + "ts": 1700000410000 |
| + } |
| + } |
| + ], |
| + "expected_threads": { |
| + "roots": { |
| + "400": 0, |
| + "401": 400, |
| + "402": 400, |
| + "403": 0 |
| + }, |
| + "reply_counts": { |
| + "400": 2, |
| + "401": 0, |
| + "402": 0, |
| + "403": 0 |
| + }, |
| + "first_reply": { |
| + "400": 401 |
| + } |
| + }, |
| + "expected_thread_items": { |
| + "400": [ |
| + 400, |
| + 401, |
| + 402 |
| + ], |
| + "401": [ |
| + 401 |
| + ], |
| + "note": "The converted chain reads in the thread in server order, root first — and 401, itself converted, has no thread of its own." |
| + }, |
| + "expected_main_feed": { |
| + "400": true, |
| + "401": false, |
| + "402": false, |
| + "403": true |
| + }, |
| + "expected_main_feed_unconverted": { |
| + "400": true, |
| + "401": true, |
| + "402": true, |
| + "403": true |
| + }, |
| + "expected_inline_chain": { |
| + "400": [], |
| + "403": [] |
| + }, |
| + "expected_reply_targets": { |
| + "note": "Conversion changes where a message renders, never what the two compose verbs quote: plain Reply still quotes the exact bubble, and Reply in thread on a converted message now continues the thread it was moved into.", |
| + "quote": { |
| + "402": { |
| + "id": 402, |
| + "name": "Charlie", |
| + "snippet": "They shut on Mondays" |
| + } |
| + }, |
| + "thread": { |
| + "402": { |
| + "id": 400, |
| + "name": "Alice", |
| + "snippet": "Where should we do dinner?" |
| + }, |
| + "403": { |
| + "id": 403, |
| + "name": "Dana", |
| + "snippet": "Unrelated: who has the keys?" |
| + } |
| + } |
| + } |
| + }, |
| + { |
| + "name": "convert_folds_before_its_members_arrive", |
| + "description": "The conversion folds FIRST and the messages it names arrive afterwards (an older history page paging in, an out-of-order live frame, a reconnect catch-up). The result is identical to folding them the other way round: 501 and 502 land in 500's thread the moment they arrive.", |
| + "note": "Order-independence is a hard requirement, not a nicety: history pages arrive newest-first while live frames arrive oldest-first, so no client can rely on seeing a conversion after its members. The claim map is state; where each message renders is DERIVED from it on every fold, so a message that arrives after its own conversion is stamped on arrival.", |
| + "message_sequence": [ |
| + { |
| + "server_message": { |
| + "id": 510, |
| + "created_at": 1700000510 |
| + }, |
| + "decrypted_payload": { |
| + "type": "threadconvert", |
| + "root": 500, |
| + "ids": [ |
| + 501, |
| + 502 |
| + ], |
| + "name": "Charlie", |
| + "uid": "user-charlie-1", |
| + "ts": 1700000510000 |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 500, |
| + "created_at": 1700000500 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Alice", |
| + "text": "Standup notes?", |
| + "ts": 1700000500000, |
| + "uid": "user-alice-1" |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 501, |
| + "created_at": 1700000501 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Bob", |
| + "text": "Pushed the router fix", |
| + "ts": 1700000501000, |
| + "uid": "user-bob-1", |
| + "replyTo": { |
| + "id": 500, |
| + "name": "Alice", |
| + "snippet": "Standup notes?" |
| + } |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 502, |
| + "created_at": 1700000502 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Charlie", |
| + "text": "Reviewing it now", |
| + "ts": 1700000502000, |
| + "uid": "user-charlie-1", |
| + "replyTo": { |
| + "id": 501, |
| + "name": "Bob", |
| + "snippet": "Pushed the router fix" |
| + } |
| + } |
| + } |
| + ], |
| + "expected_threads": { |
| + "roots": { |
| + "500": 0, |
| + "501": 500, |
| + "502": 500 |
| + }, |
| + "reply_counts": { |
| + "500": 2, |
| + "501": 0, |
| + "502": 0 |
| + }, |
| + "first_reply": { |
| + "500": 501 |
| + } |
| + }, |
| + "expected_thread_items": { |
| + "500": [ |
| + 500, |
| + 501, |
| + 502 |
| + ] |
| + }, |
| + "expected_main_feed": { |
| + "500": true, |
| + "501": false, |
| + "502": false |
| + }, |
| + "expected_main_feed_unconverted": { |
| + "500": true, |
| + "501": true, |
| + "502": true |
| + }, |
| + "expected_inline_chain": { |
| + "500": [] |
| + }, |
| + "expected_reply_targets": { |
| + "quote": { |
| + "502": { |
| + "id": 502, |
| + "name": "Charlie", |
| + "snippet": "Reviewing it now" |
| + } |
| + }, |
| + "thread": { |
| + "502": { |
| + "id": 500, |
| + "name": "Alice", |
| + "snippet": "Standup notes?" |
| + } |
| + } |
| + } |
| + }, |
| + { |
| + "name": "convert_overlapping_and_repeated", |
| + "description": "Two people convert overlapping parts of the same chain, and one conversion is sent twice. The fold settles the same way whatever the arrival order: the OLDEST conversion owns a message (lowest server id wins), a repeat changes nothing, and a claim whose root was itself converted flattens onto the outermost root — threads never nest.", |
| + "note": "620 moves 601 under 600; 621 moves 602 and 603 under 601 — which is itself now inside 600's thread, so both flatten onto 600 and thread 601 stays empty (expected_thread_items pins [601] alone, not [601, 602, 603]). 622 repeats 620 verbatim and is a no-op because 620 is older. Every claim names its root directly, so this is a pure map merge: no walk over the message graph, nothing to re-derive when a message is deleted.", |
| + "message_sequence": [ |
| + { |
| + "server_message": { |
| + "id": 600, |
| + "created_at": 1700000600 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Alice", |
| + "text": "Bug in the composer", |
| + "ts": 1700000600000, |
| + "uid": "user-alice-1" |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 601, |
| + "created_at": 1700000601 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Bob", |
| + "text": "Which build?", |
| + "ts": 1700000601000, |
| + "uid": "user-bob-1", |
| + "replyTo": { |
| + "id": 600, |
| + "name": "Alice", |
| + "snippet": "Bug in the composer" |
| + } |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 602, |
| + "created_at": 1700000602 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Charlie", |
| + "text": "Latest edge", |
| + "ts": 1700000602000, |
| + "uid": "user-charlie-1", |
| + "replyTo": { |
| + "id": 601, |
| + "name": "Bob", |
| + "snippet": "Which build?" |
| + } |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 603, |
| + "created_at": 1700000603 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Dana", |
| + "text": "Same here", |
| + "ts": 1700000603000, |
| + "uid": "user-dana-1", |
| + "replyTo": { |
| + "id": 602, |
| + "name": "Charlie", |
| + "snippet": "Latest edge" |
| + } |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 620, |
| + "created_at": 1700000620 |
| + }, |
| + "decrypted_payload": { |
| + "type": "threadconvert", |
| + "root": 600, |
| + "ids": [ |
| + 601 |
| + ], |
| + "name": "Bob", |
| + "uid": "user-bob-1", |
| + "ts": 1700000620000 |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 621, |
| + "created_at": 1700000621 |
| + }, |
| + "decrypted_payload": { |
| + "type": "threadconvert", |
| + "root": 601, |
| + "ids": [ |
| + 602, |
| + 603 |
| + ], |
| + "name": "Charlie", |
| + "uid": "user-charlie-1", |
| + "ts": 1700000621000 |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 622, |
| + "created_at": 1700000622 |
| + }, |
| + "decrypted_payload": { |
| + "type": "threadconvert", |
| + "root": 600, |
| + "ids": [ |
| + 601 |
| + ], |
| + "name": "Dana", |
| + "uid": "user-dana-1", |
| + "ts": 1700000622000 |
| + } |
| + } |
| + ], |
| + "expected_threads": { |
| + "roots": { |
| + "600": 0, |
| + "601": 600, |
| + "602": 600, |
| + "603": 600 |
| + }, |
| + "reply_counts": { |
| + "600": 3, |
| + "601": 0, |
| + "602": 0, |
| + "603": 0 |
| + }, |
| + "first_reply": { |
| + "600": 601 |
| + } |
| + }, |
| + "expected_thread_items": { |
| + "600": [ |
| + 600, |
| + 601, |
| + 602, |
| + 603 |
| + ], |
| + "601": [ |
| + 601 |
| + ], |
| + "note": "601's own thread is just 601: the messages a later conversion aimed at it flattened onto 600, because 601 had already been moved there." |
| + }, |
| + "expected_main_feed": { |
| + "600": true, |
| + "601": false, |
| + "602": false, |
| + "603": false |
| + }, |
| + "expected_main_feed_unconverted": { |
| + "600": true, |
| + "601": true, |
| + "602": true, |
| + "603": true |
| + }, |
| + "expected_inline_chain": { |
| + "600": [] |
| + }, |
| + "expected_reply_targets": { |
| + "quote": { |
| + "603": { |
| + "id": 603, |
| + "name": "Dana", |
| + "snippet": "Same here" |
| + } |
| + }, |
| + "thread": { |
| + "603": { |
| + "id": 600, |
| + "name": "Alice", |
| + "snippet": "Bug in the composer" |
| + } |
| + } |
| + } |
| + }, |
| + { |
| + "name": "convert_cycle_leaves_messages_inline", |
| + "description": "A malformed or hostile pair of conversions pointing at each other (700 into 701's thread, 701 into 700's). Neither resolves: both messages stay exactly where they were, in the feed, and the fold terminates.", |
| + "note": "A real chain can never loop — a reply's server id is always higher than the message it quotes — so this can only come from a client that made it up. The resolver walks a claimed root through to the thread it ended up in, and a cycle resolves to no conversion at all rather than hanging or picking arbitrarily. expected_inline_chain still offers the ordinary chain under 700, because nothing moved.", |
| + "message_sequence": [ |
| + { |
| + "server_message": { |
| + "id": 700, |
| + "created_at": 1700000700 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Alice", |
| + "text": "Anyone seen the cat?", |
| + "ts": 1700000700000, |
| + "uid": "user-alice-1" |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 701, |
| + "created_at": 1700000701 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Bob", |
| + "text": "On the roof again", |
| + "ts": 1700000701000, |
| + "uid": "user-bob-1", |
| + "replyTo": { |
| + "id": 700, |
| + "name": "Alice", |
| + "snippet": "Anyone seen the cat?" |
| + } |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 730, |
| + "created_at": 1700000730 |
| + }, |
| + "decrypted_payload": { |
| + "type": "threadconvert", |
| + "root": 701, |
| + "ids": [ |
| + 700 |
| + ], |
| + "name": "Charlie", |
| + "uid": "user-charlie-1", |
| + "ts": 1700000730000 |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 731, |
| + "created_at": 1700000731 |
| + }, |
| + "decrypted_payload": { |
| + "type": "threadconvert", |
| + "root": 700, |
| + "ids": [ |
| + 701 |
| + ], |
| + "name": "Charlie", |
| + "uid": "user-charlie-1", |
| + "ts": 1700000731000 |
| + } |
| + } |
| + ], |
| + "expected_threads": { |
| + "roots": { |
| + "700": 0, |
| + "701": 0 |
| + }, |
| + "reply_counts": { |
| + "700": 0, |
| + "701": 0 |
| + }, |
| + "first_reply": {} |
| + }, |
| + "expected_thread_items": { |
| + "700": [ |
| + 700 |
| + ], |
| + "701": [ |
| + 701 |
| + ] |
| + }, |
| + "expected_main_feed": { |
| + "700": true, |
| + "701": true |
| + }, |
| + "expected_main_feed_unconverted": { |
| + "700": true, |
| + "701": true |
| + }, |
| + "expected_inline_chain": { |
| + "700": [ |
| + 701 |
| + ], |
| + "701": [] |
| + }, |
| + "expected_reply_targets": { |
| + "quote": { |
| + "701": { |
| + "id": 701, |
| + "name": "Bob", |
| + "snippet": "On the roof again" |
| + } |
| + }, |
| + "thread": { |
| + "701": { |
| + "id": 701, |
| + "name": "Bob", |
| + "snippet": "On the roof again" |
| + } |
| + } |
| + } |
| + }, |
| + { |
| + "name": "convert_conflicting_claims_oldest_wins", |
| + "description": "Two conversions genuinely disagree about where a message belongs: 821 moves 802 into 810's thread, 822 moves the same message into 800's. The OLDEST claim wins — 802 lands in 810's thread — and every client settles there whatever order the events arrive in.", |
| + "note": "The vector that pins the tie-break itself. It has to be a disagreement about the ROOT, and the two roots have to be in different threads: when the loser's root sits inside the winner's, the flatten in convertedRoots makes both rules produce the same feed — which is why convert_overlapping_and_repeated (where 620 and 622 name the SAME root) cannot pin this. Mutation check: flipping the merge to newest-wins moves 802 into 800's thread and fails expected_threads.roots, reply_counts, first_reply, expected_thread_items and expected_main_feed here. Both claims are legitimately shaped — 802 quotes 801, which the conversions claimed — so the receiver's relationship check (convert_unrelated_ids_are_ignored) accepts either and the tie-break alone decides.", |
| + "message_sequence": [ |
| + { |
| + "server_message": { |
| + "id": 795, |
| + "created_at": 1700000795 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Dana", |
| + "text": "Standup notes", |
| + "ts": 1700000795000, |
| + "uid": "user-dana-1" |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 800, |
| + "created_at": 1700000800 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Alice", |
| + "text": "Shall we move the standup?", |
| + "ts": 1700000800000, |
| + "uid": "user-alice-1" |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 801, |
| + "created_at": 1700000801 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Bob", |
| + "text": "Half an hour later suits me", |
| + "ts": 1700000801000, |
| + "uid": "user-bob-1", |
| + "replyTo": { |
| + "id": 800, |
| + "name": "Alice", |
| + "snippet": "Shall we move the standup?" |
| + } |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 802, |
| + "created_at": 1700000802 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Charlie", |
| + "text": "Same, and I'll bring the notes", |
| + "ts": 1700000802000, |
| + "uid": "user-charlie-1", |
| + "replyTo": { |
| + "id": 801, |
| + "name": "Bob", |
| + "snippet": "Half an hour later suits me" |
| + } |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 820, |
| + "created_at": 1700000820 |
| + }, |
| + "decrypted_payload": { |
| + "type": "threadconvert", |
| + "root": 800, |
| + "ids": [ |
| + 801 |
| + ], |
| + "name": "Bob", |
| + "uid": "user-bob-1", |
| + "ts": 1700000820000 |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 821, |
| + "created_at": 1700000821 |
| + }, |
| + "decrypted_payload": { |
| + "type": "threadconvert", |
| + "root": 795, |
| + "ids": [ |
| + 802 |
| + ], |
| + "name": "Dana", |
| + "uid": "user-dana-1", |
| + "ts": 1700000821000 |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 822, |
| + "created_at": 1700000822 |
| + }, |
| + "decrypted_payload": { |
| + "type": "threadconvert", |
| + "root": 800, |
| + "ids": [ |
| + 802 |
| + ], |
| + "name": "Charlie", |
| + "uid": "user-charlie-1", |
| + "ts": 1700000822000 |
| + } |
| + } |
| + ], |
| + "expected_threads": { |
| + "roots": { |
| + "795": 0, |
| + "800": 0, |
| + "801": 800, |
| + "802": 795 |
| + }, |
| + "reply_counts": { |
| + "795": 1, |
| + "800": 1, |
| + "801": 0, |
| + "802": 0 |
| + }, |
| + "first_reply": { |
| + "795": 802, |
| + "800": 801 |
| + } |
| + }, |
| + "expected_thread_items": { |
| + "795": [ |
| + 795, |
| + 802 |
| + ], |
| + "800": [ |
| + 800, |
| + 801 |
| + ], |
| + "note": "802 is in 795's thread because 821 claimed it first — not in 800's, which 822 asked for a beat later." |
| + }, |
| + "expected_main_feed": { |
| + "795": true, |
| + "800": true, |
| + "801": false, |
| + "802": false |
| + }, |
| + "expected_main_feed_unconverted": { |
| + "795": true, |
| + "800": true, |
| + "801": true, |
| + "802": true |
| + }, |
| + "expected_inline_chain": { |
| + "795": [], |
| + "800": [] |
| + }, |
| + "expected_reply_targets": { |
| + "quote": { |
| + "802": { |
| + "id": 802, |
| + "name": "Charlie", |
| + "snippet": "Same, and I'll bring the notes" |
| + } |
| + }, |
| + "thread": { |
| + "802": { |
| + "id": 795, |
| + "name": "Dana", |
| + "snippet": "Standup notes" |
| + } |
| + } |
| + } |
| + }, |
| + { |
| + "name": "convert_unrelated_ids_are_ignored", |
| + "description": "A conversion names three ids, but only 903 actually hangs off root 900 — 901 and 902 are unrelated messages that quote nothing. Only 903 moves; the other two stay exactly where they are, in the feed.", |
| + "note": "The receiver checks the sender's id list against the messages themselves: a conversion moves a reply CHAIN, so a claimed message's own quote must be the root or another claimed message. Without this the event would be 'move any N messages under any root' — and since a conversion renders no bubble, the oldest claim wins forever, and deleting the convert row is not an undo, one crafted payload could quietly hide unrelated messages under an old root, unattributably. A claimed message that isn't loaded yet has no quote to check and simply stays inline until it arrives, the same degradation as a thread reply whose root is paged out.", |
| + "message_sequence": [ |
| + { |
| + "server_message": { |
| + "id": 900, |
| + "created_at": 1700000900 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Alice", |
| + "text": "Kicking off the migration", |
| + "ts": 1700000900000, |
| + "uid": "user-alice-1" |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 901, |
| + "created_at": 1700000901 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Bob", |
| + "text": "Coffee run — anyone?", |
| + "ts": 1700000901000, |
| + "uid": "user-bob-1" |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 902, |
| + "created_at": 1700000902 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Charlie", |
| + "text": "The invoice is paid", |
| + "ts": 1700000902000, |
| + "uid": "user-charlie-1" |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 903, |
| + "created_at": 1700000903 |
| + }, |
| + "decrypted_payload": { |
| + "name": "Dana", |
| + "text": "Which box first?", |
| + "ts": 1700000903000, |
| + "uid": "user-dana-1", |
| + "replyTo": { |
| + "id": 900, |
| + "name": "Alice", |
| + "snippet": "Kicking off the migration" |
| + } |
| + } |
| + }, |
| + { |
| + "server_message": { |
| + "id": 920, |
| + "created_at": 1700000920 |
| + }, |
| + "decrypted_payload": { |
| + "type": "threadconvert", |
| + "root": 900, |
| + "ids": [ |
| + 901, |
| + 902, |
| + 903 |
| + ], |
| + "name": "Bob", |
| + "uid": "user-bob-1", |
| + "ts": 1700000920000 |
| + } |
| + } |
| + ], |
| + "expected_threads": { |
| + "roots": { |
| + "900": 0, |
| + "901": 0, |
| + "902": 0, |
| + "903": 900 |
| + }, |
| + "reply_counts": { |
| + "900": 1, |
| + "901": 0, |
| + "902": 0, |
| + "903": 0 |
| + }, |
| + "first_reply": { |
| + "900": 903 |
| + } |
| + }, |
| + "expected_thread_items": { |
| + "900": [ |
| + 900, |
| + 903 |
| + ], |
| + "note": "Only the message that really replied to 900 is in its thread; naming the other two moved nothing." |
| + }, |
| + "expected_main_feed": { |
| + "900": true, |
| + "901": true, |
| + "902": true, |
| + "903": false |
| + }, |
| + "expected_main_feed_unconverted": { |
| + "900": true, |
| + "901": true, |
| + "902": true, |
| + "903": true |
| + }, |
| + "expected_inline_chain": { |
| + "900": [], |
| + "901": [], |
| + "902": [] |
| + }, |
| + "expected_reply_targets": { |
| + "quote": { |
| + "901": { |
| + "id": 901, |
| + "name": "Bob", |
| + "snippet": "Coffee run — anyone?" |
| + } |
| + }, |
| + "thread": { |
| + "901": { |
| + "id": 901, |
| + "name": "Bob", |
| + "snippet": "Coffee run — anyone?" |
| + }, |
| + "903": { |
| + "id": 900, |
| + "name": "Alice", |
| + "snippet": "Kicking off the migration" |
| + } |
| + } |
| + } |
| } |
| ] |
| } |
| \ No newline at end of file |
diff --git a/test/format.test.mjs b/test/format.test.mjs| index 8b5053b..672a308 100644 |
| --- a/test/format.test.mjs |
| +++ b/test/format.test.mjs |
| @@ -1,6 +1,6 @@ |
| // Tests for text formatting (link detection, file sizes). node test/format.test.mjs |
| import assert from "node:assert/strict"; |
| -import { tokenizeText, richTokens, blockTokens, firstLink, formatBytes, replySnippet, reactionLine, emojiOnlyCount, typingSummary, autoGroupTitle, nameList, membershipLine, youtubeId, changelogParagraphs, formatCountdown } from "../web/static/format.js"; |
| +import { tokenizeText, richTokens, blockTokens, firstLink, formatBytes, replySnippet, reactionLine, convertLine, emojiOnlyCount, typingSummary, autoGroupTitle, nameList, membershipLine, youtubeId, changelogParagraphs, formatCountdown } from "../web/static/format.js"; |
| import { emojify, emojiMatches } from "../web/static/emoji.js"; |
| let failures = 0; |
| @@ -143,6 +143,15 @@ await test("reactionLine speaks for an un-react too", () => { |
| ); |
| }); |
| +await test("convertLine says what a conversion did, pluralised and grouped", () => { |
| + // A conversion has no text of its own, so without this the chat-list row |
| + // would go blank the way a reaction's used to. |
| + assert.equal(convertLine("James", 3), "James moved 3 replies into a thread"); |
| + assert.equal(convertLine("You", 1), "You moved a reply into a thread", "never '1 replies'"); |
| + assert.equal(convertLine("James", 1204), "James moved 1,204 replies into a thread"); |
| + assert.equal(convertLine("", 2), "Someone moved 2 replies into a thread", "an unresolved actor still reads"); |
| +}); |
| + |
| await test("reactionLine falls back to the bare form without a snippet", () => { |
| // Reactions sent before targetText shipped — the primary path on rollout. |
| assert.equal(reactionLine("James", { type: "reaction", emoji: "👍", on: true }), "James reacted 👍"); |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index 4496467..83facac 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -326,6 +326,45 @@ function openReplyThread(m, ctx, anchor) { |
| t.open(target.id, target, anchor && anchor.tabIndex >= 0 ? anchor : null); |
| } |
| +// convertChain / convertToThread: the "Convert to thread" verb — take the |
| +// inline reply chain hanging under `m` out of the transcript and into a thread |
| +// rooted on `m`. The chain is derived here, on the converting client, and |
| +// shipped as an explicit id list (see the chat.convert fold): the list is what |
| +// everyone folds, so no two clients can disagree about what moved. |
| +// |
| +// convertChain is the same question the menus ask to decide whether to offer |
| +// the verb at all — a message with nothing under it never shows it, and neither |
| +// does a bubble inside a thread (nothing inline hangs off one). |
| +// |
| +// `anchor` is the bubble the menu opened on, and it settles the one case the |
| +// chain can't: a thread's ROOT is rendered inside the thread view too, and it |
| +// CAN still have inline replies out in the feed. Tidying those is something |
| +// you do while looking at the feed, so the verb stays out of the thread's own |
| +// menus — the same call the native containers make. |
| +function convertChain(m, ctx, anchor) { |
| + if (!m || !m.sid || !host || !host.convert) return []; |
| + if (anchor && anchor.closest && anchor.closest(".chatapp-thread-panel")) return []; |
| + return inlineChainIds(((ctx.getState() || {}).msgs) || [], m.sid); |
| +} |
| + |
| +// The confirm is deliberate, not ceremony: this reorganises the chat for |
| +// EVERYONE and, in v1, can't be undone (see convertConfirm). |
| +// |
| +// A conversion that can't reach the server has to SAY so. Nothing about a |
| +// failed one is visible — the chain simply stays where it was, which is |
| +// exactly what it looks like when nobody converted anything — so after |
| +// confirming a one-way act the reader would be left believing it happened. The |
| +// answer is an ordinary private notice in the feed (inline, "only visible to |
| +// you"), never a floating toast. |
| +function convertToThread(m, ctx, anchor) { |
| + const ids = convertChain(m, ctx, anchor); |
| + if (!ids.length) return; |
| + if (!confirm(convertConfirm(ids.length))) return; |
| + Promise.resolve(host.convert(ctx.threadId, m.sid, ids)).then((sent) => { |
| + if (!sent) addLocalNotice(ctx, { text: "Those replies didn't move — the connection dropped. Try again once you're back online.", short: "Couldn't move those replies" }); |
| + }); |
| +} |
| + |
| // activeThreadPane: the open reply-thread controller for a mounted thread |
| // (null when none is open) — how the shell's ▤ Views menu lists an open |
| // thread as a view and puts focus back in it. openThread/closeThread dispatch |
| @@ -433,7 +472,199 @@ function dayLabel(ms) { |
| // 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; |
| + if (m.reply && m.reply.thread && m.reply.id) return m.reply.id; |
| + // …or the thread a CONVERSION moved it into after the fact (see |
| + // claimConverts). The wire flag is read first on purpose: a reply someone |
| + // composed in a thread belongs to that thread, and no conversion can pull it |
| + // out of one. |
| + return m.movedTo || 0; |
| +} |
| + |
| +// ---- converting an inline chain into a thread (the chat.convert fold) ------- |
| +// |
| +// Reply and Reply-in-thread are separate verbs, and EVERY reply in the history |
| +// from before that split is inline — so the way a long chain becomes a thread |
| +// is always after the fact. The messages themselves can't be rewritten to say |
| +// so: only their author may edit one, only for fifteen minutes, and rewriting N |
| +// messages would churn N authorships and timestamps to change a display rule. |
| +// |
| +// So a conversion is its own encrypted event — one message-shaped payload |
| +// saying "these ids now live in the thread rooted at R" — that every client |
| +// folds client-side, exactly as a reaction does. The server stores one more |
| +// opaque blob and learns nothing; a client too old to know the payload type |
| +// drops it and goes on showing the chain inline. |
| + |
| +// A server message id as the wire may carry it: a positive whole number, and |
| +// NOTHING a coercion would rescue. Deliberately not `Number(x) || 0` — Swift's |
| +// MessagePayload types root/ids as Int64, so `"400"` or `400.5` fails to decode |
| +// and native drops the whole payload. Coercing here would convert on web and do |
| +// nothing on native: the same messages, a different transcript per platform, |
| +// which the cross-platform contract calls a hard error. |
| +function isSid(n) { |
| + return Number.isSafeInteger(n) && n > 0; |
| +} |
| + |
| +// claimConverts: merge one convert event's claims into the map (sid → |
| +// { root, at }, where `at` is the CONVERT EVENT's own server id). |
| +// |
| +// Two people can convert overlapping chains, and the same event folds twice |
| +// routinely (history page + live echo, or a reconnect catch-up), so the merge |
| +// must be a pure function of the SET of events, never of their arrival order: |
| +// the OLDEST conversion (lowest `at`) owns a message, and re-folding one |
| +// changes nothing. Ties are impossible — `at` is a server message id. Returns |
| +// null when nothing changed, so the fold can hand back the same state. |
| +// Exported for tests: "the oldest conversion wins, and a repeat is a no-op" is |
| +// the rule that makes the fold order-independent, so it's pinned directly. |
| +// |
| +// The guards mirror Swift's exactly (see isSid): a payload native would drop is |
| +// dropped here too, whole or per id. |
| +export function claimConverts(claims, root, ids, at) { |
| + if (!isSid(root) || !isSid(at)) return null; |
| + let out = null; |
| + for (const sid of ids.slice(0, MAX_CONVERT_IDS)) { |
| + if (!isSid(sid) || sid === root) continue; // a root is never inside its own thread |
| + const prev = (out || claims)[sid]; |
| + if (prev && prev.at <= at) continue; // an older conversion already owns it |
| + if (!out) out = { ...claims }; |
| + out[sid] = { root, at }; |
| + } |
| + return out; |
| +} |
| + |
| +// convertedRoots: the claims resolved to the thread each message actually |
| +// lands in — a pure function of the claim map, so every client and every |
| +// arrival order derives the same answer. Exported for tests. |
| +// |
| +// Claims can nest: one conversion moves [b, c] under root a while another moves |
| +// [c] under root b. A claimed root is followed through to the thread IT ended |
| +// up in, so claim-rooted nesting flattens onto the outermost root. (That is |
| +// what's guaranteed — NOT "threads never nest". A reply someone composed in a |
| +// thread carries the wire flag, which is authoritative and can't be |
| +// re-pointed, so if a conversion moves the message that reply's thread is |
| +// rooted on, its thread ends up inside the converted one. Benign and reachable |
| +// — it renders from its root's bubble in the outer thread, exactly like any |
| +// thread — and only a race can produce it: a "Reply in thread" in flight while |
| +// someone converts the chain it lives in.) |
| +// |
| +// A cycle can only come from a malformed or hostile payload (a real reply's sid |
| +// is always higher than the message it quotes, so real chains can't loop) and |
| +// resolves to no conversion at all rather than hanging. |
| +export function convertedRoots(claims) { |
| + const out = new Map(); |
| + for (const key of Object.keys(claims || {})) { |
| + const sid = Number(key); |
| + let cur = claims[key].root; |
| + const seen = new Set([sid]); |
| + while (claims[cur] && !seen.has(cur)) { |
| + seen.add(cur); |
| + cur = claims[cur].root; |
| + } |
| + if (claims[cur]) continue; // cycled — leave every message in it inline |
| + if (cur && cur !== sid) out.set(sid, cur); |
| + } |
| + return out; |
| +} |
| + |
| +// convertHolds: does a conversion's claim on `m` actually hold? The sender's |
| +// id list is a CLAIM, not a fact, and the receiver checks it against the |
| +// message itself: a conversion moves a reply CHAIN, so each message it names |
| +// must really hang off the thread it's being moved into — its own quote is |
| +// either the root, or another message the conversions claimed. |
| +// |
| +// Without this the shipped primitive would be "move any N messages under any |
| +// root", which is a great deal worse than it sounds: a conversion renders no |
| +// bubble (its only trace is a transient chat-list line), the oldest claim wins |
| +// forever so no later event can undo it, and deleting the convert row isn't an |
| +// undo either — so one crafted payload could quietly hide a couple of hundred |
| +// unrelated messages under some old root, unattributably. Now such an id is |
| +// simply ignored and the message stays exactly where it is. |
| +// |
| +// Order-independent, and it needs no loaded window beyond the message itself: a |
| +// claimed message that hasn't arrived yet has no quote to check, so it stays |
| +// inline until it does — the same degradation as a thread reply whose root is |
| +// paged out. |
| +function convertHolds(m, claims) { |
| + const claim = claims[m.sid]; |
| + if (!claim) return false; |
| + const quoted = (m.reply && m.reply.id) || 0; |
| + return quoted === claim.root || !!claims[quoted]; |
| +} |
| + |
| +// applyConverts: stamp each message with the thread a conversion moved it into |
| +// (`movedTo` — the one field threadRootOf reads besides the wire flag). |
| +// Derived, never authoritative: the claim map is the state and this is its |
| +// projection onto the loaded window, re-run on every fold so a message that |
| +// arrives AFTER its own conversion (an older page paging in, an out-of-order |
| +// live frame) lands in the thread just the same. Messages it doesn't move keep |
| +// their identity, so nothing else re-renders. |
| +export function applyConverts(msgs, claims) { |
| + const roots = convertedRoots(claims); |
| + let changed = false; |
| + const out = msgs.map((m) => { |
| + const to = (m.sid && convertHolds(m, claims) && roots.get(m.sid)) || 0; |
| + if ((m.movedTo || 0) === to) return m; |
| + changed = true; |
| + return { ...m, movedTo: to }; |
| + }); |
| + return changed ? out : msgs; |
| +} |
| + |
| +// inlineChainIds: the inline reply chain hanging under `rootSid` — every |
| +// message that quotes it, everything that quotes those, and so on — which is |
| +// exactly what "Convert to thread" moves. Empty when there's nothing to move, |
| +// which is also the whole gate on offering the verb. |
| +// |
| +// This is the transitive quote walk the two-verb split deleted, back in the |
| +// one narrow place that needs it: deciding what a conversion covers. It is NOT |
| +// a membership rule — nothing here reads it to decide where a message renders |
| +// (that's threadRootOf, still a straight read) — and it runs on the CONVERTING |
| +// client only, at menu time, to build the event's id list. |
| +// |
| +// One forward pass suffices because `msgs` is in server order and a reply's sid |
| +// is always higher than the message it quotes: by the time the walk reaches a |
| +// reply, whatever it quotes has already been seen. Messages already living in a |
| +// thread are skipped, so the chain is only ever inline conversation — and a |
| +// bubble inside a thread pane has no chain at all, which is what keeps the verb |
| +// off the menus in there. |
| +export function inlineChainIds(msgs, rootSid) { |
| + if (!rootSid) return []; |
| + const root = msgs.find((m) => m.sid === rootSid); |
| + if (!root || threadRootOf(root)) return []; // unloaded, or already in a thread |
| + const chain = new Set([rootSid]); |
| + const out = []; |
| + for (const m of msgs) { |
| + if (!m.sid || threadRootOf(m)) continue; // pending copy, or already in a thread |
| + const quoted = (m.reply && m.reply.id) || 0; |
| + if (!quoted || !chain.has(quoted)) continue; |
| + chain.add(m.sid); |
| + out.push(m.sid); |
| + } |
| + return out; |
| +} |
| + |
| +// Does a conversion name this sid? The O(1) question the message fold asks |
| +// before paying for a re-stamp — cheap enough to ask on every arrival, and |
| +// false for every message in the overwhelming case of a chat where nobody has |
| +// converted anything. |
| +function converted(s, sid) { |
| + return !!(sid && s.converts && s.converts[sid]); |
| +} |
| + |
| +// A hostile client could name thousands of ids in one payload (the server caps |
| +// a blob at 64KB and can't read it), and the claim map is never pruned — so the |
| +// fold takes a generous slice rather than however many arrived. Far above any |
| +// real chain: a conversation with more than this many inline replies to one |
| +// message doesn't exist. Exported so the chat-list line counts what the fold |
| +// actually took, never what the payload asked for. |
| +export const MAX_CONVERT_IDS = 2000; |
| + |
| +// The one wording for the conversion's confirm, so web, Mac and iOS ask the |
| +// same question — pluralised and locale-grouped like every displayed count. |
| +// It says what everyone will see and that it's one-way, because it is: v1 has |
| +// no undo. |
| +export function convertConfirm(n) { |
| + const what = n === 1 ? "this reply" : `these ${n.toLocaleString()} replies`; |
| + return `Move ${what} into a thread? Everyone will see the conversation there instead of in the chat, and this can't be undone.`; |
| } |
| // quoteTarget: the replyTo a PLAIN reply to `m` carries — the exact message |
| @@ -457,9 +688,15 @@ 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: rootSid, name: (m.reply && m.reply.name) || "", snippet: (m.reply && m.reply.snippet) || "" }; |
| + if (root) return { id: root.sid, name: root.name, snippet: replySnippet(root.raw) }; |
| + // Root paged out. The frozen snapshot a FLAGGED reply carries is the root's |
| + // own (every thread send derives its ref from the root), so it stands in. A |
| + // CONVERTED message's ref is the message it quoted — which is not the root — |
| + // so there's nothing truthful to fall back on and the id travels alone: the |
| + // reply still lands in the right thread, and the view says the original |
| + // isn't available rather than naming the wrong message. |
| + const flagged = !!(m.reply && m.reply.thread); |
| + return { id: rootSid, name: (flagged && m.reply.name) || "", snippet: (flagged && m.reply.snippet) || "" }; |
| } |
| // threadItems: the reply thread rooted at `rootSid` — the root message (when |
| @@ -518,11 +755,15 @@ export function threadListItems(msgs) { |
| let row = byRoot.get(rootSid); |
| if (!row) { |
| const root = bySid.get(rootSid); |
| + // Unloaded root: only a FLAGGED reply's ref is the root's own snapshot |
| + // (see threadTarget) — a converted message quotes something else, so its |
| + // row is headed by nothing rather than by the wrong message. |
| + const stand = m.reply && m.reply.thread ? m.reply : {}; |
| row = { |
| rootSid, |
| rootLoaded: !!root, |
| - name: root ? root.name || "" : m.reply.name || "", |
| - snippet: root ? replySnippet(root.raw) : m.reply.snippet || "", |
| + name: root ? root.name || "" : stand.name || "", |
| + snippet: root ? replySnippet(root.raw) : stand.snippet || "", |
| count: 0, |
| last: m, |
| }; |
| @@ -768,7 +1009,8 @@ export const app = defineApp({ |
| const msgs = s.msgs.slice(); |
| msgs[idx] = { ...msgs[idx], sid, ts: e.ts || msgs[idx].ts, pending: false, slow: false }; |
| msgs.sort(bySidPendingLast); |
| - return { msgs }; |
| + // It has a real sid now, so a conversion could already name it. |
| + return { ...s, msgs: converted(s, sid) ? applyConverts(msgs, s.converts) : msgs }; |
| } |
| } |
| const raw = { name: e.name || "", text, ts: e.ts || ((meta && meta.ts) || 0) }; |
| @@ -813,7 +1055,31 @@ export const app = defineApp({ |
| }, |
| ]; |
| msgs.sort(bySidPendingLast); |
| - return { msgs }; |
| + // A message can arrive AFTER the conversion that moves it (an older page |
| + // paging in, an out-of-order live frame, a reconnect catch-up): stamp it |
| + // so it lands in its thread rather than inline. ONLY when a conversion |
| + // actually names this sid — every other message's stamp is unchanged by |
| + // an arrival (a claim resolves against the claim map alone, never against |
| + // the loaded window), so a history page of a hundred rows does one O(1) |
| + // lookup each instead of a hundred full re-resolutions. |
| + return { ...s, msgs: converted(s, sid) ? applyConverts(msgs, s.converts) : msgs }; |
| + }, |
| + // An inline reply chain converted into a thread: the ids now live in the |
| + // thread rooted at `root`. Never a wire type — shell.js recognises the |
| + // encrypted "threadconvert" payload and synthesises this, exactly as it |
| + // does for reactions, so nobody can spoof one with a typed message. The |
| + // claim map IS the state; where each message renders is derived from it |
| + // (applyConverts → movedTo → threadRootOf), so folds in any order, and |
| + // repeated folds of the same event, settle on the same feed. |
| + [N.ev("convert")]: (s, e, meta) => { |
| + const at = (meta && meta.id) || 0; // the convert event's own server id |
| + const ids = Array.isArray(e.ids) ? e.ids : []; |
| + if (!ids.length) return s; |
| + // No coercion: claimConverts validates root/at/each id the way Swift's |
| + // decoder does, so a payload native drops is dropped here too. |
| + const converts = claimConverts(s.converts || {}, e.root, ids, at); |
| + if (!converts) return s; |
| + return { ...s, converts, msgs: applyConverts(s.msgs, converts) }; |
| }, |
| // A still-unconfirmed optimistic send, 5s on: mark it slow so the bubble |
| // surfaces a subtle "sending…" state (host-fed by shell.js's markSlow timer). |
| @@ -824,7 +1090,7 @@ export const app = defineApp({ |
| if (idx < 0) return s; |
| const msgs = s.msgs.slice(); |
| msgs[idx] = { ...msgs[idx], slow: true }; |
| - return { msgs }; |
| + return { ...s, msgs }; |
| }, |
| [N.ev("reaction")]: (s, e, meta, ctx) => { |
| if (e.targetSid === undefined || e.emoji === undefined || e.uid === undefined || e.on === undefined) return s; |
| @@ -858,16 +1124,21 @@ export const app = defineApp({ |
| derived.sort((a, b) => a.emoji.localeCompare(b.emoji)); |
| return { ...m, reactions: derived, reactionEvents: updated, rev: m.rev + 1 }; |
| }); |
| - return { msgs }; |
| + return { ...s, msgs }; |
| }, |
| [N.ev("edit")]: (s, e) => ({ |
| + ...s, |
| msgs: s.msgs.map((m) => { |
| if (m.sid !== e.msgId) return m; |
| const text = typeof e.text === "string" ? e.text : m.text; |
| return { ...m, text, raw: { ...m.raw, text }, edited: true, rev: m.rev + 1 }; |
| }), |
| }), |
| - [N.ev("delete")]: (s, e) => ({ msgs: s.msgs.filter((m) => m.sid !== e.msgId) }), |
| + // A deleted message leaves its conversion claim behind — harmless, since |
| + // nothing resolves it any more. Its neighbours are untouched: every claim |
| + // names its root directly, so deleting one message mid-chain never spills |
| + // the rest of the thread back into the feed. |
| + [N.ev("delete")]: (s, e) => ({ ...s, msgs: s.msgs.filter((m) => m.sid !== e.msgId) }), |
| }, |
| // Typing: relay-only, never stored or folded. The server echoes your own beat |
| @@ -1447,8 +1718,14 @@ function renderItem(m, ctx) { |
| // 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. |
| + // |
| + // "In a thread" is threadRootOf, not the wire flag: a CONVERTED reply |
| + // rendered inline (its root paged out) wears the same thread tally on |
| + // its card — paintQuoteThreadLink reads the same rollup — so the card |
| + // must open the thread it advertises. Native asks the same question |
| + // (MessageBubble's replyQuote → threadRootID). |
| const t = threads.get(ctx.threadId); |
| - const ref = m.reply.thread ? threadTarget(((ctx.getState() || {}).msgs) || [], m) : null; |
| + const ref = threadRootOf(m) ? threadTarget(((ctx.getState() || {}).msgs) || [], m) : null; |
| if (t && ref && ref.id) t.open(ref.id, ref, q); |
| else scrollToMessage(wrap, m.reply.id); |
| }; |
| @@ -1940,6 +2217,13 @@ function openMessagePicker(m, ctx, anchor) { |
| // The anchor is the opener: the hover strip's button on that path, else the |
| // bubble itself (unfocusable — openReplyThread drops it). |
| action(icon("thread"), "Reply in thread", () => openReplyThread(m, ctx, anchor)); |
| + // Convert to thread — offered only where there IS an inline chain to move |
| + // (convertChain), so it never shows on a message nobody answered, and never |
| + // inside the thread pane. Right after the thread verb: the two are about the |
| + // same place, one for what comes next and one for what already happened. |
| + if (convertChain(m, ctx, anchor).length) { |
| + action(icon("foldIn"), "Convert to thread", () => convertToThread(m, ctx, anchor)); |
| + } |
| // Copy — the original touch sheet's action; native selection is suppressed on |
| // bubbles (long-press opens this menu), so Copy must live here. |
| if (m.text.trim()) { |
| @@ -2049,6 +2333,11 @@ function sheetActions(m, ctx, wrap) { |
| // Its own glyph (see the desktop picker) — the sheet's rows are labelled, so |
| // this is about the two verbs looking as different as they behave. |
| acts.push({ label: "Reply in thread", icon: icon("thread"), run: () => openReplyThread(m, ctx, wrap) }); |
| + // Convert to thread — same gate as the desktop picker: only on a message |
| + // that actually has an inline chain under it. |
| + if (convertChain(m, ctx, wrap).length) { |
| + acts.push({ label: "Convert to thread", icon: icon("foldIn"), run: () => convertToThread(m, ctx, wrap) }); |
| + } |
| // Native selection is suppressed on bubbles (long-press opens this sheet), so |
| // Copy must live here. |
| if (m.text.trim()) { |
| @@ -4657,6 +4946,12 @@ function buildSkeleton(el, ctx) { |
| for (const r of rows) { |
| const label = replyCountLabel(r.count); // the same string the root's tally wears |
| const who = r.name ? r.name + ": " : ""; |
| + // A CONVERTED chain whose root has scrolled out of the loaded window has |
| + // no truthful stand-in (threadListItems: only a flagged reply's ref is |
| + // its root's own snapshot), so the row would be a blank line above its |
| + // tally — and would read "Open thread, 3 Replies," to a screen reader. |
| + // Say what the thread view says in exactly the same case. |
| + const line0 = r.snippet || (r.rootLoaded ? "" : "The original message isn't available"); |
| const b = document.createElement("button"); |
| b.type = "button"; |
| b.className = "thread-list-row"; |
| @@ -4668,7 +4963,7 @@ function buildSkeleton(el, ctx) { |
| nameEl.textContent = who; |
| const snip = document.createElement("span"); |
| snip.className = "thread-list-snippet"; |
| - snip.textContent = r.snippet || ""; |
| + snip.textContent = line0; |
| line.append(nameEl, snip); |
| const count = document.createElement("span"); |
| count.className = "thread-list-count"; |
| @@ -4679,7 +4974,7 @@ function buildSkeleton(el, ctx) { |
| // than a fragment of someone's sentence. Both visible strings are |
| // carried through verbatim, so voice control still matches on the words |
| // a sighted user can see (WCAG 2.5.3). |
| - b.setAttribute("aria-label", `Open thread, ${label}, ${who}${r.snippet || ""}`); |
| + b.setAttribute("aria-label", `Open thread, ${label}, ${who}${line0}`); |
| b.onclick = () => { |
| hidePanel(); // one right dock — see the note above |
| // No opener worth recording: the row dies with the list, so focus |
diff --git a/web/static/format.js b/web/static/format.js| index e53dd03..640a517 100644 |
| --- a/web/static/format.js |
| +++ b/web/static/format.js |
| @@ -263,6 +263,18 @@ export function reactionLine(who, payload, max = 40) { |
| return snip ? `${head} ${off ? "from" : "to"} “${snip}”` : head; |
| } |
| +// The chat-list line for a conversion — "James moved 3 replies into a thread". |
| +// Same reasoning as reactionLine: a conversion is an ordinary encrypted message |
| +// with no text of its own, so it can be the newest thing in a thread, and a row |
| +// that said nothing would go blank. `who` is resolved by the caller (so it can |
| +// say "You"); the count pluralises and groups for the locale. Pure, and mirrored |
| +// natively by ThreadStore's preview so every platform reads the same line. |
| +export function convertLine(who, n) { |
| + const actor = String(who || "").trim() || "Someone"; |
| + const count = Number(n) || 0; |
| + return `${actor} moved ${count === 1 ? "a reply" : `${count.toLocaleString()} replies`} into a thread`; |
| +} |
| + |
| // Counts the emoji in a message whose only content is emoji (plus whitespace), |
| // returning 0 for anything else. Used to render short all-emoji messages large |
| // and bubble-less (à la Messages). Grapheme-segmented so ZWJ families (👨👩👧👦), |
diff --git a/web/static/icons.js b/web/static/icons.js| index 1e697f8..7cf095d 100644 |
| --- a/web/static/icons.js |
| +++ b/web/static/icons.js |
| @@ -82,6 +82,18 @@ export const ICON_THREAD = |
| 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + |
| '<path d="M16 10a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 14.286V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>' + |
| '<path d="M20 9a2 2 0 0 1 2 2v10.286a.71.71 0 0 1-1.212.502l-2.202-2.202A2 2 0 0 0 17.172 19H10a2 2 0 0 1-2-2v-1"></path></svg>'; |
| +// Arrows folding in toward a line — "Convert to thread": take the reply chain |
| +// stacked under this message and collapse it out of the transcript. NOT the |
| +// thread glyph again: that verb is about where the NEXT reply goes, this one is |
| +// about tidying replies that already happened, and the picker's rows are |
| +// icon-only. (Lucide: fold-vertical) |
| +export const ICON_FOLD_IN = |
| + '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' + |
| + 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + |
| + '<path d="M12 22v-6"></path><path d="M12 8V2"></path>' + |
| + '<path d="M4 12H2"></path><path d="M10 12H8"></path>' + |
| + '<path d="M16 12h-2"></path><path d="M22 12h-2"></path>' + |
| + '<path d="m15 19-3-3-3 3"></path><path d="m15 5-3 3-3-3"></path></svg>'; |
| // Chain link, for the "Copy link" message-permalink action (issue #893). |
| export const ICON_LINK = |
| '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' + |
diff --git a/web/static/shell.js b/web/static/shell.js| index 0084f05..19e9bcd 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -48,7 +48,7 @@ import { |
| capAllows, |
| appReloadMessage, |
| } from "/static/ui.js"; |
| -import { richTokens, blockTokens, firstLink, replySnippet, reactionLine, formatBytes, autoGroupTitle, nameList, membershipLine, emojiOnlyCount, youtubeId, relTime, formatCountdown } from "/static/format.js"; |
| +import { richTokens, blockTokens, firstLink, replySnippet, reactionLine, convertLine, formatBytes, autoGroupTitle, nameList, membershipLine, emojiOnlyCount, youtubeId, relTime, formatCountdown } from "/static/format.js"; |
| import { emojify, emojiMatches } from "/static/emoji.js"; |
| import { startBootScene, finishBootScene } from "/static/bootscene.js"; |
| import { processImage, videoMeta } from "/static/media.js"; |
| @@ -146,7 +146,7 @@ import "/static/apps/admin.js"; // the admin panel (admin-only; opened via /admi |
| import "/static/apps/webhooks.js"; // incoming-webhooks management (admin-only; /webhooks — docs/webhooks.md) |
| import "/static/apps/appstudio.js"; // make + manage your own member apps (docs/member-apps.md) |
| import { openMemoryPanel } from "/static/apps/memory.js"; // /memory — the AI-memory scratchpad (docs/eleven-memory.md) |
| -import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping, feedGlideToBottom, scrollToMessage, repaintMentionsOf, activeThreadPane, threadListCount, openThreadList } from "/static/apps/chat.js"; |
| +import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping, feedGlideToBottom, scrollToMessage, repaintMentionsOf, activeThreadPane, threadListCount, openThreadList, MAX_CONVERT_IDS } from "/static/apps/chat.js"; |
| import { avatarFor, threadAvatarFor, paintAvatar, avatarEl } from "/static/avatars.js"; |
| import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js"; |
| import { INVITER_HINT_KEY, readInviterHint, inviterHintTarget } from "/static/invitehint.js"; |
| @@ -858,7 +858,7 @@ const EDIT_WINDOW_MS = 15 * 60 * 1000; // mirrors editWindow (hub.go); server en |
| const TIME_SEPARATOR_GAP_MS = 15 * 60 * 1000; |
| // The UI icon set (web/static/icons.js — Lucide idiom, docs/icons.md). |
| -import { ICON_REPLY, ICON_THREAD, ICON_X, ICON_EDIT, ICON_REACT, ICON_TRASH, ICON_PLAY, ICON_PIN, ICON_MUTE, ICON_REPORT, ICON_COPY, ICON_LINK, ICON_CHEVRON_DOWN, ICON_CHEVRON_LEFT, ICON_MORE_VERTICAL, ICON_BRAIN, ICON_WRENCH } from "/static/icons.js"; |
| +import { ICON_REPLY, ICON_THREAD, ICON_FOLD_IN, ICON_X, ICON_EDIT, ICON_REACT, ICON_TRASH, ICON_PLAY, ICON_PIN, ICON_MUTE, ICON_REPORT, ICON_COPY, ICON_LINK, ICON_CHEVRON_DOWN, ICON_CHEVRON_LEFT, ICON_MORE_VERTICAL, ICON_BRAIN, ICON_WRENCH } from "/static/icons.js"; |
| const app = document.getElementById("app"); |
| @@ -2431,9 +2431,9 @@ async function applyThreads(r) { |
| if (t.id !== activeId) { |
| if ((t.last_at || 0) <= readMarks[t.id] || ownMessage(payload, myUserId, session.name)) { |
| unread[t.id] = false; |
| - } else if (payload.type === "reaction") { |
| - // Someone reacting never lights the dot (matching the live path); |
| - // an earlier lit dot stays lit. |
| + } else if (payload.type === "reaction" || payload.type === "threadconvert") { |
| + // Someone reacting, or tidying a chain into a thread, never lights |
| + // the dot (matching the live path); an earlier lit dot stays lit. |
| } else if (!isAppType(payload.type) || appUnread(payload)) { |
| unread[t.id] = true; |
| } |
| @@ -2706,6 +2706,12 @@ function previewText(t, data) { |
| // "James reacted 👍 to “buy milk”" (#520 left this blank). It names the |
| // actor itself, so it returns before the group prefix below. |
| if (data.type === "reaction") return reactionLine(data.uid && data.uid === myUserId ? "You" : who, data); |
| + // A conversion has no text either. Saying what happened beats the blank row |
| + // it would otherwise leave — the server counts the event as the thread's |
| + // newest message, so this row is what a reload renders regardless. The count |
| + // is what the FOLD took, not what the payload asked for: a huge crafted list |
| + // is capped there, and the line must not claim more than moved. |
| + if (data.type === "threadconvert") return convertLine(data.uid && data.uid === myUserId ? "You" : who, Math.min((data.ids || []).length, MAX_CONVERT_IDS)); |
| // Through the markdown plain projection, never raw: raw text leaks the |
| // composer's opt-out escapes as visible backslashes (\*lit\*), and shows |
| // the marks the bubble renders as styling. |
| @@ -5392,6 +5398,24 @@ setChatAppHost({ |
| return false; |
| } |
| }, |
| + // "Convert to thread": an inline reply chain becomes a thread rooted at |
| + // `root`. Like a reaction, it's an ordinary encrypted message carrying a |
| + // typed payload — the server stores one more opaque blob and never learns |
| + // that a thread exists, let alone which messages are in it. Every client |
| + // folds it (chat.js's chat.convert); one too old to know the type drops it |
| + // and keeps showing the chain inline, which is exactly the old behaviour. |
| + convert: async (threadId, root, ids) => { |
| + const key = threadKeys[threadId]; |
| + if (!key || !root || !ids.length || !ws || ws.readyState !== WebSocket.OPEN) return false; |
| + const payload = { type: "threadconvert", root, ids, name: stampName(), uid: myUserId, ts: Date.now() }; |
| + try { |
| + const blob = await encrypt(key, payload); |
| + ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob })); |
| + return true; |
| + } catch { |
| + return false; |
| + } |
| + }, |
| del: (threadId, messageId, blobIds) => { |
| if (!ws || ws.readyState !== WebSocket.OPEN) return; |
| const frame = { type: "delete", thread_id: threadId, message_id: messageId }; |
| @@ -5491,7 +5515,7 @@ setChatAppHost({ |
| } |
| return null; |
| }, |
| - icons: { reply: ICON_REPLY, thread: ICON_THREAD, edit: ICON_EDIT, react: ICON_REACT, trash: ICON_TRASH, x: ICON_X, play: ICON_PLAY, copy: ICON_COPY, link: ICON_LINK, mute: ICON_MUTE, report: ICON_REPORT, chevronDown: ICON_CHEVRON_DOWN }, |
| + icons: { reply: ICON_REPLY, thread: ICON_THREAD, foldIn: ICON_FOLD_IN, edit: ICON_EDIT, react: ICON_REACT, trash: ICON_TRASH, x: ICON_X, play: ICON_PLAY, copy: ICON_COPY, link: ICON_LINK, mute: ICON_MUTE, report: ICON_REPORT, chevronDown: ICON_CHEVRON_DOWN }, |
| // Helper-proposed one-tap action chips ("Switch to dark" …): whitelisted, |
| // labelled, and run by the host — the app just mounts the returned row. |
| actionChips: (actions) => { |
| @@ -6956,6 +6980,14 @@ async function chatAppFoldMessage(threadId, m) { |
| foldHostEvent(threadId, "chat", { type: "chat.reaction", targetSid: data.targetSid, emoji: data.emoji, uid: data.uid, on: data.on, ts: data.ts }); |
| return; |
| } |
| + // "Convert to thread" (chat.js's chat.convert): an inline chain moved into |
| + // a thread. The event's OWN message id rides in the meta — it's how two |
| + // overlapping conversions settle deterministically (oldest wins), so it |
| + // must be passed, not derived from the payload. |
| + if (data.type === "threadconvert") { |
| + foldHostEvent(threadId, "chat", { type: "chat.convert", root: data.root, ids: data.ids, uid: data.uid, ts: data.ts }, { id: m.id }); |
| + return; |
| + } |
| if (isAppType(data.type)) { |
| foldEvent(threadId, data, { id: m.id, ts: data.ts }); |
| // The event also projects into the conversation — the same one-log-two- |
| @@ -7847,6 +7879,27 @@ function connectWS() { |
| renderThreadList(); |
| return; |
| } |
| + // A conversion ("Convert to thread"): fold it so the chain collapses into |
| + // its root's tally live, then stop. It's a reorganisation, not something |
| + // anyone said — so it never lights the unread dot and never notifies. (The |
| + // service worker drops a typed, textless payload for the same reason; the |
| + // iOS extension can't drop one without Apple's filtering entitlement, so |
| + // it ships a silent, passive list entry instead.) Like a reaction it DOES |
| + // retitle the chat-list row, because the server counts it as the thread's |
| + // latest message and a reload would say so anyway. |
| + if (payload && payload.type === "threadconvert") { |
| + foldHostEvent(tid, "chat", { type: "chat.convert", root: payload.root, ids: payload.ids, uid: payload.uid, ts: payload.ts }, { id: msg.id }); |
| + if (tid === activeId) renderActiveSurfaces(); |
| + const ct = threads.find((x) => x.id === tid); |
| + if (ct) { |
| + ct.last_at = msg.created_at || Math.floor(Date.now() / 1000); |
| + previewPayload[tid] = payload; |
| + threadPreview[tid] = previewText(ct, payload); |
| + } |
| + if (tid === activeId && document.hasFocus()) markThreadRead(tid); |
| + renderThreadList(); |
| + return; |
| + } |
| // A mini-app event, not a chat message: fold it (so the app's state stays |
| // current), then let the OWNING APP decide its presence — its notify hook |
| // may toast/alert (a live event, possibly a scheduled one the server just |