native: thread-only replies — Mac + iOS hide in-thread replies from the main feed (replyTo.thread) (#1475)
* core: thread-only replies — ReplyRef.thread + the main-feed filter (vector-pinned)
Replies made in a thread live only in the thread (Slack's model): the wire
contract is replyTo: { id, name, snippet, thread: true } — the flag rides
INSIDE replyTo, in the ciphertext; absent/false = an inline quote reply.
- ReplyRef gains `thread: Bool?` (optional, Codable, absent on the wire when
nil, so pre-flag history round-trips byte-identically) and `inThread()`,
the one place a flag is ever written (always true, never false).
- ChatFeedModel.mainFeedItems(_:) is the pure, testable feed filter, with
isThreadOnly(_:) as its predicate; `feedMessages` is a published mirror
of `messages` kept in step by didSet, so every fold path (history, live
frames, edits, deletes, optimistic sends) publishes both and the feed
containers subscribe to the filtered list. Hidden replies still tally on
their root, still fold into threadItems, still count as arrivals.
- threadItems sorts pending (optimistic) copies LAST — the explicit form of
the web's bySidPendingLast, whatever id the copy carries.
- test/event-vectors/chat.json: message_in_thread (expected_native_fields
.replyTo.thread + expected_main_feed_visible:false), message_with_reply
gains expected_main_feed_visible:true, reply_thread_grouping gains the
flagged reply 104 + expected_main_feed. Byte-identical to the web PR's
copy; the two PRs merge together.
- Swift tests read chat.json from the source tree (ChatVectors helper),
strictly — a missing vector or key fails, never skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
* mac: thread-only replies — pane sends flag the thread, the feed hides them
The docked pane's send and stageThreadReply stage threadReplyRef(...)
.inThread(); plain Reply / quote-card / ⌘R on the channel bar never set it.
The transcript renders model.feedMessages, and every pin/settle/anchor read
that keyed on the fold's newest row (lastRowFlush, pinUntilSettled,
captureAnchor, the arrival/typing/staged/text-size pins, the unfurl re-pin,
↑-edit-latest, ⌘R/⌘T's latest read) reads the feed's list instead — a
thread reply arriving neither scrolls nor pins the feed, and the settle
verify can't key on a row that has no bubble. Grouping and day separators
take `previous` off the visible list. jump(to:) on a hidden reply (search /
media-sheet jump) opens the pane on its root instead of scrolling to a
bubble that isn't there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
* ios: thread-only replies — the overlay's staged reply flags the thread, the feed hides them
stageThreadReply (the thread view's composer contract, and replyInThread's
path) stages threadReplyRef(...).inThread(); a plain Reply / swipe / quote
reply from the feed never carries the flag. Reply on a bubble inside the
open thread view routes to that staging (the Mac pane's routing) and the
view's menus drop "Reply in thread" (it would resolve to the open thread).
StockChatFeed and FlippedChatFeed subscribe to model.$feedMessages, so
items, the prev-neighbour grouping/separators and the newest-changed pin
decisions all run over the visible list. jumpAndWave on a hidden reply
(search hit, media sheet, quote/tally) opens the thread on its root; ↑
edits the latest own message on the surface in front of the reader.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
* core: root-aware thread hide + land ON the jumped-to reply inside the thread view
Review of #1475 (1): isThreadOnly hid unconditionally while the tally only
ever lands on a LOADED root (refreshReplyCounts), so a thread reply whose
root was older than the loaded history page rendered NOWHERE — cold-open a
busy chat and its newest message was invisible on every surface until the
reader blind-scrolled past the root. mainFeedItems is now root-aware: a
flagged reply is hidden only when its RESOLVED root (threadRoots — legacy
chains included, never just replyTo.id) is among the loaded messages;
otherwise it degrades to an inline bubble and collapses into the tally the
moment the root pages in. Web twin: inMainFeed(m, bySid) over rootRef.
Review (2), the shared half: ReplyThreadView takes a jumpTarget/jumpTick
and scrolls that reply to the centre once its rows lay out (re-aimed by the
tick on an open view), so a jump into a thread lands on the reply asked
for, not merely the newest — the web's .chatapp-thread-msgs landing.
Vectors: test/event-vectors/chat.json byte-identical to the web branch tip
(e075ecdb) — thread_reply_root_unloaded (only the flagged reply 105, no
message 100, expected_main_feed {105: true}); single-message
expected_main_feed_visible evaluated with the quoted root seeded from the
replyTo snapshot, plus the without-root case asserted visible.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
* mac: a jump into a thread lands on THAT reply — and only when the feed hides it
jump(to:) decides on feed membership, not the flag: a loaded message absent
from feedMessages is a thread reply with its root on screen, so open the
docked pane on the root and land on + wave the target inside it
(jumpIntoThread — threadJump/threadJumpTick into ReplyThreadView, the
threadDanceID/threadDanceTick wave on the pane's rows). With the root
unloaded the reply is an inline bubble and the ordinary scroll finds it.
A fresh open (openThread/closeThread) clears the target so a tally tap
never lands on a stale one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
* ios: notification tap for a thread-only reply opens its thread; jumps land on the reply
Review of #1475 (2): the tap routes at THREAD granularity (no message id),
the feed's unread anchor (performInitialAnchor) searches its own rows — now
feedMessages — and never sees the hidden reply, and the open chat marks
itself read on arrival: the reader tapped a banner and saw an unchanged
transcript. landOnUnreadThreadReply, on the same trigger as the anchor
(the tap's unread cutoff, one-shot per open): when the newest unread
non-mine message in the FULL fold is absent from the feed, jumpAndWave it
— which now opens the thread on the root and lands on + waves that reply
inside the view (jumpIntoThread → ReplyThreadView jumpTarget/jumpTick,
threadDanceID/threadDanceTick on the view's rows). Routing itself
(NotificationRoute / LChatApp, PR #1429's area) is untouched; the feed's
own anchor still runs underneath for inline unreads. jumpAndWave likewise
decides on feed membership, not the flag, so a reply whose root is
unloaded — an inline bubble now — scrolls as before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
* core: open a thread on a tap only when NO unread is visible; land the jump once
Second-pass review of #1475. The notification landing picked the NEWEST
unread while the feed's own anchor picks the OLDEST ("so reading continues
where it left off"), so a reader with several inline unreads whose newest
unread happened to be a thread reply tapped a push for an inline message,
got the feed anchored correctly — and then a thread overlay they never
asked for on top of it, their actual unreads behind it, composer armed on
that thread's root.
unreadThreadJump(in:feed:since:) is the pure, all-or-nothing gate: it
returns the newest unread only when EVERY unread non-mine message above the
cutoff is absent from the main feed; one visible unread and it returns nil,
leaving the feed's anchor alone and opening nothing. Own messages never
count as unread (they neither trigger nor block), and a thread reply whose
root isn't loaded renders inline, so it counts as visible — no thread opens
over it. Tested three ways in ReplyThreadVectorTests, including the exact
reported shape (inline unread + a NEWER thread unread → nil).
Also one-shot the thread view's jump landing (ReplyThreadView.landJump
records the id+tick it served): a re-appear of the replies scroll —
returning from the video player or a camera fullScreenCover — can no longer
re-aim at a target the reader has since scrolled away from. A fresh tick
(the container re-aiming) is a new request and lands again.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
* ios: the tap's thread landing goes through the pure unreadThreadJump gate
landOnUnreadThreadReply now asks ChatFeedModel.unreadThreadJump, so it fires
only when no unread is visible in the transcript; with any inline unread the
feed's own anchor (oldest-unread) owns the open and nothing is opened over
it. Same one-shot per open and the same push-tap-only cutoff as before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
10 files changed,
+754
−46
ios/LChat/Views/FlippedChatFeed.swift+3 −1ios/LChat/Views/NativeChatScreen.swift+105 −4ios/LChat/Views/StockChatFeed.swift+6 −1ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+90 −2ios/LChatCore/Sources/LChatCore/Models.swift+22 −2ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift+34 −0ios/LChatCore/Tests/LChatCoreTests/ChatVectors.swift+62 −0ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift+104 −0ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift+234 −1ios/LChatMac/NativeFeedView.swift+94 −35
diff --git a/ios/LChat/Views/FlippedChatFeed.swift b/ios/LChat/Views/FlippedChatFeed.swift| index 269e568..8f20526 100644 |
| --- a/ios/LChat/Views/FlippedChatFeed.swift |
| +++ b/ios/LChat/Views/FlippedChatFeed.swift |
| @@ -282,7 +282,9 @@ struct FlippedChatFeed: UIViewRepresentable { |
| // subscribe — which delivers a parked model's messages right here, |
| // before the first layout). Always use the emitted value: the |
| // property itself still holds the old one mid-publish. |
| - model.$messages |
| + // feedMessages, not messages: the main feed's list leaves out |
| + // thread-only replies (they live in the reply-thread view). |
| + model.$feedMessages |
| .removeDuplicates() |
| .sink { [weak self] in self?.setMessages($0) } |
| .store(in: &cancellables) |
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift| index 920f87a..7663bb7 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -118,6 +118,17 @@ struct NativeChatScreen: View { |
| // threadFallback (chat.js openThread's ref argument). |
| @State private var threadRootID: Int64? |
| @State private var threadFallback: ReplyRef? |
| + // A jump whose target lives only in its thread (search hit, media-sheet |
| + // jump, a notification's newest unread): the view's row to land on + its |
| + // wave — the feed's danceID/danceTick twins for the thread surface |
| + // (jumpIntoThread). |
| + @State private var threadJump: Int64? |
| + @State private var threadJumpTick = 0 |
| + @State private var threadDanceID: Int64? |
| + @State private var threadDanceTick = 0 |
| + // The notification-tap thread-reply check ran for this open (one-shot, |
| + // like the unread anchor it complements — landOnUnreadThreadReply). |
| + @State private var unreadThreadChecked = false |
| @State private var pickerItem: PhotosPickerItem? |
| @State private var showFilePicker = false |
| @State private var staged: [StagedUpload] = [] |
| @@ -756,7 +767,10 @@ struct NativeChatScreen: View { |
| } |
| // A search hit can arrive before or after history folds — cover both. |
| .onChange(of: model.ready) { _, ready in |
| - if ready { runPendingJump() } |
| + if ready { |
| + runPendingJump() |
| + landOnUnreadThreadReply() |
| + } |
| } |
| // Foregrounding with this chat on screen: merge whatever the NSE |
| // filed while we were suspended straight from disk — the pushed |
| @@ -773,9 +787,32 @@ struct NativeChatScreen: View { |
| mountSpan = Perf.begin("feed.mount", slowMs: 500) |
| } |
| runPendingJump() |
| + landOnUnreadThreadReply() |
| } |
| } |
| + // A notification tap for a reply that lives only in its thread: the tap |
| + // routes at THREAD granularity (no message id), the feed's unread anchor |
| + // (performInitialAnchor) searches its own rows and never sees the hidden |
| + // reply, and the open chat marks itself read on arrival — so the reader |
| + // tapped a banner and saw an unchanged transcript. When EVERY unread is |
| + // one of those, open the newest one's thread and land on it (jumpAndWave |
| + // → jumpIntoThread). The all-or-nothing gate lives in the pure |
| + // unreadThreadJump: with even one unread visible in the transcript the |
| + // feed's own anchor is already right (it lands on the OLDEST unread), and |
| + // opening a thread over it would cover the unreads the reader came for. |
| + // Same trigger as that anchor (the tap's cutoff — set only on a push |
| + // tap), same one-shot per open. Routing stays where it is |
| + // (NotificationRoute / LChatApp): this only reads the cutoff it left. |
| + private func landOnUnreadThreadReply() { |
| + guard !unreadThreadChecked, let cutoff = unreadCutoff, model.ready else { return } |
| + unreadThreadChecked = true |
| + guard let id = ChatFeedModel.unreadThreadJump(in: model.messages, |
| + feed: model.feedMessages, |
| + since: cutoff) else { return } |
| + jumpAndWave(id) |
| + } |
| + |
| // Everything a feed row needs from this screen, resolved at cell-configure |
| // time (rows never observe the model — see FeedHooks). |
| private var feedHooks: FeedHooks { |
| @@ -813,6 +850,7 @@ struct NativeChatScreen: View { |
| ReplyThreadView(model: model, rootID: rootID, fallback: threadFallback, |
| theme: .named(themeName), isGroup: isGroup, |
| swipeDownCloses: true, |
| + jumpTarget: threadJump, jumpTick: threadJumpTick, |
| onClose: closeThread, |
| onRootPaged: { |
| // The root just paged in — refresh the banner to its |
| @@ -847,7 +885,7 @@ struct NativeChatScreen: View { |
| onFileTap: { openFile($0) }, |
| onVideoTap: { att, frame in playVideo(att, from: frame) }, |
| downloading: inter.blobDownload, |
| - actions: messageActions(m), |
| + actions: threadMessageActions(m), |
| unfurl: { await model.unfurl($0) }) |
| .environment(\.chatFontSize, chatFontSize) |
| .environment(\.mentionIndex, model.mentionIndex) |
| @@ -856,6 +894,26 @@ struct NativeChatScreen: View { |
| headerProfile = HeaderProfileTarget(id: uid) |
| return .handled |
| }) |
| + // The jump wave, on the thread surface (jumpIntoThread's target). |
| + .jumpWave(on: m.id == threadDanceID, tick: threadDanceTick) |
| + } |
| + } |
| + |
| + // A jump whose target lives only in its thread — loaded, but absent from |
| + // the transcript (mainFeedItems: a thread reply with its root on screen): |
| + // open the thread view on the root (a no-op when already open there), |
| + // then land on THAT reply inside it and wave it — the feed jump's two |
| + // beats, on the view. The web lands inside .chatapp-thread-msgs the same |
| + // way, so a search hit or notification never dead-ends on "the newest |
| + // reply" instead of the one asked for. |
| + private func jumpIntoThread(_ target: ChatFeedModel.Message) { |
| + guard let root = target.threadRootID ?? target.reply?.id, root > 0 else { return } |
| + openThread(root) |
| + threadJump = target.id |
| + threadJumpTick += 1 |
| + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { |
| + threadDanceID = target.id |
| + threadDanceTick += 1 |
| } |
| } |
| @@ -886,11 +944,31 @@ struct NativeChatScreen: View { |
| return actions |
| } |
| + // Reply/Edit on a bubble INSIDE the open thread view (the Mac pane's |
| + // routing, paneMessageActions): Reply re-arms the thread's root reply on |
| + // the composer — flagged thread-only, like every send from the view — and |
| + // pops the keyboard (a reply intent, unlike a tally tap); "Reply in |
| + // thread" would resolve to the thread already open and do exactly that, |
| + // so the view's menus omit it. Edit stays the plain startEdit — cancelEdit |
| + // hands the composer back to the thread (stageThreadReply). |
| + private func threadMessageActions(_ m: ChatFeedModel.Message) -> MessageActions { |
| + var actions = messageActions(m) |
| + actions.onReply = { |
| + stageThreadReply() |
| + composerFocused = true |
| + } |
| + actions.onReplyInThread = nil |
| + return actions |
| + } |
| + |
| private func openThread(_ rootID: Int64) { |
| guard rootID > 0, threadRootID != rootID else { return } |
| // Freeze the header stand-in NOW (web threadFallback) — the deepest |
| // quoted snapshot in loaded state, in case the root never pages in. |
| threadFallback = model.threadReplyRef(root: rootID) |
| + // A fresh open lands at the newest reply — never on a stale jump |
| + // target from an earlier one (jumpIntoThread re-aims right after). |
| + threadJump = nil |
| withAnimation(.easeOut(duration: 0.18)) { threadRootID = rootID } |
| stageThreadReply() |
| } |
| @@ -915,16 +993,22 @@ struct NativeChatScreen: View { |
| // contract ("Replying to <root author>"). A reply displaces an in-flight |
| // edit, but the keyboard is deliberately NOT popped: it would cover the |
| // thread the reader just opened (the web skips focus on touch too). |
| + // Flagged thread-only (ReplyRef.thread): a send from the open thread |
| + // view lives in the thread, Slack-style — the main feed shows only the |
| + // root's "N replies" tally. The compact analogue of the Mac/web pane |
| + // composer's send; a plain Reply/swipe/quote reply from the feed itself |
| + // never carries the flag. |
| private func stageThreadReply() { |
| guard let rootID = threadRootID else { return } |
| editingID = nil |
| - replyTo = model.threadReplyRef(root: rootID) |
| + replyTo = model.threadReplyRef(root: rootID).inThread() |
| } |
| private func closeThread() { |
| guard let rootID = threadRootID else { return } |
| withAnimation(.easeOut(duration: 0.15)) { threadRootID = nil } |
| threadFallback = nil |
| + threadJump = nil |
| // Leaving the thread drops its staged reply — never an unrelated one |
| // staged since (an edit clears replyTo itself, so that survives). |
| if replyTo?.id == rootID { replyTo = nil } |
| @@ -933,6 +1017,15 @@ struct NativeChatScreen: View { |
| // Scroll to a message and wave it — quote-taps and search hits share this. |
| // The near/far animation split lives in the container's scrollTo. |
| private func jumpAndWave(_ id: Int64) { |
| + // Loaded but not in the transcript = a thread-only reply whose root |
| + // is loaded (mainFeedItems): its bubble is in the thread view, so |
| + // land there. (With the root unloaded it degrades to an inline bubble |
| + // and the ordinary scroll below finds it.) |
| + if let target = model.messages.first(where: { $0.id == id }), |
| + !model.feedMessages.contains(where: { $0.id == id }) { |
| + jumpIntoThread(target) |
| + return |
| + } |
| feedController.scrollTo(id: id) |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { |
| interaction.danceID = id |
| @@ -1133,7 +1226,15 @@ struct NativeChatScreen: View { |
| onSend: sendDraft, |
| onPasteImage: { if defaultCommand == nil { stageImageData($0) } }, |
| onEditLast: { |
| - if let last = model.messages.last(where: { $0.mine && !$0.action }) { |
| + // ↑ edits the latest own message on the |
| + // surface in front of the reader: the open |
| + // thread's (root included) while the view |
| + // is up, else the FEED's — a thread-only |
| + // reply of mine isn't in the transcript. |
| + let pool = threadRootID.map { |
| + ChatFeedModel.threadItems(in: model.messages, root: $0) |
| + } ?? model.feedMessages |
| + if let last = pool.last(where: { $0.mine && !$0.action }) { |
| startEdit(last) |
| } |
| }) |
diff --git a/ios/LChat/Views/StockChatFeed.swift b/ios/LChat/Views/StockChatFeed.swift| index 84eed5b..2c2ca64 100644 |
| --- a/ios/LChat/Views/StockChatFeed.swift |
| +++ b/ios/LChat/Views/StockChatFeed.swift |
| @@ -485,7 +485,12 @@ struct StockChatFeed: UIViewControllerRepresentable { |
| } |
| func subscribe() { |
| - model.$messages |
| + // The MAIN FEED's list (feedMessages): the fold minus thread-only |
| + // replies, which live in the reply-thread view. Everything |
| + // downstream — items, `current`, the prev-neighbour grouping and |
| + // separators, newestChanged/pin decisions — runs over this list, |
| + // so a thread reply arriving never scrolls or regroups the feed. |
| + model.$feedMessages |
| .removeDuplicates() |
| .sink { [weak self] in self?.setMessages($0) } |
| .store(in: &cancellables) |
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift| index 2e1f8f2..4900549 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| @@ -103,7 +103,22 @@ public final class ChatFeedModel: ObservableObject { |
| return !a.name.isEmpty && a.name == b.name |
| } |
| - @Published public private(set) var messages: [Message] = [] |
| + /// EVERY loaded message, in server order — the fold's single source of |
| + /// truth: reply tallies, the thread views (threadItems), jump targets and |
| + /// unread/notification state all read this. The main feed does NOT: it |
| + /// renders `feedMessages` below. |
| + @Published public private(set) var messages: [Message] = [] { |
| + didSet { feedMessages = Self.mainFeedItems(messages) } |
| + } |
| + /// The MAIN FEED's list: `messages` minus the replies made in a thread |
| + /// (replyTo.thread — see ReplyRef), the Slack model: those live only in |
| + /// the thread view, and the feed shows their root's "N replies" tally |
| + /// instead. Kept in step with `messages` by its didSet so every fold path |
| + /// (history, live frames, edits, deletes, optimistic sends) publishes both |
| + /// without knowing; the feed containers subscribe to THIS one, so a |
| + /// 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] = [] |
| @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 |
| @@ -1778,7 +1793,15 @@ public final class ChatFeedModel: ObservableObject { |
| var out: [Message] = [] |
| if let root = list.first(where: { $0.id == rootID }) { out.append(root) } |
| out.append(contentsOf: list.filter { roots[$0.id] == rootID }) |
| - return out.sorted { $0.id < $1.id } |
| + // Pending (optimistic) copies sort LAST, never ahead of the root or a |
| + // confirmed reply — the web's bySidPendingLast rule, and the cause of |
| + // its thread-view "blink" when a just-sent reply sorted above the |
| + // root. Native pending ids already sit above every real id (1<<62, |
| + // foldOptimisticSend), so this is the explicit form of that order. |
| + return out.sorted { a, b in |
| + if a.pending != b.pending { return !a.pending } |
| + return a.id < b.id |
| + } |
| } |
| /// The ReplyRef the open thread view stages on the composer (always the |
| @@ -1804,6 +1827,71 @@ public final class ChatFeedModel: ObservableObject { |
| Self.threadReplyRef(in: messages, root: rootID) |
| } |
| + /// A reply made IN a thread (its payload's replyTo carries thread:true — |
| + /// ReplyRef): it belongs to the thread view, not the main feed (see |
| + /// mainFeedItems for the one exception). Everything else about it is |
| + /// unchanged — it still tallies on its root (refreshReplyCounts), folds |
| + /// into threadItems, and counts as an arrival for unread/notification |
| + /// purposes. |
| + nonisolated public static func isThreadOnly(_ m: Message) -> Bool { |
| + m.reply?.thread == true |
| + } |
| + |
| + /// The main feed's item list from the full fold: every message except |
| + /// the thread-only replies whose resolved ROOT is loaded (Slack's model — |
| + /// the feed shows a threaded root with its "N replies" tally; the replies |
| + /// themselves live in the thread view). ROOT-AWARE, deliberately: the |
| + /// tally only exists on a loaded root (refreshReplyCounts), so a thread |
| + /// reply whose root is older than the loaded history page would |
| + /// otherwise render NOWHERE — invisible on every surface until the reader |
| + /// blind-scrolled past the root. Such a reply degrades to an inline |
| + /// bubble until its root pages in, then collapses into the tally. Inline |
| + /// quote replies (no flag) stay in the feed as before. Order is |
| + /// preserved, so bubble grouping (same-sender runs) and day separators |
| + /// computed over THIS list treat two feed messages as neighbours even |
| + /// when a hidden reply sits between them in the fold. Mirrors the web's |
| + /// inMainFeed/viewItems; pinned by the message_in_thread, |
| + /// reply_thread_grouping + thread_reply_root_unloaded vectors |
| + /// (test/event-vectors/chat.json). |
| + nonisolated public static func mainFeedItems(_ list: [Message]) -> [Message] { |
| + let roots = threadRoots(list) |
| + var loaded = Set<Int64>() |
| + loaded.reserveCapacity(list.count) |
| + 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 } |
| + // Hidden only while its root is on screen to carry the tally. |
| + return !loaded.contains(root) |
| + } |
| + } |
| + |
| + /// A notification tap landed on this chat and every unread message is a |
| + /// reply living in its thread: the id to open that thread on and land |
| + /// upon (the newest of them), else nil. |
| + /// |
| + /// A push for a thread-only reply routes at THREAD granularity — no |
| + /// message id — and the feed's own unread anchor searches the VISIBLE |
| + /// rows, so it can't see the reply the reader was sent to: they tap the |
| + /// banner and find an unchanged transcript. This is the containers' |
| + /// answer, and it is deliberately all-or-nothing: it fires only when NO |
| + /// unread message is in the feed. With even one visible unread, the |
| + /// feed's anchor is doing the right thing (it lands on the OLDEST unread |
| + /// — reading continues where it left off), and auto-opening a thread |
| + /// would cover those unreads with a view the reader never asked for, |
| + /// composer armed on someone else's root. |
| + /// |
| + /// `cutoff` is the pre-tap read watermark; `feed` is mainFeedItems of the |
| + /// same fold. Own messages never count as unread. |
| + nonisolated public static func unreadThreadJump(in list: [Message], feed: [Message], |
| + since cutoff: Int64) -> Int64? { |
| + let unread = list.filter { $0.at > cutoff && !$0.mine } |
| + guard !unread.isEmpty else { return nil } |
| + let visible = Set(feed.map(\.id)) |
| + guard !unread.contains(where: { visible.contains($0.id) }) else { return nil } |
| + return unread.last?.id |
| + } |
| + |
| // Re-derive each message's reply-thread tally (chat.js's replyCounts fold): |
| // every reply counts toward its resolved ROOT, so a legacy chain tallies on |
| // the thread's top message. Runs once per batch, before the list publishes. |
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift| index ce2ab8f..b9f55ac 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Models.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Models.swift |
| @@ -211,11 +211,31 @@ public struct ReplyRef: Codable, Hashable, Sendable { |
| public var id: Int64? |
| public var name: String? |
| public var snippet: String? |
| - |
| - public init(id: Int64? = nil, name: String? = nil, snippet: String? = nil) { |
| + // A reply made IN a thread (the docked pane / the thread view's composer, |
| + // "Reply in thread"): it lives only in the thread, Slack-style — the main |
| + // feed shows the root with its "N replies" tally, never the reply itself. |
| + // Absent/false = an ordinary inline quote reply (the pre-thread wire |
| + // shape). The flag rides INSIDE replyTo, in the ciphertext; optional so |
| + // history without it round-trips byte-identically (nil encodes as absent, |
| + // like every other optional payload field). Vector: message_in_thread in |
| + // test/event-vectors/chat.json. |
| + public var thread: Bool? |
| + |
| + public init(id: Int64? = nil, name: String? = nil, snippet: String? = nil, |
| + thread: Bool? = nil) { |
| self.id = id |
| self.name = name |
| self.snippet = snippet |
| + self.thread = thread |
| + } |
| + |
| + /// This ref flagged as a thread-only reply — what every thread-originated |
| + /// send stages. Only ever writes `true`: an unflagged ref stays nil, so the |
| + /// key never appears on the wire for plain replies. |
| + public func inThread() -> ReplyRef { |
| + var r = self |
| + r.thread = true |
| + return r |
| } |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift b/ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift| index d42fed5..5987d98 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift |
| @@ -38,6 +38,14 @@ public struct ReplyThreadView<Row: View>: View { |
| /// The docked presentation (see the header note): panel content only, |
| /// flush in the container's column — no dim, no card, no drag. |
| private let docked: Bool |
| + /// A reply to land ON (a jump to a reply that lives only in its thread — |
| + /// a search hit, a quote tap, a notification's newest unread): once the |
| + /// rows have laid out the list scrolls it to the centre; the container |
| + /// waves it through its row closure. nil = open at the newest reply, as |
| + /// always. Bumping `jumpTick` re-lands a target on an already-open view |
| + /// (the web lands inside .chatapp-thread-msgs the same way). |
| + private let jumpTarget: Int64? |
| + private let jumpTick: Int |
| private let onClose: () -> Void |
| /// Paging just surfaced the root — the container refreshes its staged |
| /// reply banner to the live snapshot (web pageInRoot's tail). |
| @@ -59,10 +67,17 @@ public struct ReplyThreadView<Row: View>: View { |
| @State private var rootHeight: CGFloat = 0 |
| // Header-drag offset for the swipe-down close. |
| @State private var dragOffset: CGFloat = 0 |
| + // The jump this view has already landed (id + the tick that asked for |
| + // it): landing is ONE-SHOT, so a re-appear of the replies scroll — |
| + // returning from the video player or a camera fullScreenCover — can't |
| + // re-aim at a target the reader has since scrolled away from. A fresh |
| + // tick (the container re-aiming) is a new request and lands again. |
| + @State private var landedJump: (id: Int64, tick: Int)? |
| public init(model: ChatFeedModel, rootID: Int64, fallback: ReplyRef?, |
| theme: ChatTheme, isGroup: Bool, swipeDownCloses: Bool = false, |
| docked: Bool = false, |
| + jumpTarget: Int64? = nil, jumpTick: Int = 0, |
| onClose: @escaping () -> Void, onRootPaged: (() -> Void)? = nil, |
| @ViewBuilder row: @escaping (ChatFeedModel.Message, _ showSender: Bool) -> Row) { |
| self.model = model |
| @@ -72,6 +87,8 @@ public struct ReplyThreadView<Row: View>: View { |
| self.isGroup = isGroup |
| self.swipeDownCloses = swipeDownCloses |
| self.docked = docked |
| + self.jumpTarget = jumpTarget |
| + self.jumpTick = jumpTick |
| self.onClose = onClose |
| self.onRootPaged = onRootPaged |
| self.row = row |
| @@ -281,6 +298,23 @@ public struct ReplyThreadView<Row: View>: View { |
| } |
| } |
| } |
| + // The jump target: on the opening mount, and again whenever the |
| + // container re-aims an open view (the tick). |
| + .onAppear { landJump(proxy) } |
| + .onChange(of: jumpTick) { _, _ in landJump(proxy) } |
| + } |
| + } |
| + |
| + // Scroll the jump target into view, ONCE per request (see landedJump). |
| + // Rows are lazy and the view may still be sliding in, so give layout a |
| + // beat first; a target not among the replies (paged out, deleted) is |
| + // left alone — the bottom anchor stands. |
| + private func landJump(_ proxy: ScrollViewProxy) { |
| + guard let id = jumpTarget else { return } |
| + if let landed = landedJump, landed.id == id, landed.tick == jumpTick { return } |
| + landedJump = (id, jumpTick) |
| + DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { |
| + withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(id, anchor: .center) } |
| } |
| } |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ChatVectors.swift b/ios/LChatCore/Tests/LChatCoreTests/ChatVectors.swift| new file mode 100644 |
| index 0000000..aa7e56e |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/ChatVectors.swift |
| @@ -0,0 +1,62 @@ |
| +import Foundation |
| +import XCTest |
| +@testable import LChatCore |
| + |
| +// 🤖 The shared chat event vectors (test/event-vectors/chat.json), read from |
| +// the SOURCE TREE — the same file the web suite (test/event-vectors.test.mjs) |
| +// runs, so there is no copied fixture to drift (the AppDefaultCommandTests |
| +// pattern). Strict by design: a vector or key the contract names must exist, |
| +// so a test asking for it FAILS rather than skipping when the JSON lags. |
| +enum ChatVectors { |
| + static var url: URL { |
| + URL(fileURLWithPath: #filePath) |
| + .deletingLastPathComponent() // Tests/LChatCoreTests |
| + .deletingLastPathComponent() // Tests |
| + .deletingLastPathComponent() // LChatCore |
| + .deletingLastPathComponent() // ios |
| + .deletingLastPathComponent() // repo |
| + .appending(path: "test/event-vectors/chat.json") |
| + } |
| + |
| + /// Every entry under "vectors". |
| + static func all() throws -> [[String: Any]] { |
| + let data = try Data(contentsOf: url) |
| + let root = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any], |
| + "chat.json is not a JSON object") |
| + return try XCTUnwrap(root["vectors"] as? [[String: Any]], "chat.json has no vectors array") |
| + } |
| + |
| + /// One named vector — a hard failure when it's missing. |
| + static func vector(_ name: String) throws -> [String: Any] { |
| + try XCTUnwrap(try all().first { $0["name"] as? String == name }, |
| + "chat.json has no vector named \(name)") |
| + } |
| + |
| + /// A vector's decrypted_payload decoded the way the app decodes a blob |
| + /// (JSONDecoder → MessagePayload). |
| + static func payload(_ v: [String: Any]) throws -> MessagePayload { |
| + let obj = try XCTUnwrap(v["decrypted_payload"], "vector has no decrypted_payload") |
| + let data = try JSONSerialization.data(withJSONObject: obj) |
| + return try JSONDecoder().decode(MessagePayload.self, from: data) |
| + } |
| + |
| + /// The server-side frame metadata — id + created_at (seconds). |
| + static func serverMessage(_ v: [String: Any]) throws -> (id: Int64, at: Int64) { |
| + let meta = try XCTUnwrap(v["server_message"] as? [String: Any], "vector has no server_message") |
| + let id = try XCTUnwrap((meta["id"] as? NSNumber)?.int64Value, "server_message.id") |
| + let at = try XCTUnwrap((meta["created_at"] as? NSNumber)?.int64Value, "server_message.created_at") |
| + return (id, at) |
| + } |
| + |
| + /// 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. |
| + static func message(_ v: [String: Any]) throws -> ChatFeedModel.Message { |
| + let p = try payload(v) |
| + let meta = try serverMessage(v) |
| + return ChatFeedModel.Message(id: meta.id, name: p.name ?? "", text: p.text ?? "", |
| + mine: false, at: meta.at, action: p.action ?? false, |
| + edited: false, attachments: p.attachments ?? [], |
| + reply: p.replyTo, raw: p) |
| + } |
| +} |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift| index ede0cc0..820c59e 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift |
| @@ -460,4 +460,108 @@ final class EventVectorTests: XCTestCase { |
| XCTAssertNil(payload.ts, "missing ts should be nil") |
| XCTAssertNil(payload.attachments, "missing attachments should be nil") |
| } |
| + |
| + // ---- thread-only replies (replyTo.thread) — the message_in_thread vector ---- |
| + // A reply made IN a thread (the docked pane / thread view composer, "Reply |
| + // in thread") carries replyTo.thread:true inside the ciphertext. Native |
| + // decodes it as ReplyRef.thread, and the main feed's item list |
| + // (ChatFeedModel.mainFeedItems) leaves it out — Slack's model: the feed |
| + // shows the root's "N replies" tally, the reply lives in the thread view. |
| + // Read straight from test/event-vectors/chat.json, strictly: the keys ARE |
| + // the contract, so a missing vector or key fails here, never skips. |
| + |
| + func testThreadReplyVectorDecodesFlagAndHidesFromFeed() throws { |
| + let v = try ChatVectors.vector("message_in_thread") |
| + let payload = try ChatVectors.payload(v) |
| + |
| + // expected_native_fields.replyTo — every field, thread included. |
| + let native = try XCTUnwrap(v["expected_native_fields"] as? [String: Any], |
| + "message_in_thread must carry expected_native_fields") |
| + XCTAssertEqual(payload.name, native["name"] as? String) |
| + XCTAssertEqual(payload.text, native["text"] as? String) |
| + XCTAssertEqual(payload.uid, native["uid"] as? String) |
| + XCTAssertEqual(payload.isRenderableChat, native["isRenderableChat"] as? Bool) |
| + let ref = try XCTUnwrap(native["replyTo"] as? [String: Any], |
| + "expected_native_fields must carry replyTo") |
| + XCTAssertEqual(payload.replyTo?.id, (ref["id"] as? NSNumber)?.int64Value) |
| + XCTAssertEqual(payload.replyTo?.name, ref["name"] as? String) |
| + XCTAssertEqual(payload.replyTo?.snippet, ref["snippet"] as? String) |
| + let flag = try XCTUnwrap(ref["thread"] as? Bool, |
| + "expected_native_fields.replyTo.thread is the contract") |
| + XCTAssertTrue(flag, "the vector is the flagged case") |
| + XCTAssertEqual(payload.replyTo?.thread, flag, "ReplyRef.thread decodes the flag") |
| + |
| + // expected_main_feed_visible, through the pure filter, with the ROOT |
| + // loaded (the tally's home — a root the transcript shows): the folded |
| + // reply is absent from the main feed's list. Nothing about the fold |
| + // changes — it is still a renderable chat message. |
| + let visible = try XCTUnwrap(v["expected_main_feed_visible"] as? Bool, |
| + "message_in_thread must carry expected_main_feed_visible") |
| + let msg = try ChatVectors.message(v) |
| + let rootID = try XCTUnwrap(payload.replyTo?.id) |
| + let root = ChatFeedModel.Message(id: rootID, name: payload.replyTo?.name ?? "", |
| + text: payload.replyTo?.snippet ?? "", mine: false, |
| + at: msg.at - 1, edited: false) |
| + XCTAssertTrue(payload.isRenderableChat) |
| + XCTAssertTrue(ChatFeedModel.isThreadOnly(msg)) |
| + XCTAssertEqual(ChatFeedModel.mainFeedItems([root, msg]).contains { $0.id == msg.id }, visible, |
| + "mainFeedItems must agree with expected_main_feed_visible") |
| + XCTAssertFalse(visible, "the vector pins the hidden case") |
| + // Root NOT loaded (older than the loaded page): the reply degrades to an |
| + // inline bubble rather than rendering nowhere — thread_reply_root_unloaded |
| + // pins the sequence form of this in ReplyThreadVectorTests. |
| + XCTAssertEqual(ChatFeedModel.mainFeedItems([msg]).map(\.id), [msg.id], |
| + "a thread reply with no loaded root stays in the feed") |
| + } |
| + |
| + 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 |
| + // main-feed bubble. |
| + let v = try ChatVectors.vector("message_with_reply") |
| + let visible = try XCTUnwrap(v["expected_main_feed_visible"] as? Bool, |
| + "message_with_reply must carry expected_main_feed_visible") |
| + let payload = try ChatVectors.payload(v) |
| + XCTAssertNotNil(payload.replyTo) |
| + XCTAssertNil(payload.replyTo?.thread, "no flag on the wire decodes nil") |
| + let msg = try ChatVectors.message(v) |
| + XCTAssertFalse(ChatFeedModel.isThreadOnly(msg)) |
| + // Evaluated the way every single-message vector is: with the quoted |
| + // root seeded into the loaded window — and, unflagged, just as visible |
| + // without it. |
| + let rootID = try XCTUnwrap(payload.replyTo?.id) |
| + let root = ChatFeedModel.Message(id: rootID, name: payload.replyTo?.name ?? "", |
| + text: payload.replyTo?.snippet ?? "", mine: false, |
| + at: msg.at - 1, edited: false) |
| + XCTAssertEqual(ChatFeedModel.mainFeedItems([root, msg]).contains { $0.id == msg.id }, visible) |
| + XCTAssertEqual(ChatFeedModel.mainFeedItems([msg]).contains { $0.id == msg.id }, visible) |
| + XCTAssertTrue(visible, "the vector pins the visible case") |
| + } |
| + |
| + func testThreadFlagRoundTripsByteIdentically() throws { |
| + // History without the key re-encodes without it (an edit re-encrypts the |
| + // raw payload — the shape old clients wrote must come back unchanged); |
| + // a flagged ref keeps its true through a decode/encode/decode cycle. |
| + let plain = try JSONDecoder().decode( |
| + ReplyRef.self, from: #"{"id":1,"name":"A","snippet":"s"}"#.data(using: .utf8)!) |
| + XCTAssertNil(plain.thread) |
| + let plainOut = String(decoding: try JSONEncoder().encode(plain), as: UTF8.self) |
| + XCTAssertFalse(plainOut.contains("thread"), "nil never appears on the wire: \(plainOut)") |
| + |
| + let flagged = plain.inThread() |
| + XCTAssertEqual(flagged.thread, true) |
| + XCTAssertEqual(flagged.id, plain.id) |
| + XCTAssertEqual(flagged.snippet, plain.snippet) |
| + let flaggedOut = String(decoding: try JSONEncoder().encode(flagged), as: UTF8.self) |
| + XCTAssertTrue(flaggedOut.contains(#""thread":true"#), "the flag rides inside replyTo: \(flaggedOut)") |
| + XCTAssertEqual(try JSONDecoder().decode(ReplyRef.self, from: Data(flaggedOut.utf8)).thread, true) |
| + |
| + // The whole payload survives an edit's re-encode (chat.js's {…raw, text}). |
| + let v = try ChatVectors.vector("message_in_thread") |
| + var payload = try ChatVectors.payload(v) |
| + payload.text = "In the thread (edited)" |
| + let again = try JSONDecoder().decode(MessagePayload.self, from: try JSONEncoder().encode(payload)) |
| + XCTAssertEqual(again.replyTo?.thread, true, "an edit keeps the reply in its thread") |
| + XCTAssertEqual(again.replyTo?.id, payload.replyTo?.id) |
| + } |
| } |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift| index 650a80e..9182c6d 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/ReplyThreadVectorTests.swift |
| @@ -31,6 +31,11 @@ final class ReplyThreadVectorTests: XCTestCase { |
| Message(id: 103, name: "Dana", text: "Good to be here", mine: false, |
| at: 1_700_000_003, edited: false, |
| reply: ReplyRef(id: 100, name: "Alice", snippet: "Hello everyone")), |
| + // Sent IN the thread (replyTo.thread): tallies like any reply, but the |
| + // main feed leaves it out — see testMainFeedVisibilityFromVector. |
| + Message(id: 104, name: "Erin", text: "Replying from inside the thread", mine: false, |
| + at: 1_700_000_004, edited: false, |
| + reply: ReplyRef(id: 100, name: "Alice", snippet: "Hello everyone", thread: true)), |
| ] |
| } |
| @@ -42,14 +47,219 @@ final class ReplyThreadVectorTests: XCTestCase { |
| XCTAssertEqual(list.first { $0.id == 101 }?.threadRootID, 100) |
| XCTAssertEqual(list.first { $0.id == 102 }?.threadRootID, 100, "legacy chain resolves transitively") |
| XCTAssertEqual(list.first { $0.id == 103 }?.threadRootID, 100) |
| + XCTAssertEqual(list.first { $0.id == 104 }?.threadRootID, 100, "a thread-only reply groups like any other") |
| XCTAssertNil(list.first { $0.id == 100 }?.threadRootID, "the root is not itself a reply") |
| // expected_threads.reply_counts + first_reply. |
| - XCTAssertEqual(list.first { $0.id == 100 }?.replyCount, 3, "the root tallies its whole thread") |
| + XCTAssertEqual(list.first { $0.id == 100 }?.replyCount, 4, "the root tallies its whole thread, thread-only replies included") |
| XCTAssertEqual(list.first { $0.id == 100 }?.firstReplyID, 101) |
| XCTAssertEqual(list.first { $0.id == 101 }?.replyCount, 0, "a mid-thread reply never tallies") |
| } |
| + // ---- thread-only replies vs the main feed (expected_main_feed) ------------ |
| + // Driven straight from test/event-vectors/chat.json so the Swift side runs |
| + // the SAME sequence the web suite runs. Strict: expected_main_feed must be |
| + // present and name every message in the sequence — the keys are the |
| + // contract, a lagging JSON fails here rather than skipping. |
| + |
| + func testMainFeedVisibilityFromVector() throws { |
| + let v = try ChatVectors.vector("reply_thread_grouping") |
| + let sequence = try XCTUnwrap(v["message_sequence"] as? [[String: Any]]) |
| + var list = try sequence.map(ChatVectors.message) |
| + ChatFeedModel.refreshReplyCounts(&list) |
| + |
| + // expected_threads, from the JSON this time (the hand-built twin above |
| + // pins the same numbers): roots, tallies, first reply. |
| + let threads = try XCTUnwrap(v["expected_threads"] as? [String: Any]) |
| + let roots = try XCTUnwrap(threads["roots"] as? [String: NSNumber]) |
| + for (sid, root) in roots { |
| + XCTAssertEqual(list.first { $0.id == Int64(sid) }?.threadRootID, root.int64Value, "root of \(sid)") |
| + } |
| + let counts = try XCTUnwrap(threads["reply_counts"] as? [String: NSNumber]) |
| + for (sid, n) in counts { |
| + XCTAssertEqual(list.first { $0.id == Int64(sid) }?.replyCount, n.intValue, "reply count of \(sid)") |
| + } |
| + let first = try XCTUnwrap(threads["first_reply"] as? [String: NSNumber]) |
| + for (sid, id) in first { |
| + XCTAssertEqual(list.first { $0.id == Int64(sid) }?.firstReplyID, id.int64Value, "first reply of \(sid)") |
| + } |
| + |
| + // expected_main_feed: every message's main-feed visibility, through the |
| + // pure filter — thread-only replies hidden, everything else (roots, |
| + // inline replies, legacy chains) shown, in order. |
| + let feed = try XCTUnwrap(v["expected_main_feed"] as? [String: Bool], |
| + "reply_thread_grouping must carry expected_main_feed") |
| + let visible = ChatFeedModel.mainFeedItems(list) |
| + for m in list { |
| + let want = try XCTUnwrap(feed[String(m.id)], "expected_main_feed must name message \(m.id)") |
| + XCTAssertEqual(visible.contains { $0.id == m.id }, want, "main-feed visibility of \(m.id)") |
| + } |
| + XCTAssertEqual(visible.map(\.id), list.filter { feed[String($0.id)] == true }.map(\.id), |
| + "the feed keeps server order") |
| + |
| + // The thread view still shows the whole thread, hidden replies included. |
| + let hidden = list.filter { feed[String($0.id)] == false }.map(\.id) |
| + XCTAssertFalse(hidden.isEmpty, "the vector must carry at least one thread-only reply") |
| + let thread = ChatFeedModel.threadItems(in: list, root: 100).map(\.id) |
| + for id in hidden { |
| + XCTAssertTrue(thread.contains(id), "thread-only reply \(id) folds into its thread") |
| + } |
| + } |
| + |
| + func testThreadReplyWithUnloadedRootStaysInFeed() throws { |
| + // thread_reply_root_unloaded: the hide is ROOT-AWARE. A thread reply |
| + // whose root is older than the loaded page can't tally anywhere (the |
| + // tally lives on a loaded root), so it degrades to an inline bubble |
| + // rather than rendering nowhere — and collapses into the tally the |
| + // moment its root pages in. |
| + let v = try ChatVectors.vector("thread_reply_root_unloaded") |
| + let sequence = try XCTUnwrap(v["message_sequence"] as? [[String: Any]]) |
| + var list = try sequence.map(ChatVectors.message) |
| + ChatFeedModel.refreshReplyCounts(&list) |
| + let feed = try XCTUnwrap(v["expected_main_feed"] as? [String: Bool], |
| + "thread_reply_root_unloaded must carry expected_main_feed") |
| + let reply = try XCTUnwrap(list.first, "the vector holds the flagged reply") |
| + let rootID = try XCTUnwrap(reply.reply?.id) |
| + XCTAssertTrue(ChatFeedModel.isThreadOnly(reply), "the vector's premise: a flagged reply…") |
| + XCTAssertFalse(list.contains { $0.id == rootID }, "…whose root is NOT loaded") |
| + |
| + let visible = ChatFeedModel.mainFeedItems(list) |
| + for m in list { |
| + let want = try XCTUnwrap(feed[String(m.id)], "expected_main_feed must name message \(m.id)") |
| + XCTAssertEqual(visible.contains { $0.id == m.id }, want, "main-feed visibility of \(m.id)") |
| + XCTAssertTrue(want, "the vector pins the degrade-to-inline case") |
| + } |
| + |
| + // The root paging in flips it: hidden from the feed, tallied on the root. |
| + var withRoot = list |
| + withRoot.insert(Message(id: rootID, name: "Alice", text: "Hello everyone", mine: false, |
| + at: reply.at - 5, edited: false), at: 0) |
| + ChatFeedModel.refreshReplyCounts(&withRoot) |
| + XCTAssertEqual(ChatFeedModel.mainFeedItems(withRoot).map(\.id), [rootID], |
| + "with the root loaded the reply collapses into the tally") |
| + XCTAssertEqual(withRoot.first { $0.id == rootID }?.replyCount, 1) |
| + XCTAssertEqual(ChatFeedModel.threadItems(in: withRoot, root: rootID).map(\.id), [rootID, reply.id]) |
| + } |
| + |
| + // ---- the notification tap's thread landing (unreadThreadJump) ------------ |
| + // A push for a thread-only reply routes at thread granularity, so the |
| + // container asks this: is EVERY unread hidden in a thread? Only then does |
| + // it open one — a visible unread means the feed's own anchor (which lands |
| + // on the OLDEST unread) is already right, and a thread opened over it |
| + // would cover the messages the reader came for. |
| + |
| + private func unreadFixture() -> (visible: Message, hiddenOld: Message, hiddenNew: Message, root: Message) { |
| + let root = Message(id: 10, name: "Al", text: "root", mine: false, at: 100, edited: false) |
| + let visible = Message(id: 11, name: "Bo", text: "inline unread", mine: false, at: 200, edited: false) |
| + let hiddenOld = Message(id: 12, name: "Cy", text: "thread unread", mine: false, at: 210, |
| + edited: false, reply: ReplyRef(id: 10, name: "Al", snippet: "root", thread: true)) |
| + let hiddenNew = Message(id: 13, name: "Di", text: "newer thread unread", mine: false, at: 220, |
| + edited: false, reply: ReplyRef(id: 10, name: "Al", snippet: "root", thread: true)) |
| + return (visible, hiddenOld, hiddenNew, root) |
| + } |
| + |
| + func testUnreadThreadJumpOnlyWhenNothingVisibleIsUnread() { |
| + let f = unreadFixture() |
| + let cutoff: Int64 = 150 // the pre-tap watermark: the root is read, the rest aren't |
| + |
| + // Every unread hidden in a thread → the newest of them is the landing. |
| + let allHidden = [f.root, f.hiddenOld, f.hiddenNew] |
| + XCTAssertEqual(ChatFeedModel.unreadThreadJump(in: allHidden, |
| + feed: ChatFeedModel.mainFeedItems(allHidden), |
| + since: cutoff), 13, |
| + "all unreads are in threads — open the newest one's") |
| + |
| + // A visible unread among them → hands off to the feed's own anchor, even |
| + // though the NEWEST unread is a thread reply (the reported bug). |
| + let mixed = [f.root, f.visible, f.hiddenOld, f.hiddenNew] |
| + XCTAssertNil(ChatFeedModel.unreadThreadJump(in: mixed, |
| + feed: ChatFeedModel.mainFeedItems(mixed), |
| + since: cutoff), |
| + "an unread in the transcript means the feed's anchor owns this open") |
| + |
| + // Nothing unread at all → nothing to do. |
| + XCTAssertNil(ChatFeedModel.unreadThreadJump(in: allHidden, |
| + feed: ChatFeedModel.mainFeedItems(allHidden), |
| + since: 999), |
| + "no unread above the cutoff") |
| + } |
| + |
| + func testUnreadThreadJumpIgnoresMineAndRootlessReplies() { |
| + let f = unreadFixture() |
| + // My own message above the cutoff is not an unread — it must neither |
| + // trigger a landing nor block one. |
| + var mine = f.visible |
| + mine.mine = true |
| + let withMine = [f.root, mine, f.hiddenNew] |
| + XCTAssertEqual(ChatFeedModel.unreadThreadJump(in: withMine, |
| + feed: ChatFeedModel.mainFeedItems(withMine), |
| + since: 150), 13, |
| + "my own send doesn't count as a visible unread") |
| + |
| + // A thread reply whose root ISN'T loaded renders inline (mainFeedItems) |
| + // — so it's a visible unread, and no thread opens over it. |
| + let rootless = [f.hiddenNew] |
| + XCTAssertNil(ChatFeedModel.unreadThreadJump(in: rootless, |
| + feed: ChatFeedModel.mainFeedItems(rootless), |
| + since: 150), |
| + "root unloaded — the reply is an ordinary bubble in the feed") |
| + } |
| + |
| + @MainActor |
| + func testMainFeedItemsKeepsNeighboursAdjacent() { |
| + // A hidden reply between two feed messages: the visible list has them |
| + // side by side, so sender-run grouping and day separators computed over |
| + // it (the containers read `previous` off the visible list) see them as |
| + // neighbours — a thread reply never splits a run in the transcript. |
| + var raw = MessagePayload() |
| + raw.uid = "user-al-1" |
| + let a = Message(id: 1, name: "Al", text: "one", mine: false, at: 1, edited: false, raw: raw) |
| + let inThread = Message(id: 2, name: "Bo", text: "in thread", mine: false, at: 2, edited: false, |
| + reply: ReplyRef(id: 1, name: "Al", snippet: "one", thread: true)) |
| + let b = Message(id: 3, name: "Al", text: "two", mine: false, at: 3, edited: false, raw: raw) |
| + let inline = Message(id: 4, name: "Cy", text: "quote reply", mine: false, at: 4, edited: false, |
| + reply: ReplyRef(id: 1, name: "Al", snippet: "one")) |
| + let feed = ChatFeedModel.mainFeedItems([a, inThread, b, inline]) |
| + XCTAssertEqual(feed.map(\.id), [1, 3, 4], "thread-only hidden; roots and inline replies stay") |
| + XCTAssertTrue(ChatFeedModel.sameSender(feed[0], feed[1]), |
| + "the two feed messages group as one run across the hidden reply") |
| + XCTAssertEqual(ChatFeedModel.mainFeedItems([]).map(\.id), [], "empty in, empty out") |
| + // Root-aware: the same thread reply with its root NOT loaded stays inline. |
| + XCTAssertEqual(ChatFeedModel.mainFeedItems([inThread, b, inline]).map(\.id), [2, 3, 4], |
| + "no loaded root to carry the tally — the reply shows inline") |
| + // A legacy chain resolves to its root through a loaded mid-chain parent: |
| + // a flagged reply quoting a REPLY hides only when the resolved root is |
| + // loaded, not merely the quoted parent. |
| + let chained = Message(id: 5, name: "Di", text: "chain", mine: false, at: 5, edited: false, |
| + reply: ReplyRef(id: 4, name: "Cy", snippet: "quote reply", thread: true)) |
| + XCTAssertEqual(ChatFeedModel.mainFeedItems([a, inline, chained]).map(\.id), [1, 4], |
| + "resolved root 1 loaded — hidden") |
| + XCTAssertEqual(ChatFeedModel.mainFeedItems([inline, chained]).map(\.id), [4, 5], |
| + "quoted parent loaded but the resolved root 1 is not — shown") |
| + } |
| + |
| + #if DEBUG |
| + @MainActor |
| + func testFeedMessagesMirrorsTheFold() { |
| + // The model's published main-feed list tracks every publish of the fold |
| + // (didSet), so the containers never filter for themselves. |
| + let inst = SavedInstance(origin: "https://example.invalid", deviceToken: "tok", |
| + screenName: "Paul", userPub: "", userPriv: "") |
| + let thread = ChatThread(id: "t1", kind: "dm", title: "Eleven", preview: "", |
| + lastAt: 0, unread: false, pinned: false, hidden: false, |
| + members: [], threadKeyB64: "") |
| + let model = ChatFeedModel(instance: inst, thread: thread, myUserId: "me") |
| + var list = vectorMessages() |
| + ChatFeedModel.refreshReplyCounts(&list) |
| + model._testInject(messages: list) |
| + XCTAssertEqual(model.messages.map(\.id), [100, 101, 102, 103, 104]) |
| + XCTAssertEqual(model.feedMessages.map(\.id), [100, 101, 102, 103], |
| + "the feed list is the fold minus thread-only replies") |
| + XCTAssertEqual(model.feedMessages.first { $0.id == 100 }?.replyCount, 4, |
| + "the root's tally still counts the hidden reply") |
| + } |
| + #endif |
| + |
| func testReplyTargetStagesRoot() { |
| let list = vectorMessages() |
| @@ -112,6 +322,29 @@ final class ReplyThreadVectorTests: XCTestCase { |
| "the other thread folds on its own root") |
| } |
| + func testThreadItemsSortPendingCopiesLast() { |
| + // A just-sent reply's optimistic copy sits at the BOTTOM of the thread |
| + // until its echo confirms it — never ahead of the root or a confirmed |
| + // reply (the web's bySidPendingLast; its thread-view "blink" came from a |
| + // pending copy sorting above the root). Holds whatever id the copy |
| + // carries: the native synthetic id (1<<62) and a web-style 0 alike. |
| + var msgs = threadItemsFixture() |
| + var synthetic = Message(id: 1 << 62, name: "Me", text: "sending", mine: true, at: 9, |
| + edited: false, reply: ReplyRef(id: 1, name: "Al", snippet: "root", thread: true)) |
| + synthetic.pending = true |
| + synthetic.cid = "c1" |
| + var zero = Message(id: 0, name: "Me", text: "also sending", mine: true, at: 9, |
| + edited: false, reply: ReplyRef(id: 1, name: "Al", snippet: "root", thread: true)) |
| + zero.pending = true |
| + zero.cid = "c2" |
| + msgs.insert(zero, at: 0) |
| + msgs.append(synthetic) |
| + XCTAssertEqual(ChatFeedModel.threadItems(in: msgs, root: 1).map(\.id), [1, 3, 4, 0, 1 << 62], |
| + "root, confirmed replies in server order, then the pending copies") |
| + XCTAssertEqual(ChatFeedModel.threadItems(in: msgs, root: 1).first?.id, 1, |
| + "the root is always first, whatever a pending copy's id") |
| + } |
| + |
| func testThreadItemsWithUnloadedRoot() { |
| // Root scrolled out of the loaded window: the thread still folds from the |
| // replies we can see (the view renders the quoted snapshot as its header). |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index 55628c3..59b7261 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -249,6 +249,13 @@ struct NativeFeedView: View { |
| // threadFallback (chat.js openThread's ref argument). |
| @State private var threadRootID: Int64? |
| @State private var threadFallback: ReplyRef? |
| + // A jump whose target lives only in its thread (search hit, media-sheet |
| + // jump): the pane's row to land on + its wave — the feed's danceID/ |
| + // danceTick twins for the thread surface (jumpIntoThread). |
| + @State private var threadJump: Int64? |
| + @State private var threadJumpTick = 0 |
| + @State private var threadDanceID: Int64? |
| + @State private var threadDanceTick = 0 |
| // Mac v1: the reply thread always DOCKS (thread-pane spec §6) — a flush |
| // right-hand pane inside the chat surface, Slack-style, with its own |
| // composer. The lifted-overlay path is kept intact below (it's iOS's |
| @@ -432,7 +439,7 @@ struct NativeFeedView: View { |
| // children can overdraw their row freely. |
| ScrollView { |
| LazyVStack(spacing: 0) { |
| - if let appIntro, model.ready, model.exhausted || model.messages.isEmpty { |
| + if let appIntro, model.ready, model.exhausted || model.feedMessages.isEmpty { |
| Text(appIntro) |
| .font(.system(size: 13)) |
| .foregroundStyle(.secondary) |
| @@ -442,8 +449,14 @@ struct NativeFeedView: View { |
| .padding(.horizontal, 16) |
| .frame(maxWidth: .infinity) |
| } |
| - ForEach(Array(model.messages.enumerated()), id: \.element.id) { i, m in |
| - let previous = i > 0 ? model.messages[i - 1] : nil |
| + // The MAIN FEED list (model.feedMessages): the fold minus |
| + // thread-only replies — they live in the docked pane. |
| + // `previous` is read off this same list, so sender-run |
| + // grouping and day separators are computed over what's |
| + // visible: two feed bubbles with a hidden reply between |
| + // them in the fold still read as neighbours. |
| + ForEach(Array(model.feedMessages.enumerated()), id: \.element.id) { i, m in |
| + let previous = i > 0 ? model.feedMessages[i - 1] : nil |
| FeedRow(key: rowKey(m, previous: previous), |
| content: { AnyView(row(m, previous: previous, proxy: proxy)) }) |
| .equatable() |
| @@ -497,7 +510,7 @@ struct NativeFeedView: View { |
| .min { abs($0.value.midY - mid) < abs($1.value.midY - mid) } |
| Task { |
| await model.loadOlder() |
| - guard model.messages.first?.id != anchorID else { return } |
| + guard model.feedMessages.first?.id != anchorID else { return } |
| DispatchQueue.main.async { |
| if let ref, frames.viewport.height > ref.value.height { |
| let f = max(0, min(1, ref.value.minY / |
| @@ -584,10 +597,12 @@ struct NativeFeedView: View { |
| // send). The previous last row still being materialised near the |
| // viewport is the "at bottom" signal; yanking someone out of |
| // scroll-back for every incoming message made busy groups |
| - // unreadable. |
| - .onChange(of: model.messages.last?.id) { old, id in |
| + // unreadable. Keyed on the FEED's newest row, not the fold's: a |
| + // thread-only reply arriving (hidden here, shown in the pane) |
| + // must neither scroll nor pin the transcript. |
| + .onChange(of: model.feedMessages.last?.id) { old, id in |
| guard id != nil else { return } |
| - let mine = model.messages.last?.mine == true |
| + let mine = model.feedMessages.last?.mine == true |
| // Near-bottom only when the previous last row is MATERIALISED |
| // near the viewport. Defaulting the unknown case to true |
| // treated a reader deep in history (old row destroyed, no |
| @@ -597,7 +612,7 @@ struct NativeFeedView: View { |
| // No materialised rows at all while messages exist = the feed |
| // is showing blank space (the offset outlived a content |
| // shrink) — always re-pin our way out of that. |
| - let wedged = frames.rows.isEmpty && !model.messages.isEmpty |
| + let wedged = frames.rows.isEmpty && !model.feedMessages.isEmpty |
| // A VERIFIED pin, not a one-shot scrollTo: the send shrinks |
| // the composer inset (draft clearing) mid-jump and rows |
| // materialise with real heights, so the content can resize |
| @@ -609,7 +624,7 @@ struct NativeFeedView: View { |
| // The typing bubble appearing at the bottom should scroll into |
| // view when the reader is already there. |
| .onChange(of: model.typing.isEmpty) { _, empty in |
| - if !empty, let last = model.messages.last?.id, |
| + if !empty, let last = model.feedMessages.last?.id, |
| let f = frames.rows[last], f.minY < frames.viewport.height + 150 { |
| proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| } |
| @@ -618,7 +633,7 @@ struct NativeFeedView: View { |
| // unstaging resize the composer inset — keep the newest message |
| // pinned through it when the reader was at the bottom. |
| .onChange(of: staged.isEmpty) { _, _ in |
| - if let last = model.messages.last?.id, |
| + if let last = model.feedMessages.last?.id, |
| let f = frames.rows[last], f.minY < frames.viewport.height + 150 { |
| pinUntilSettled(proxy) |
| } |
| @@ -697,14 +712,14 @@ struct NativeFeedView: View { |
| // beat and rescue only if the viewport actually came up empty, so |
| // a scrolled-back reader isn't yanked to the newest message. |
| .onChange(of: chatFontSize) { _, _ in |
| - let nearBottom = model.messages.last.flatMap { frames.rows[$0.id]?.minY } |
| + let nearBottom = model.feedMessages.last.flatMap { frames.rows[$0.id]?.minY } |
| .map { $0 < frames.viewport.height + 150 } ?? false |
| if nearBottom { |
| pinUntilSettled(proxy) |
| return |
| } |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { |
| - if frames.rows.isEmpty && !model.messages.isEmpty { pinUntilSettled(proxy) } |
| + if frames.rows.isEmpty && !model.feedMessages.isEmpty { pinUntilSettled(proxy) } |
| } |
| } |
| .onAppear { |
| @@ -881,7 +896,7 @@ struct NativeFeedView: View { |
| // "restored" away from arriving messages next time. |
| private func captureAnchor() { |
| guard instance != nil, frames.viewport.height > 0, !frames.rows.isEmpty else { return } |
| - if let last = model.messages.last?.id, let f = frames.rows[last], |
| + if let last = model.feedMessages.last?.id, let f = frames.rows[last], |
| f.maxY <= frames.viewport.height + 150 { |
| storeAnchor(nil) |
| return |
| @@ -914,7 +929,7 @@ struct NativeFeedView: View { |
| _ anchor: NativeFeedModelCache.FeedScrollAnchor, |
| attempts: Int = 12) { |
| guard model.ready else { return } |
| - guard model.messages.contains(where: { $0.id == anchor.rowID }) else { |
| + guard model.feedMessages.contains(where: { $0.id == anchor.rowID }) else { |
| storeAnchor(nil) |
| pinUntilSettled(proxy) |
| return |
| @@ -959,15 +974,18 @@ struct NativeFeedView: View { |
| // composer — means a pinned maxY lands SHORT of viewport.height, so we |
| // can't require maxY ≈ height; "in the lower half and not below the bottom |
| // edge" is the robust band for a tall chat.) |
| + // Reads the FEED's rows (feedMessages): a thread-only reply is the fold's |
| + // newest message but has no row here — keying the verify on it would |
| + // never settle, and the feed would stay hidden. |
| private func lastRowFlush() -> Bool { |
| - guard let id = model.messages.last?.id, let f = frames.rows[id] else { return false } |
| + guard let id = model.feedMessages.last?.id, let f = frames.rows[id] else { return false } |
| let vh = frames.viewport.height |
| guard vh > 0 else { return false } |
| // Short chat: the whole log fits (content height = last.maxY - |
| // first.minY, independent of the offset), top-aligned — flush means the |
| // first row starts at/after the top and the last row's bottom isn't |
| // shoved past the viewport bottom. |
| - if let firstID = model.messages.first?.id, let ff = frames.rows[firstID], |
| + if let firstID = model.feedMessages.first?.id, let ff = frames.rows[firstID], |
| f.maxY - ff.minY <= vh { |
| return ff.minY >= -2 && f.maxY <= vh + 2 |
| } |
| @@ -994,7 +1012,7 @@ struct NativeFeedView: View { |
| private func pinUntilSettled(_ proxy: ScrollViewProxy, attempts: Int = 12, |
| rescues: Int = 4) { |
| guard model.ready else { return } // the ready flip re-enters |
| - guard let id = model.messages.last?.id else { |
| + guard let id = model.feedMessages.last?.id else { |
| settled = true // nothing to pin (empty chat) |
| return |
| } |
| @@ -1005,8 +1023,8 @@ struct NativeFeedView: View { |
| // below the viewport, or none materialised) — jolt to a valid |
| // anchor and retry on a slower cadence (bounded by `rescues`) |
| // rather than revealing a blank pane. |
| - if rescues > 0, !lastRowFlush(), !model.messages.isEmpty { |
| - if let first = model.messages.first?.id { |
| + if rescues > 0, !lastRowFlush(), !model.feedMessages.isEmpty { |
| + if let first = model.feedMessages.first?.id { |
| proxy.scrollTo(first, anchor: .top) |
| } |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { |
| @@ -1018,7 +1036,7 @@ struct NativeFeedView: View { |
| return |
| } |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { |
| - guard model.messages.last?.id == id else { |
| + guard model.feedMessages.last?.id == id else { |
| settled = true |
| return |
| } |
| @@ -1036,7 +1054,7 @@ struct NativeFeedView: View { |
| // Confirm the pin HELD; if it slipped either way (pushed below |
| // OR stranded above), keep pinning. |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { |
| - guard model.messages.last?.id == id, !lastRowFlush() else { return } |
| + guard model.feedMessages.last?.id == id, !lastRowFlush() else { return } |
| pinUntilSettled(proxy, attempts: max(attempts - 1, 2), rescues: rescues) |
| } |
| return |
| @@ -1052,7 +1070,7 @@ struct NativeFeedView: View { |
| // the stranded open sit blank for ~a second before the slow rescue |
| // path finally landed it. (Previously gated on frames.rows.isEmpty, |
| // so a stranded-but-materialised feed never recovered at all.) |
| - if let first = model.messages.first?.id { |
| + if let first = model.feedMessages.first?.id { |
| proxy.scrollTo(first, anchor: .top) |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { |
| pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues) |
| @@ -1265,7 +1283,7 @@ struct NativeFeedView: View { |
| // is the last row and the reader is at the |
| // bottom, re-pin so the posted link stays |
| // visible (verified, like every other pin). |
| - if card != nil, m.id == model.messages.last?.id, |
| + if card != nil, m.id == model.feedMessages.last?.id, |
| let f = frames.rows[m.id], |
| f.minY < frames.viewport.height + 150 { |
| DispatchQueue.main.async { pinUntilSettled(proxy) } |
| @@ -1305,9 +1323,11 @@ struct NativeFeedView: View { |
| .jumpWave(on: m.id == danceID, tick: danceTick) |
| } |
| - // The list ⌘R / ⌘T read — see the tick handlers above. |
| + // The list ⌘R / ⌘T read — see the tick handlers above. The feed's |
| + // VISIBLE list when no thread is open: a thread-only reply is never the |
| + // "latest message" in front of the reader. |
| private var hotkeyMessages: [ChatFeedModel.Message] { |
| - guard let rootID = threadRootID else { return model.messages } |
| + guard let rootID = threadRootID else { return model.feedMessages } |
| return ChatFeedModel.threadItems(in: model.messages, root: rootID) |
| } |
| @@ -1440,7 +1460,9 @@ struct NativeFeedView: View { |
| }, |
| ownsCancelKey: !paneFocused, |
| onEditLatest: { |
| - guard let last = model.messages.last(where: { $0.mine && !$0.action }) |
| + // ↑ edits the latest own message IN THE FEED — a thread |
| + // reply of mine is edited from the pane (its own ↑). |
| + guard let last = model.feedMessages.last(where: { $0.mine && !$0.action }) |
| else { return false } |
| startEdit(last) |
| return true |
| @@ -1729,6 +1751,7 @@ struct NativeFeedView: View { |
| private func threadOverlay(_ rootID: Int64) -> some View { |
| ReplyThreadView(model: model, rootID: rootID, fallback: threadFallback, |
| theme: theme, isGroup: isGroup, |
| + jumpTarget: threadJump, jumpTick: threadJumpTick, |
| onClose: closeThread, |
| onRootPaged: { |
| // The root just paged in — refresh the banner to its |
| @@ -1750,6 +1773,7 @@ struct NativeFeedView: View { |
| VStack(spacing: 0) { |
| ReplyThreadView(model: model, rootID: rootID, fallback: threadFallback, |
| theme: theme, isGroup: isGroup, docked: true, |
| + jumpTarget: threadJump, jumpTick: threadJumpTick, |
| onClose: closeThread, |
| row: { m, showSender in |
| threadBubble(m, showSender: showSender, pane: true) |
| @@ -1863,11 +1887,12 @@ struct NativeFeedView: View { |
| return wire.isEmpty ? paneDraft : wire |
| } |
| - // The pane's send: an ORDINARY replyTo reply — no command routing, no |
| - // default-command detour (matching the web pane composer). The reply |
| - // target derives FRESH here (ChatFeedModel.replyTarget semantics): the |
| - // root may have paged in, or the chain's deepest snapshot improved, |
| - // since the pane opened. |
| + // The pane's send: a replyTo reply flagged thread-only (ReplyRef.thread |
| + // — it lives in the thread, never as a main-feed bubble; Slack's model) |
| + // — no command routing, no default-command detour (matching the web pane |
| + // composer). The reply target derives FRESH here (ChatFeedModel |
| + // .replyTarget semantics): the root may have paged in, or the chain's |
| + // deepest snapshot improved, since the pane opened. |
| private func sendPaneDraft() { |
| // An edit replaces the message in place — same rule as the channel bar. |
| if let id = paneEditingID { |
| @@ -1883,7 +1908,7 @@ struct NativeFeedView: View { |
| // Only clear once the send reaches the socket — a down socket keeps |
| // the draft to resend, never silently drops it (the web's rule). |
| if model.send(paneWireDraft, attachments: paneStaged.compactMap(\.attachment), |
| - replyTo: model.threadReplyRef(root: rootID)) { |
| + replyTo: model.threadReplyRef(root: rootID).inThread()) { |
| paneDraft = "" |
| paneStaged = [] |
| paneSendQueued = false |
| @@ -2021,6 +2046,26 @@ struct NativeFeedView: View { |
| app.youtubeLightbox = YouTubeLightboxItem(videoID: videoID, start: start, |
| sourceFrame: frame) |
| }) |
| + // The jump wave, on the thread surface (jumpIntoThread's target). |
| + .jumpWave(on: m.id == threadDanceID, tick: threadDanceTick) |
| + } |
| + |
| + // A jump whose target lives only in its thread — loaded, but absent from |
| + // the transcript (mainFeedItems: a thread reply with its root on screen): |
| + // open the docked pane on the root (a no-op when already open there), |
| + // then land on THAT reply inside it and wave it — the feed jump's two |
| + // beats, on the pane. The web lands inside .chatapp-thread-msgs the same |
| + // way, so a search hit or media-sheet jump never dead-ends on "the |
| + // newest reply" instead of the one asked for. |
| + private func jumpIntoThread(_ target: ChatFeedModel.Message) { |
| + guard let root = target.threadRootID ?? target.reply?.id, root > 0 else { return } |
| + openThread(root) |
| + threadJump = target.id |
| + threadJumpTick += 1 |
| + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { |
| + threadDanceID = target.id |
| + threadDanceTick += 1 |
| + } |
| } |
| // "Reply in Thread" (thread-pane spec §3.5; web openReplyThread): open |
| @@ -2043,6 +2088,9 @@ struct NativeFeedView: View { |
| // Freeze the header stand-in NOW (web threadFallback) — the deepest |
| // quoted snapshot in loaded state, in case the root never pages in. |
| threadFallback = model.threadReplyRef(root: rootID) |
| + // A fresh open lands at the newest reply — never on a stale jump |
| + // target from an earlier one (jumpIntoThread re-aims right after). |
| + threadJump = nil |
| // Switching roots with the pane open: the old thread's half-typed |
| // draft or in-flight edit must not leak into the new one. |
| if threadDocked, threadRootID != nil { resetPaneComposer() } |
| @@ -2061,11 +2109,13 @@ struct NativeFeedView: View { |
| // Stage the composer's reply on the thread's root — the view's composer |
| // contract ("Replying to <root author>"). A reply displaces an in-flight |
| - // edit; desktop focuses the composer, like the web. |
| + // edit; desktop focuses the composer, like the web. Flagged thread-only: |
| + // a send from an open thread view lives in the thread (the same flag the |
| + // docked pane's bar sets), never as a main-feed bubble. |
| private func stageThreadReply() { |
| guard let rootID = threadRootID else { return } |
| editingID = nil |
| - replyTo = model.threadReplyRef(root: rootID) |
| + replyTo = model.threadReplyRef(root: rootID).inThread() |
| composerFocused = true |
| } |
| @@ -2073,6 +2123,7 @@ struct NativeFeedView: View { |
| guard let rootID = threadRootID else { return } |
| withAnimation(.easeOut(duration: 0.15)) { threadRootID = nil } |
| threadFallback = nil |
| + threadJump = nil |
| if threadDocked { |
| // The pane's composer dies with the pane (the web's docked pane |
| // block is destroyed on close); focus returns to the channel bar |
| @@ -2362,7 +2413,15 @@ struct NativeFeedView: View { |
| } |
| private func jump(to id: Int64, proxy: ScrollViewProxy) { |
| - guard model.messages.contains(where: { $0.id == id }) else { return } |
| + guard let target = model.messages.first(where: { $0.id == id }) else { return } |
| + // Loaded but not in the transcript = a thread-only reply whose root |
| + // is loaded (mainFeedItems): its bubble is in the pane, so land |
| + // there. (With the root unloaded it degrades to an inline bubble and |
| + // the ordinary scroll below finds it.) |
| + if !model.feedMessages.contains(where: { $0.id == id }) { |
| + jumpIntoThread(target) |
| + return |
| + } |
| // Already fully in view (with a little headroom for the wave)? Don't |
| // yank the feed around — just wave where it stands. |
| if let f = frames.rows[id], f.minY >= 12, f.maxY <= frames.viewport.height - 4 { |