eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import LChatCore
2import SwiftUI
3import AVFoundation
4import os
5
6// Routes drop-to-attach from RootView's ONE drop surface (the window-wide
7// AppKit catcher, with its SwiftUI .onDrop safety net) to the visible feed.
8// Per-pane .onDrop regions can't coexist with the keep-alive ZStack: drop
9// targeting resolves to the topmost region regardless of opacity or
10// allowsHitTesting, so a hidden space's region either swallowed the drop
11// (staging into its own composer, invisibly) or — type-gated — dead-ended
12// the drag with no + badge for the pane actually on screen.
13//
14// Routing is PULL-based, deliberately: feeds register by pane key on mount
15// and unregister on unmount, and the drop asks "which pane is selected NOW"
16// at drag time. The first cut had the active feed claim/release a single
17// slot on isActive flips — and a missed/coalesced onChange left the router
18// unclaimed while the visible feed believed it was the owner (bitten on a
19// return to an earlier space). Mount lifecycle is structural and can't be
20// coalesced away; selection is read live, so there is no event ordering to
21// get wrong. Main-thread only (registration comes from view lifecycle,
22// delivery from the drop surface).
23//
24// The payload is the drag PASTEBOARD, not [NSItemProvider]: the AppKit
25// catcher only ever has the pasteboard, and it carries every drag shape the
26// feed stages (file URLs, file promises, raw image data) — plus the drop's
27// LOCATION, since one window-wide region can't itself say where a drop
28// landed and the feed needs that to pick a composer (see dropTarget(at:)).
29final class PaneDropRouter {
30 static let shared = PaneDropRouter()
31 private struct Entry {
32 let token: UUID
33 let handler: (NSPasteboard, CGPoint) -> Void
34 }
35 // Keyed by the pane's instance origin — hidden panes stay registered
36 // (they stay mounted); selection picks between them at drop time.
37 private var entries: [String: Entry] = [:]
38
39 func register(_ key: String, token: UUID,
40 handler: @escaping (NSPasteboard, CGPoint) -> Void) {
41 Logger.identity.info("drop router: register \(key, privacy: .public)")
42 entries[key] = Entry(token: token, handler: handler)
43 }
44 // Token-checked so a within-pane thread switch can't misfire: the old
45 // feed's onDisappear may land after the new feed registered the same key.
46 func unregister(_ key: String, token: UUID) {
47 guard entries[key]?.token == token else { return }
48 Logger.identity.info("drop router: unregister \(key, privacy: .public)")
49 entries[key] = nil
50 }
51 // Drives the drag's + badge: no registered feed for the selected pane
52 // (locked pane, default-command thread, first-run) → the window rejects
53 // the drag, matching the old empty-type-list gate.
54 func canDrop(over key: String?) -> Bool {
55 key.flatMap { entries[$0] } != nil
56 }
57 /// `point` is where the file was dropped, in SwiftUI `.global` space
58 /// (top-left origin, measured from the window's content view) — the space
59 /// `LiveFrame.rect` answers in, so the receiving feed can test it against
60 /// its tracked frames with no further conversion. Both drop surfaces
61 /// convert into it once, at their own edge (RootView).
62 func deliver(_ pasteboard: NSPasteboard, at point: CGPoint, over key: String?) -> Bool {
63 guard let key, let entry = entries[key] else {
64 Logger.identity.error("drop router: no feed for \(key ?? "<no selection>", privacy: .public) — drop bounced")
65 return false
66 }
67 Logger.identity.info("drop router: deliver to \(key, privacy: .public) at \(Self.pt(point), privacy: .public)")
68 entry.handler(pasteboard, point)
69 return true
70 }
71
72 // Points/rects for the log lines — rounded, so a `log show` read of a
73 // misrouted drop is legible at a glance.
74 static func pt(_ p: CGPoint) -> String { String(format: "(%.0f, %.0f)", p.x, p.y) }
75 static func rc(_ r: CGRect) -> String {
76 String(format: "(%.0f, %.0f, %.0f×%.0f)", r.minX, r.minY, r.width, r.height)
77 }
78}
79
80// One thread's natively-folded feed — the real native chat surface (mounted by
81// the takeover's NativeChatView and the Debug diff pane alike), rendered with
82// the shared MessageBubble leaf views.
83// leaf view (LChatCore). Own view so the ChatFeedModel's lifetime is tied
84// (via .id above) to the picked thread.
85struct NativeFeedView: View {
86 @StateObject private var model: ChatFeedModel
87 @Environment(\.chatFontSize) private var chatFontSize
88 private let isGroup: Bool
89 private let roster: [RosterEntry]
90 private let myUserId: String
91 // A bot in the thread is what makes /commands answerable (web threadHasBot).
92 private let hasBot: Bool
93 // The message a quote-tap jumped to does a little dance so the eye lands on
94 // it. danceTick retriggers the animation even for the same target.
95 @State private var danceID: Int64?
96 @State private var danceTick = 0
97 // Live row frames in the scroll viewport, written by row geometry probes and
98 // read imperatively by jump(). A plain reference (NOT @State): frames change
99 // on every scroll tick, and none of the UI derives from them — state here
100 // would re-render the whole list per tick.
101 // ObservableObject (with no @Published — geometry writes must never
102 // re-render the list) so @StateObject pins ONE instance across struct
103 // re-inits: the swipe monitor closure and the geometry probes have to be
104 // reading and writing the same store.
105 private final class FrameStore: ObservableObject {
106 var rows: [Int64: CGRect] = [:]
107 var viewport: CGSize = .zero
108 // RootView keeps every opened instance's pane MOUNTED at opacity 0, so
109 // hidden panes share the visible one's frame; the swipe monitor must
110 // only act for the pane actually on screen. A box (not view state) so
111 // the monitor closure reads the live value.
112 var isActive = true
113 // Debounces the scroll-position capture (the per-thread anchor): the
114 // wheel monitor fires per tick, and reading the row frames mid-fling
115 // is churn for nothing — capture once the scroll goes quiet.
116 var anchorDebounce: DispatchWorkItem?
117 // Throttle stamp for the DURING-scroll captures. The trailing
118 // debounce alone lost the race that mattered: scroll up, click
119 // another chat within a beat — teardown cancelled the pending
120 // capture and no anchor was ever saved (Paul's "state isn't being
121 // saved at all"). Capturing every ~150ms mid-scroll keeps the anchor
122 // never more than a few frames stale, whatever happens next.
123 var lastCaptureAt: TimeInterval = 0
124 // The reader's own hands on the feed: when they last moved it (a
125 // scroll, or a mouse drag — selecting text autoscrolls with no scroll
126 // events at all), and whether a drag is still down. The self-correcting
127 // scroll loops read these and hold off: moving the offset under a live
128 // drag turns a text selection into a scroll, and a quiet read into a
129 // jump.
130 var lastInputAt: TimeInterval = 0
131 var dragging = false
132 // The centre-row reading taken at that moment — the prepend
133 // re-anchor's reference. Kept CURRENT as the reader moves the feed
134 // rather than read once when a page fetch starts: the fetch is a real
135 // network round-trip plus a decrypt, and a reading from before it
136 // would put the reader back where they were when it began (a
137 // scrolled-away-by-itself feed). Stale only while nothing is moving —
138 // when it is stale, it is also still correct.
139 var centreRef: NativeFeedModelCache.FeedScrollAnchor?
140 // Coalesces the re-pin that follows a viewport resize: a live divider
141 // drag fires per pixel, and one verify loop per tick would fight
142 // itself. One pin, a beat after the size settles.
143 var resizePinDebounce: DispatchWorkItem?
144 // The live scroll loop's generation. All three of this file's
145 // self-correcting loops — pinUntilSettled, settleBottomGently and
146 // restoreUntilSettled — run across many runloop turns, and the resize
147 // debounce above only cancels a pin that has not STARTED. So a second
148 // trigger (another keystroke widening the composer, a message landing
149 // mid-restore) used to start a second loop beside the first, each
150 // issuing its own scrollTo from its own round of geometry — which is
151 // the "jumping around" the reader sees. A fresh loop takes the next
152 // generation; every continuation in all three re-checks that it still
153 // holds the current one and stands down if it doesn't.
154 var pinGeneration = 0
155 }
156 @StateObject private var frames = FrameStore()
157
158 private let mentionAvatar: (String) -> AvatarSource?
159
160 private let isActive: Bool
161 @AppStorage("nativeTheme") private var themeName = "classic"
162 private var theme: ChatTheme { .named(themeName) }
163 @Environment(\.colorScheme) private var scheme
164
165 init(model: ChatFeedModel, thread: ChatThread, myUserId: String, isGroup: Bool,
166 isActive: Bool = true, roster: [RosterEntry],
167 instance: SavedInstance? = nil, instanceKeyB64: String? = nil,
168 store: ThreadStore? = nil,
169 mentionAvatar: @escaping (String) -> AvatarSource?,
170 onReopenDM: (() async -> LChatAPI.ReopenDM)? = nil,
171 onMutePerson: ((String, Bool) -> Void)? = nil,
172 onReport: ((ChatFeedModel.Message) -> Void)? = nil,
173 onSettled: (() -> Void)? = nil,
174 onMemberProfile: ((String) -> Void)? = nil) {
175 // The model arrives PARKED (NativeFeedModelCache) — folded messages and
176 // a live socket survive leaving the chat, so re-opening never flashes.
177 _model = StateObject(wrappedValue: model)
178 // EVERY open starts hidden until its position verifiably lands —
179 // bottom pin or anchor restore alike. The first cut of this revealed
180 // parked models from the first frame ("instant"), but rows
181 // materialise with real heights after that frame, and with tall
182 // bubbles (code blocks) the correction was a visible jump on every
183 // switch — Paul's report, twice. A parked model settles in a verify
184 // round or two (~50–100ms of pane background), which reads as a fast
185 // render; a visible jump reads as broken. No jumps > instant.
186 _settled = State(initialValue: false)
187 self.isGroup = isGroup
188 self.isActive = isActive
189 self.roster = roster
190 self.myUserId = myUserId
191 self.mentionAvatar = mentionAvatar
192 let members = Set(thread.members)
193 self.hasBot = roster.contains { members.contains($0.user_id) && $0.isAgent }
194 self.appIntro = thread.appId.flatMap { AppChatProjection.intros[$0] }
195 // The app-thread's default command (To-do: plain text adds a to-do) —
196 // the web's defaultCommandFor, so every composer routes identically.
197 self.defaultCommand = AppDefaultCommand.spec(appID: thread.appId, hasBot: self.hasBot)
198 // The thread's app-local verbs for the "/" typeahead (/add in To-do),
199 // then the command-host verbs (/emoji) — every native composer offers
200 // what the per-thread command webview can answer (see sendDraft).
201 self.appCommands = AppDefaultCommand.localCommands(appID: thread.appId) + WebPanelCommands.catalog
202 self.threadID = thread.id
203 // The command host needs the full thread (key) + instance to seed its
204 // webview; nil (a mount without an instance) just disables forwarding.
205 self.thread = thread
206 self.instance = instance
207 self.instanceKeyB64 = instanceKeyB64
208 self.store = store
209 self.onReopenDM = onReopenDM
210 self.onMutePerson = onMutePerson
211 self.onReport = onReport
212 self.onSettled = onSettled
213 self.onMemberProfile = onMemberProfile
214 }
215
216 private let defaultCommand: AppDefaultCommand.Spec?
217 private let appCommands: [(name: String, desc: String)]
218
219 private let threadID: String
220 private let thread: ChatThread
221 private let instance: SavedInstance?
222 private let instanceKeyB64: String?
223 // For the command host's needAppThread answers (open-or-create a personal
224 // app thread) — nil just leaves cross-thread commands unresolved.
225 private let store: ThreadStore?
226 // The closed banner's Reopen (#578) — routed through the mounting view's
227 // ThreadStore so the sidebar row updates with the composer. nil (a mount
228 // without a store, e.g. the Debug pane) just hides the button's action.
229 private let onReopenDM: (() async -> LChatAPI.ReopenDM)?
230 // Mute/unmute a message's sender (#578) — the container (NativeChatView)
231 // routes it to ThreadStore.setPersonMuted; nil hides the menu item.
232 private let onMutePerson: ((String, Bool) -> Void)?
233 // Report a message's sender to the admins (#578) — the container owns the
234 // sheet (it holds the ThreadStore with the roster + keys to seal to);
235 // nil hides the menu item.
236 private let onReport: ((ChatFeedModel.Message) -> Void)?
237 // Fired every time the feed's position verifiably lands (settled flips
238 // true) — the chat switcher uses it to drop the outgoing feed it holds
239 // over the settle beat.
240 private let onSettled: (() -> Void)?
241 // A tapped @mention chip — the container opens the member's profile card.
242 private let onMemberProfile: ((String) -> Void)?
243
244 // The companion app's start-of-history intro (chatIntro on the web),
245 // shown above the earliest message once all history is loaded.
246 private let appIntro: String?
247
248 // The lightbox lives on MacAppState (RootView overlays it window-wide).
249 @EnvironmentObject private var app: MacAppState
250 // Transient status-bar line while a video/file fetches ("nothing happened"
251 // is worse than a quiet caption; the web shows a radial ring here).
252 @State private var busyText: String?
253 // A default command's push-back line ("when should I remind you?") — its
254 // OWN slot, not busyText: the upload paths clear busyText on their own 3s
255 // timers and would wipe an unrelated instruction. The generation counter
256 // makes every clear timer cancel-safe (a re-shown identical prompt must
257 // not be wiped by the previous showing's timer), and dcRouting keeps one
258 // async routing in flight at a time (no double-created reminders).
259 @State private var routeNotice: String?
260 @State private var routeNoticeGen = 0
261 @State private var dcRouting = false
262 // The feed's inline status line (FeedNoteBar, above the transcript): a
263 // jump that has to page history back for its target says so, and says why
264 // when it can't reach it. `revealing` is the target of the walk in flight
265 // — one at a time, so a second tap can't stack walks.
266 @State private var feedNote: String?
267 @State private var feedNoteGen = 0
268 @State private var revealing: Int64?
269 // The thread's COMMAND HOST — the hidden per-thread webview that runs
270 // mini-app slash commands (/emoji) and shows their panels; the panel
271 // reveals the webview above the composer (see CommandPanelWebView.swift).
272 @State private var commandHost: CommandHost?
273 // This thread's commandItems() rows (the host reports them once ready) —
274 // they feed the "/" typeahead and the forward-or-send routing decision.
275 @State private var hostRows: [CommandRow] = []
276 @State private var panelVisible = false
277 @State private var panelHeight: CGFloat = 240
278 @State private var panelAnimates = true // the open post's animate flag, reused on close
279 // The native composer's draft — cleared only when a send actually reaches
280 // the server (model.send returns false on a down socket, keeping the text).
281 @State private var draft = ""
282 @State private var integrityReport: String? // /integrity read-out (tamper evidence)
283 // A message's "Convert to thread" action: the chain to move, held while the
284 // confirm is up. The ids are snapshotted at the click, so what moves is what
285 // the person was looking at — not a set re-derived after the fact.
286 @State private var convertTarget: ConvertTarget?
287 struct ConvertTarget: Identifiable {
288 let root: Int64
289 let ids: [Int64]
290 var id: Int64 { root }
291 }
292 // Plain @State: MacComposerField drives first responder from it directly.
293 @State private var composerFocused = false
294 // Programmatic edits (typeahead splice, panel inserts) act on the view via
295 // the controller, so the caret survives them.
296 @State private var composerController = MacComposerController()
297 @AppStorage("formatAsYouType") private var formatAsYouType = true
298 // Attachments staged on the draft (Staged is file-scope, shared with
299 // MessageComposerBar, which renders the chips; the upload pipeline that
300 // fills them stays here — the window drop zone feeds it).
301 @State private var staged: [Staged] = []
302 // Send pressed mid-upload (the web's PR #269): the button swaps its arrow
303 // for a spinner and the message goes out the moment the uploads finish.
304 @State private var sendQueued = false
305 // Staged reply target / message being edited (the composer banners).
306 @State private var replyTo: ReplyRef?
307 @State private var editingID: Int64?
308 // The open reply-thread view's root id (nil = closed).
309 @State private var threadRootID: Int64?
310 // Is the Threads list showing in the right dock? (It and the thread pane
311 // share that one column — see the body.) It takes the caret while it's up,
312 // so Escape closes it rather than reaching the composer underneath.
313 @State private var showThreadList = false
314 @FocusState private var threadListFocused: Bool
315 // A jump whose target lives only in its thread (search hit, media-sheet
316 // jump): the pane's row to land on + its wave — the feed's danceID/
317 // danceTick twins for the thread surface (jumpIntoThread).
318 @State private var threadJump: Int64?
319 @State private var threadJumpTick = 0
320 @State private var threadDanceID: Int64?
321 @State private var threadDanceTick = 0
322 // Mac v1: the reply thread always DOCKS (thread-pane spec §6) — a flush
323 // right-hand pane inside the chat surface, Slack-style, with its own
324 // composer. The lifted-overlay path is kept intact below (it's iOS's
325 // presentation, and the escape hatch if docking ever grows a width
326 // fallback) but is unreachable while this stays constant true.
327 private let threadDocked = true
328 // The docked pane's OWN composer state — a second container state set,
329 // exactly what the MessageComposerBar extraction (slice 4) was for. No
330 // replyTo twin: the pane's reply target is fixed (the thread root,
331 // derived fresh at send), so nothing user-armed lives here.
332 @State private var paneDraft = ""
333 @State private var paneFocused = false
334 @State private var paneController = MacComposerController()
335 @State private var paneStaged: [Staged] = []
336 @State private var paneSendQueued = false
337 @State private var paneEditingID: Int64?
338 @State private var paneBusyText: String?
339 // This surface's message menus, keyed by message id, so ⌘T can pop the one
340 // belonging to the right bubble (issue #392). A plain reference handed down
341 // the Environment: the catchers register themselves as they mount, and
342 // nothing here ever observes it.
343 // One per surface, never shared: a message showing in BOTH the feed and an
344 // open reply-thread view has a catcher in each, and a single scope would
345 // hand ⌘T whichever mounted last.
346 @State private var menuScope = MessageMenuScope()
347 @State private var threadMenuScope = MessageMenuScope()
348 // Messages' "Show Times": how far the time gutter is revealed (0…44).
349 // Driven continuously by two-finger horizontal swipes over the feed and
350 // snapped open/closed on release, or toggled whole from the context menu.
351 // Deliberately NOT persisted — it's a peek, not a mode.
352 @State private var timesReveal: CGFloat = 0
353 // The feed's window frame + the swipe monitor handle, for routing
354 // horizontal scroll events (vertical ones pass through untouched). The
355 // frame is a LiveFrame — read lazily at scroll-event time, never tracked
356 // with a per-frame `onChange(of: CGRect)` (window-resize churn — #899).
357 @State private var paneFrame = LiveFrame()
358 // The DOCKED THREAD PANE's window frame — despite the name above,
359 // paneFrame tracks the FEED column, so routing a drop needs the other
360 // half of the split. Same lazy LiveFrame treatment: read only when a
361 // drop lands (dropTarget(at:)), never observed. Its anchor dies with the
362 // pane, so a closed thread reads .zero — and an empty rect contains
363 // nothing, which is exactly "everything goes to the channel composer".
364 @State private var threadPaneFrame = LiveFrame()
365 @State private var swipeMonitor: Any?
366 // The drag monitor's twin handle: mouse input over the feed is how a text
367 // selection moves the offset (AppKit autoscrolls the drag), and it emits no
368 // scroll events, so the swipe monitor alone can't see it.
369 @State private var dragMonitor: Any?
370 // Identifies this feed's registration on the shared drop router, so a
371 // stale unregister (a thread switch's old feed disappearing after the new
372 // one registered the same pane key) can't evict the rightful receiver.
373 @State private var dropToken = UUID()
374 // True while a horizontal swipe is actively dragging the gutter — the
375 // zero-delta release event snaps only when a drag was in flight.
376 @State private var draggingTimes = false
377 // The bubble stage is sized from the VIEWPORT (minus row padding 20, run
378 // spacer 48, and the gutter — reserved whether or not it's on screen, so
379 // revealing times never resizes a bubble), capped at a readable measure.
380 // State, not a frame-store read: window resizes must re-render the rows.
381 static let timeGutter: CGFloat = 44
382 @State private var stageWidth: CGFloat = 368
383 @State private var viewportHeight: CGFloat = 0
384
385 private func updateStageWidth(_ viewport: CGSize) {
386 viewportHeight = viewport.height
387 // Quantised to 16pt steps: a live divider drag fires per-pixel size
388 // changes, and re-wrapping every row each tick is the flashing/jumping
389 // Paul saw — most ticks now change nothing.
390 let raw = min(660, max(300, viewport.width - 20 - 48 - Self.timeGutter))
391 let stepped = (raw / 16).rounded(.down) * 16
392 if stepped != stageWidth { stageWidth = stepped }
393 }
394
395 private var timesShown: Bool { timesReveal > 0 }
396 // The clip currently downloading + its 0…1 progress; drives the radial
397 // ring on its card. Shown only once the fetch outlives the web's 150ms
398 // grace, so a cached/fast load never flashes an empty ring.
399 @State private var videoDownload: (id: String, progress: Double)?
400
401 // Jump-to-latest: shown once the reader has scrolled a MODERATE distance
402 // up — much wider than the 150pt auto-pin slack used elsewhere in this
403 // file, so the button doesn't appear for merely reading the newest
404 // couple of messages. Toggled by the sentinel's geometry probe below,
405 // written only on the boolean's actual edges (not every scroll tick) so
406 // it never becomes another per-frame @Published-style re-render source
407 // (see the FrameStore comment above on why `frames` itself isn't state).
408 @State private var jumpButtonVisible = false
409 static let jumpButtonGap: CGFloat = 400
410 // Legacy (always-shown) scrollbars claim ~15pt of layout width on the
411 // trailing edge, so the scroll CONTENT — and with it the bubbles' right
412 // edge — ends short of the ScrollView frame the jump button's overlay
413 // measures from. The content probe records the difference so the button
414 // hugs the bubbles' actual edge in both scroller modes. Integer-guarded:
415 // during a live divider drag viewport and content shrink in lockstep, so
416 // the inset is constant and this never becomes a per-pixel re-render.
417 @State private var scrollerInset: CGFloat = 0
418
419 private func updateScrollerInset(contentWidth: CGFloat) {
420 let inset = max(0, (frames.viewport.width - contentWidth).rounded())
421 if inset != scrollerInset { scrollerInset = inset }
422 }
423
424 var body: some View {
425 HStack(spacing: 0) {
426 feedAndComposer
427 // The docked thread pane (thread-pane spec §6): a flush right
428 // column INSIDE the chat surface, beside the feed — never over
429 // it. Insertion/removal animates with openThread/closeThread's
430 // withAnimation; the lifted-overlay presentation is the kept
431 // !threadDocked path inside feedAndComposer.
432 // The Threads list takes the SAME dock: there is only one, so the
433 // list and a thread are never both readable (the web made exactly
434 // this trade). Opening a thread from a row therefore drops the
435 // list as the thread arrives — which is what you'd want anyway:
436 // you came to find a thread, you found it.
437 //
438 // The list only BORROWS the dock, though: it lays OVER the open
439 // pane rather than replacing it, so a thread you were mid-reply in
440 // keeps its half-typed text — bold runs, caret and undo included —
441 // while you look something up, and closing the list hands it
442 // straight back. (Replacing the pane looked equivalent because the
443 // draft string lives on THIS view, but the rich content lives in
444 // the composer's own text storage, which dies with the view: the
445 // reply would come back plain, with the caret at the end and the
446 // undo stack gone. The web's panel evicts the thread outright for
447 // the same reason — its pane composer dies with the DOM node.)
448 // ONE right column, whatever is standing in it. The list and the
449 // pane used to be separate branches, so opening a thread from a
450 // row removed one column and inserted another — and the feed
451 // reflowed wide and back in the gap between them. Kept as a single
452 // container, the column never leaves: only its contents cross-fade,
453 // and the transition below is just for the column itself arriving
454 // or leaving.
455 if threadRootID != nil || showThreadList {
456 Divider()
457 ZStack {
458 if let rootID = threadRootID, threadDocked {
459 threadPane(rootID)
460 .accessibilityHidden(showThreadList) // covered: not in the reading order
461 // Explicitly a FADE, not the default. Inserted
462 // inside a withAnimation, SwiftUI otherwise
463 // animates the new subtree's own geometry — the
464 // composer measuring its field, the scroll view
465 // taking its size — and the pane visibly grows
466 // into place. The column is already the right
467 // size; only the contents should change.
468 .transition(.opacity)
469 }
470 if showThreadList { threadListPane.transition(.opacity) }
471 }
472 .frame(width: 340)
473 .transition(.move(edge: .trailing).combined(with: .opacity))
474 }
475 }
476 .alert("Integrity check", isPresented: .init(
477 get: { integrityReport != nil }, set: { if !$0 { integrityReport = nil } })
478 ) {
479 Button("OK", role: .cancel) { integrityReport = nil }
480 } message: {
481 Text(integrityReport ?? "")
482 }
483 // "Convert to thread" confirms, because it reorganises the chat for
484 // EVERYONE and, in v1, can't be undone. One wording for all three
485 // platforms (ChatFeedModel.convertConfirm).
486 .alert("Convert to Thread", isPresented: .init(
487 get: { convertTarget != nil }, set: { if !$0 { convertTarget = nil } }),
488 presenting: convertTarget
489 ) { target in
490 Button("Cancel", role: .cancel) { convertTarget = nil }
491 Button("Move") {
492 // A conversion that can't reach the server has to SAY so:
493 // nothing about a failed one is visible — the chain stays where
494 // it was, exactly as if nobody had converted anything — so
495 // after confirming a one-way act the reader would be left
496 // believing it happened. The inline note bar, never a toast.
497 if !model.convertToThread(root: target.root, ids: target.ids) {
498 showFeedNote("Those replies didn't move — you're offline.")
499 }
500 convertTarget = nil
501 }
502 } message: { target in
503 Text(ChatFeedModel.convertConfirm(target.ids.count))
504 }
505 .task(id: instance?.id) {
506 if let instance { await NativeFeedModelCache.shared.pollIntegrity(for: instance) }
507 }
508 .onAppear {
509 model.start() // idempotent — a parked model is already live
510 frames.isActive = isActive
511 installSwipeMonitor()
512 installDragMonitor()
513 registerForDrops()
514 installProjector()
515 }
516 .onChange(of: isActive) { _, active in
517 frames.isActive = active
518 // Release or claim the composer with the pane: a hidden pane's
519 // stale focused=true would keep async-stealing first responder
520 // from the visible pane's field (the old @FocusState dropped
521 // itself automatically; the representable's @State cannot).
522 // With a docked thread open, the claim goes to ITS bar — the
523 // surface the reader was working in.
524 let paneOpen = threadDocked && threadRootID != nil
525 composerFocused = active && !paneOpen
526 paneFocused = active && paneOpen
527 }
528 .onDisappear {
529 // Deliberately NOT model.stop(): parked models keep their socket
530 // (the FeedWebCache rule); the cache's eviction stops them.
531 if let swipeMonitor { NSEvent.removeMonitor(swipeMonitor) }
532 swipeMonitor = nil
533 if let dragMonitor { NSEvent.removeMonitor(dragMonitor) }
534 dragMonitor = nil
535 frames.dragging = false
536 // One last capture at teardown, belt to the throttle's braces —
537 // captureAnchor no-ops if the row frames have already been
538 // cleared by the children's own teardown (order isn't
539 // guaranteed), in which case the throttled capture stands.
540 frames.anchorDebounce?.cancel()
541 frames.anchorDebounce = nil
542 captureAnchor()
543 if let key = instance?.origin {
544 PaneDropRouter.shared.unregister(key, token: dropToken)
545 }
546 }
547 // Drop-to-attach is NOT a per-feed .onDrop: every visited space stays
548 // alive in RootView's ZStack, and drop-region resolution picks the
549 // topmost region regardless of opacity/allowsHitTesting — a hidden
550 // pane's region either stole the drop (staging into the wrong space's
551 // composer) or, gated, dead-ended the drag with no + badge at all.
552 // Instead RootView owns the ONE drop surface for the whole window
553 // and PaneDropRouter (top of file) hands each drop to whichever
554 // registered feed belongs to the pane selected at drag time.
555 // @mentions: the roster index bubbles style "@Name" runs against
556 // (feed rows and the reply-thread view alike), and the chip-tap
557 // interception — a MentionLink URL opens the member's profile card;
558 // every real link keeps the system route.
559 .environment(\.mentionIndex, model.mentionIndex)
560 .environment(\.openURL, OpenURLAction { url in
561 guard let uid = MentionLink.uid(from: url), let onMemberProfile
562 else { return .systemAction }
563 onMemberProfile(uid)
564 return .handled
565 })
566 }
567
568 // The feed column — the scrolling transcript, the channel composer inset
569 // and the top integrity banner — split out of body so the docked thread
570 // pane can sit BESIDE it (spec §6). The paneFrame tracking stays on this
571 // column: the times-gutter swipe must never claim scrolls made over the
572 // docked pane's own lists.
573 private var feedAndComposer: some View {
574 ScrollViewReader { proxy in
575 // A ScrollView + LazyVStack, NOT a List: List's NSTableView-backed
576 // rows clip their bounds, which beheaded the wave animation — stack
577 // children can overdraw their row freely.
578 ScrollView {
579 LazyVStack(spacing: 0) {
580 if let appIntro, model.ready, model.exhausted || model.feedMessages.isEmpty {
581 Text(appIntro)
582 .font(.system(size: 13))
583 .foregroundStyle(.secondary)
584 .multilineTextAlignment(.center)
585 .frame(maxWidth: 440)
586 .padding(.vertical, 28)
587 .padding(.horizontal, 16)
588 .frame(maxWidth: .infinity)
589 }
590 // The MAIN FEED list (model.feedMessages): the fold minus
591 // thread-only replies — they live in the docked pane.
592 // `previous` is read off this same list, so sender-run
593 // grouping and day separators are computed over what's
594 // visible: two feed bubbles with a hidden reply between
595 // them in the fold still read as neighbours.
596 ForEach(Array(model.feedMessages.enumerated()), id: \.element.id) { i, m in
597 let previous = i > 0 ? model.feedMessages[i - 1] : nil
598 FeedRow(key: rowKey(m, previous: previous),
599 content: { AnyView(row(m, previous: previous, proxy: proxy)) })
600 .equatable()
601 .id(m.id)
602 // Deleted messages implode — a quick shrink-and-
603 // fade where the bubble stood.
604 .transition(.opacity) // plain fade — a scale-implosion removal blanked the whole feed on inset changes
605 // Geometry probe for "is this row already in view?"
606 // (jump-without-scroll). Writes to the frame store,
607 // never to state.
608 .background(GeometryReader { g in
609 // Direct side-effect write on every layout pass —
610 // NOT `.onChange(of: CGRect)`. Observing this
611 // per-frame value fired SwiftUI's "onChange(of:
612 // CGRect) action tried to update multiple times
613 // per frame" churn (×every visible row) during a
614 // window maximise/restore (#899). The store has
615 // no @Published, so the write invalidates nothing
616 // (see FrameStore) — this keeps `rows` just as
617 // current, minus the observation.
618 let _ = (frames.rows[m.id] = g.frame(in: .named("feed")))
619 Color.clear
620 .onDisappear { frames.rows.removeValue(forKey: m.id) }
621 })
622 // Scroll-back: the oldest row materialising means the
623 // reader is nearing the top — fold the previous page,
624 // then re-anchor it so history grows upward without
625 // the viewport jumping (the web's prepend anchoring).
626 .onAppear {
627 // Fire while the reader is still ~8 rows from
628 // the top: the page splices in off-screen and
629 // the seam is never visible at reading pace.
630 // model.loadOlder self-gates (ready flag +
631 // cooldown), so a prepend can't cascade-load
632 // the whole history. The `settled` gate stops
633 // the opening pin-jump (which transiently
634 // materialises top rows) from prefetching
635 // pages nobody asked for.
636 guard settled, i < 8, model.ready, !model.exhausted else { return }
637 let anchorID = m.id
638 // The trigger row often materialises while still
639 // ABOVE the viewport (lazy lookahead), so its own
640 // position is useless for restoring. Anchor on
641 // whichever row is closest to the viewport's
642 // centre instead — solidly on screen, valid
643 // UnitPoint — and put IT back exactly where it
644 // was, after the prepend has laid out.
645 refreshCentreRef()
646 Task {
647 await model.loadOlder()
648 guard model.feedMessages.first?.id != anchorID else { return }
649 // The reading is taken at the LAST moment the
650 // reader moved the feed (frames.centreRef),
651 // not at the moment this fetch started: a page
652 // is a network round-trip plus a decrypt, and
653 // whatever they scrolled or dragged through it
654 // would otherwise be undone — the feed
655 // scrolling itself back a screenful for no
656 // reason the reader can see.
657 DispatchQueue.main.async {
658 if let ref = frames.centreRef,
659 model.feedMessages.contains(where: { $0.id == ref.rowID }) {
660 proxy.scrollTo(ref.rowID,
661 anchor: UnitPoint(x: 0, y: ref.fraction))
662 } else {
663 proxy.scrollTo(anchorID, anchor: .top)
664 }
665 }
666 }
667 }
668 // Optimistic send (web parity): the copy already shows
669 // instantly and the confirm swaps it in place; once it's
670 // gone slow (5s unconfirmed) the bubble dims, with a
671 // VoiceOver cue so the delay isn't conveyed by opacity
672 // alone.
673 .opacity(m.pending && m.slow ? 0.55 : 1)
674 .accessibilityHint(m.pending ? (m.slow ? "Still sending" : "Sending") : "")
675 }
676 // The web's typing bubble: three bouncing dots, them-side,
677 // living IN the feed (the old composer-inset text line's
678 // background clipped the bubble beneath it).
679 if !model.typing.isEmpty {
680 HStack(spacing: 0) {
681 TypingBubble(theme: theme,
682 label: isGroup ? typingSummary(model.typing) : nil)
683 Spacer(minLength: 48)
684 }
685 .padding(EdgeInsets(top: 8, leading: 12, bottom: 2, trailing: 12))
686 .transition(.opacity)
687 }
688 // The pin target: a fixed-height sentinel BELOW the last
689 // row. scrollTo(lastRow) computes against the row's
690 // ESTIMATED height while it's unmaterialised — a tall
691 // final message lands the feed one message short, and
692 // every retry redoes the same wrong math. The sentinel's
693 // height is exact, so "sentinel at the bottom" is always
694 // truly the bottom.
695 Color.clear.frame(height: 1).id(Self.bottomSentinel)
696 }
697 .padding(.vertical, 6)
698 // Short chats read from the TOP: without this the bottom
699 // anchor bottom-aligns content shorter than the viewport.
700 .frame(minHeight: max(0, viewportHeight), alignment: .top)
701 // Jump-button geometry probe: the CONTAINER's frame, not the
702 // bottom sentinel's — a leaf row gets pruned by LazyVStack
703 // once it's far enough off-screen (see the per-row probe
704 // above, which does exactly that on .onDisappear), so reading
705 // gap off the sentinel would freeze the wrong value the
706 // moment it's culled, well before a real "moderate scroll"
707 // threshold. The VStack's own extent is never culled — SwiftUI
708 // needs it for the scroll math regardless of which rows are
709 // realised — so its maxY always reflects the true content
710 // bottom. State only flips at the threshold crossing, so
711 // scrolling itself never re-renders the feed.
712 .background(GeometryReader { g in
713 let gap = g.frame(in: .named("feed")).maxY - frames.viewport.height
714 Color.clear
715 .onAppear {
716 updateJumpButtonVisible(gap: gap)
717 updateScrollerInset(contentWidth: g.size.width)
718 }
719 .onChange(of: gap) { _, gap in updateJumpButtonVisible(gap: gap) }
720 .onChange(of: g.size.width) { _, w in updateScrollerInset(contentWidth: w) }
721 })
722 }
723 .coordinateSpace(name: "feed")
724 // Every bubble's menu catcher registers itself here as it mounts,
725 // so ⌘T can pop the right one (issue #392).
726 .environment(\.messageMenuScope, menuScope)
727 .background(GeometryReader { g in
728 Color.clear
729 .onAppear {
730 frames.viewport = g.size
731 updateStageWidth(g.size)
732 }
733 .onChange(of: g.size) { _, s in
734 frames.viewport = s
735 updateStageWidth(s)
736 }
737 })
738 // Follow the newest message like the web feed's pin-to-bottom —
739 // but only when the reader is AT the bottom (or it's their own
740 // send). The previous last row still being materialised near the
741 // viewport is the "at bottom" signal; yanking someone out of
742 // scroll-back for every incoming message made busy groups
743 // unreadable. Keyed on the FEED's newest row, not the fold's: a
744 // thread-only reply arriving (hidden here, shown in the pane)
745 // must neither scroll nor pin the transcript.
746 .onChange(of: model.feedMessages.last?.id) { old, id in
747 guard id != nil else { return }
748 let mine = model.feedMessages.last?.mine == true
749 // Near-bottom only when the previous last row is MATERIALISED
750 // near the viewport. Defaulting the unknown case to true
751 // treated a reader deep in history (old row destroyed, no
752 // frame) as at-the-bottom and yanked them down on arrivals.
753 let nearBottom = old == nil || old.flatMap { frames.rows[$0]?.minY }
754 .map { $0 < frames.viewport.height + 150 } ?? false
755 // No materialised rows at all while messages exist = the feed
756 // is showing blank space (the offset outlived a content
757 // shrink) — always re-pin our way out of that.
758 let wedged = frames.rows.isEmpty && !model.feedMessages.isEmpty
759 // A VERIFIED pin, not a one-shot scrollTo: the send shrinks
760 // the composer inset (draft clearing) mid-jump and rows
761 // materialise with real heights, so the content can resize
762 // under a one-shot target and leave the viewport clamped past
763 // the real end — a fully blank feed. pinUntilSettled re-pins
764 // against measured geometry until the last row is truly flush.
765 if mine || nearBottom || wedged { pinUntilSettled(proxy) }
766 }
767 // The typing bubble appearing at the bottom should scroll into
768 // view when the reader is already there.
769 .onChange(of: model.typing.isEmpty) { _, empty in
770 // Never while the reader's hands are on the feed: someone else
771 // starting to type is the weakest reason there is to move the
772 // offset, and it lands under live drags all day in a busy group.
773 // The shared reading (the row's BOTTOM within slack) — the
774 // row's TOP still counted a reader hundreds of points into
775 // scroll-back as "at the bottom" whenever the newest message
776 // was tall. Same correction as the viewport-resize gate above.
777 if !empty, !readerIsDriving(), let last = model.feedMessages.last?.id,
778 FeedGeometry.isNearBottom(lastRow: frames.rows[last],
779 viewportHeight: frames.viewport.height) {
780 proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
781 }
782 }
783 // The composer band is part of this pane's layout, and it moves for
784 // more reasons than the old `staged.isEmpty` gate (one boolean edge)
785 // could see: a staged photo grows it TWICE — the upload box, then
786 // the decoded thumbnail — and a reply banner arming, a draft
787 // wrapping to a second line or the window resizing all move it too.
788 // Every one of those shrinks the scroll viewport, and SwiftUI keeps
789 // the content's offset from the top, so the newest message slides
790 // below the fold behind the band. The photo's SECOND growth had no
791 // re-pin at all: the jump on uploading a photo. The viewport's own
792 // measured height (it is already the inset-reduced height —
793 // safeAreaInset shrinks this ScrollView's frame) catches all of
794 // them.
795 .onChange(of: viewportHeight) { old, new in
796 guard old > 0, new != old, let last = model.feedMessages.last?.id,
797 let f = frames.rows[last] else { return }
798 // Was the reader at the bottom BEFORE the viewport changed? The
799 // rows have not moved yet, so the OLD height is the honest
800 // reading — against the new one, a reader the band has just
801 // covered reads as "scrolled away" and gets left there. The
802 // shared reading (the row's BOTTOM within slack), not the row's
803 // top: for a tall newest message `minY < old + 150` still held
804 // a couple of hundred points into scroll-back, so typing yanked
805 // a reader who had scrolled up a little back down.
806 guard FeedGeometry.isNearBottom(lastRow: f, viewportHeight: old) else { return }
807 frames.resizePinDebounce?.cancel()
808 // The GENTLE correction, never the cold-open loop: that loop's
809 // rescue jolts to the top of loaded history to force
810 // materialisation, which is right for a wedged pane and
811 // catastrophic here — the composer band moves on almost every
812 // keystroke (the typeahead strip opening, re-ranking and
813 // closing; a draft wrapping a line), and each jolt is a
814 // full-height jump under someone who is only typing.
815 // And nothing at all while the feed is still unrevealed: the
816 // open/restore machinery owns the offset until `settled`, and a
817 // band that moves during a restore (a draft rehydrating, a
818 // reply banner re-arming) used to start a bottom pin BESIDE the
819 // restore — two loops scrolling to different targets, the pin
820 // wiping the reader's saved anchor if it verified first.
821 // settleBottomGently's own `settled` guard enforces this.
822 let pin = DispatchWorkItem { settleBottomGently(proxy) }
823 frames.resizePinDebounce = pin
824 DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: pin)
825 }
826 // Opening a chat lands at the newest message — and STAYS there:
827 // one pin isn't enough, because rows materialise with their real
828 // (often taller) heights after the jump and shift the content a
829 // message short. Re-pin until the frame store confirms the last
830 // row is actually flush with the viewport bottom.
831 .onChange(of: model.ready) { _, ready in
832 guard ready else { return }
833 if let anchor = savedAnchor() {
834 restoreUntilSettled(proxy, anchor)
835 } else {
836 pinUntilSettled(proxy)
837 }
838 consumeJump(proxy)
839 }
840 // Search hits land on their message: page history until the
841 // target is loaded, then jump-and-wave.
842 .onChange(of: app.nativeJump) { _, _ in consumeJump(proxy) }
843 // The header kebab's Threads row (a view up — see MacAppState).
844 .onChange(of: app.nativeReplyThreadList) { _, _ in consumeThreadListRequest() }
845 // ⌘R / ⌘T (Message ▸ Reply to / React to Latest Message, issue
846 // #392) — the web feed's two keyboard message verbs, which the Mac
847 // never had. Both act on the surface in FRONT of the reader: the
848 // reply-thread view's messages while it's open, the feed's
849 // otherwise.
850 .onChange(of: app.replyLatestTick) { _, _ in
851 guard isActive, !model.dmClosed,
852 let m = ChatFeedModel.latestReplyTarget(in: hotkeyMessages) else { return }
853 // With the docked pane open the thread's composer IS the
854 // pane's bar — ⌘R just puts the caret there (its fixed reply
855 // already targets the root); the overlay path stages on the
856 // channel bar as before.
857 if threadDocked, threadRootID != nil {
858 paneFocused = true
859 } else {
860 startReply(m)
861 }
862 }
863 // ⇧⌘R (Message ▸ Reply in Thread to Latest Message) — ⌘R's
864 // thread-pane twin, same gate and same "latest" read: the pane's
865 // messages while it's open (so it resolves to the open thread and
866 // just refocuses), the feed's otherwise (opens that message's
867 // thread, root-resolved like the menu verb).
868 .onChange(of: app.replyInThreadLatestTick) { _, _ in
869 guard isActive, !model.dmClosed,
870 let m = ChatFeedModel.latestReplyTarget(in: hotkeyMessages) else { return }
871 replyInThread(m)
872 }
873 .onChange(of: app.reactLatestTick) { _, _ in
874 guard isActive,
875 let m = ChatFeedModel.latestReactionTarget(in: hotkeyMessages) else { return }
876 // The menu hangs off the bubble, so the bubble has to be on
877 // screen — and materialised at all, or there's no catcher to
878 // pop it from. A feed scrolled back into history scrolls first
879 // and pops once the row has landed. (The thread view scrolls
880 // itself and opens at the bottom, so it's exempt.)
881 let scope = threadRootID == nil ? menuScope : threadMenuScope
882 if threadRootID == nil, !rowOnScreen(m.id) {
883 withAnimation { proxy.scrollTo(m.id, anchor: .bottom) }
884 DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
885 scope.popUpMenu(for: m.id)
886 }
887 return
888 }
889 // Never straight from the onChange: popping runs a modal
890 // tracking loop, and this one is still inside a view update.
891 DispatchQueue.main.async { scope.popUpMenu(for: m.id) }
892 }
893 // A Text Size change (⌘+/⌘−, Settings slider) relayouts every row
894 // at once; a shrink can leave the scroll offset clamped past the
895 // new content end — a fully blank feed, the same wedge the
896 // message-arrival handler guards against (and which un-wedged
897 // itself only on the next arrival). Re-pin immediately when the
898 // reader was at the bottom; from deep in history, give layout a
899 // beat and rescue only if the viewport actually came up empty, so
900 // a scrolled-back reader isn't yanked to the newest message.
901 .onChange(of: chatFontSize) { _, _ in
902 // The heavy loop is right here — every row re-lays out at a
903 // new height, which is what its verify rounds exist for — but
904 // the gate is the shared one, tall-newest-row included.
905 let nearBottom = FeedGeometry.isNearBottom(
906 lastRow: model.feedMessages.last.flatMap { frames.rows[$0.id] },
907 viewportHeight: frames.viewport.height)
908 if nearBottom {
909 pinUntilSettled(proxy)
910 return
911 }
912 DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
913 if frames.rows.isEmpty && !model.feedMessages.isEmpty { pinUntilSettled(proxy) }
914 }
915 }
916 .onAppear {
917 // The mount clock starts at the opening appear only (the view
918 // is rebuilt per thread switch, so one appear = one open).
919 if mountSpan == nil, !settled {
920 mountSpan = Perf.begin("feed.mount", slowMs: 500)
921 }
922 // Switching BACK to a thread the reader had scrolled away from
923 // restores their spot instead of pinning to the bottom — the
924 // anchor lives in the model cache (the view and its scroll
925 // offset are rebuilt per thread; the cache survives). Hidden
926 // while the restore lands (one or two verify rounds) so the
927 // switch never shows the position being corrected — the jump
928 // Paul reported. No anchor = the reader left at the bottom =
929 // today's pin, still visible from the first frame.
930 if let anchor = savedAnchor() {
931 if settled { settled = false }
932 restoreUntilSettled(proxy, anchor)
933 } else {
934 pinUntilSettled(proxy)
935 }
936 consumeJump(proxy)
937 // A Threads click made while this feed wasn't mounted (the
938 // chat was showing an app's Interface) waits for this.
939 consumeThreadListRequest()
940 }
941 // Ends the mount span one turn after the reveal commits, so the
942 // revealed frame's layout is measured — cold fetch, pin/restore
943 // verify rounds and all. Also the switcher's signal to drop the
944 // outgoing feed it held over this beat.
945 .onChange(of: settled) { _, isSettled in
946 guard isSettled else { return }
947 onSettled?()
948 guard let span = mountSpan else { return }
949 mountSpan = nil
950 let count = model.messages.count
951 DispatchQueue.main.async { span.end("\(count) messages") }
952 }
953 // Anchor to the bottom BEFORE first layout — a parked/prewarmed
954 // model arrives with messages already folded, and the onAppear pin
955 // races layout and loses (the land-at-top bug, same as iOS).
956 .defaultScrollAnchor(.bottom)
957 // The feed reveals only once history has folded AND the pin has
958 // verifiably landed — a parked chat settles in a frame or two, a
959 // cold one shows the loader; either way the reader finds it
960 // already at the bottom, never mid-snap. The reveal is a hard cut
961 // (no animation): with messages in hand, switching chats must
962 // read as instant, not a cross-fade.
963 .opacity(model.ready && settled ? 1 : 0)
964 .overlay {
965 if !model.ready {
966 ProgressView().controlSize(.small)
967 }
968 }
969 // Jump-to-latest (web parity): bottom-right of the feed, shown
970 // once jumpButtonVisible flips (the sentinel probe above). Sized to
971 // this composer's 28pt +/send discs, not the web's 40px (its
972 // composer circles ARE 40px, so 40 reads proportionate there and
973 // oversized here). Insets: trailing matches bubbleRow's 10pt row
974 // padding — plus the legacy scrollbar's width when one is shown
975 // (scrollerInset) — so the disc's right edge sits exactly on the
976 // bubbles' right edge in both scroller modes; the bottom gap
977 // equals it.
978 .overlay(alignment: .bottomTrailing) {
979 if model.ready && settled && jumpButtonVisible {
980 Button {
981 withAnimation(.easeOut(duration: 0.2)) {
982 proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
983 }
984 } label: {
985 Image(systemName: "chevron.down")
986 .font(.system(size: 12, weight: .semibold))
987 .frame(width: 28, height: 28)
988 .background(.regularMaterial, in: Circle())
989 .overlay(Circle().strokeBorder(.quaternary, lineWidth: 1))
990 }
991 .buttonStyle(.plain)
992 .accessibilityLabel("Jump to latest")
993 .help("Jump to latest") // mouse-hover tooltip (the web button's title=)
994 .padding(.trailing, 10 + scrollerInset)
995 .padding(.bottom, 10)
996 .transition(.opacity)
997 }
998 }
999 }
1000 // The thread view lifts over the FEED pane only (the web's
1001 // .chatapp-feedwrap overlay) — applied before the composer inset, so
1002 // the real composer below stays live, staged to the thread's root.
1003 // Kept as the !threadDocked path only: Mac v1 always docks (§6).
1004 .overlay {
1005 if let rootID = threadRootID, !threadDocked {
1006 threadOverlay(rootID)
1007 .environment(\.messageMenuScope, threadMenuScope)
1008 .transition(.opacity.combined(with: .offset(y: 12)))
1009 }
1010 }
1011 // spacing 0: the default safe-area spacing left a visible dead strip
1012 // between the scrollbar's end (and the jump button's gap) and the
1013 // composer's top edge.
1014 .safeAreaInset(edge: .bottom, spacing: 0) {
1015 // An ended DM (#578): the server refuses new messages, so the
1016 // composer gives way to the closed banner — the one honest action
1017 // left is Reopen.
1018 if model.dmClosed {
1019 dmClosedBanner
1020 } else {
1021 // The material band lives on the input row INSIDE `composer`,
1022 // never on this whole inset: staged previews/banners float
1023 // transparent above it, so the band can't grow and curtain the
1024 // chat (the failure that got the original full-inset .bar slab
1025 // removed).
1026 composer
1027 }
1028 }
1029 // The feed's inline status line — in flow above the transcript, never
1030 // floating (see FeedNoteBar). Nothing to say renders nothing.
1031 .safeAreaInset(edge: .top, spacing: 0) {
1032 FeedNoteBar(feedNote)
1033 }
1034 // Tamper-evidence failure banner (docs/tamper-evidence.md) — only when
1035 // this space fails an integrity check; "What's this?" fills the report
1036 // alert below.
1037 .safeAreaInset(edge: .top, spacing: 0) {
1038 if let instance {
1039 IntegrityBanner(monitor: NativeFeedModelCache.shared.integrityMonitor(for: instance)) {
1040 integrityReport = NativeFeedModelCache.shared
1041 .integrityMonitor(for: instance).reportLines().joined(separator: "\n")
1042 }
1043 }
1044 }
1045 // Track the feed's window frame so the swipe monitor only claims
1046 // scroll events that happen over it (read lazily — see paneFrame).
1047 .trackingFrame(into: paneFrame)
1048 }
1049
1050 private func updateJumpButtonVisible(gap: CGFloat) {
1051 let show = gap > Self.jumpButtonGap
1052 if show != jumpButtonVisible { jumpButtonVisible = show }
1053 }
1054
1055 // Register this feed as its pane's drop receiver for as long as it is
1056 // mounted — visibility is the drop surface's problem, not ours. A
1057 // default-command thread (text-only surface) or an instance-less mount
1058 // (the Debug diff pane) registers nothing, so the drag shows no + badge
1059 // there — same behaviour the per-feed type-list gate used to give.
1060 private func registerForDrops() {
1061 guard defaultCommand == nil, let key = instance?.origin else { return }
1062 // Registered once per mount, so the target is decided AT DROP TIME
1063 // from live state (the closure's @State reads go through SwiftUI's
1064 // storage, not a snapshot): the window zone pre-empts the fields'
1065 // own onStageDrop backstops, so this closure is what decides which
1066 // composer a dropped file lands on.
1067 PaneDropRouter.shared.register(key, token: dropToken) { pb, point in
1068 let target = dropTarget(at: point)
1069 guard stageDrop(pb, into: target) else { return }
1070 // The caret follows the file into the bar it landed on — the same
1071 // move pickFiles makes for the "+" picker, and what keeps Return
1072 // meaning "send this" straight after a drop. Not while the
1073 // Threads list stands, though: it OWNS the caret while it's up
1074 // (openThreadList), and a column you can't Escape out of is a trap.
1075 guard !showThreadList else { return }
1076 switch target {
1077 case .main: composerFocused = true
1078 case .pane: paneFocused = true
1079 }
1080 }
1081 }
1082
1083 // WHERE you dropped decides which composer stages the file — never which
1084 // composer holds the caret. Routing by focus (this closure's first cut)
1085 // meant that with a thread open EVERY drop in the window went to the
1086 // pane, because opening a thread claims the caret: dropping a photo on
1087 // the channel composer put it in the thread (James, Mac, 2026-08-30).
1088 //
1089 // COORDINATE SPACES: `point` and `threadPaneFrame.rect` are both SwiftUI
1090 // `.global` — top-left origin, measured from the window's content view.
1091 // The drop surface does the one AppKit flip (RootView's DropCatcherView /
1092 // PaneDropDelegate); LiveFrame answers in that space by construction.
1093 // Nothing is converted here, and nothing here may assume otherwise.
1094 private func dropTarget(at point: CGPoint) -> ComposerTarget {
1095 // No docked pane on screen → the channel composer, exactly as before.
1096 // The Threads list borrows the SAME column and covers the pane
1097 // opaquely, so while it stands the pane's composer can't be seen: a
1098 // drop over it belongs to the bar the reader can actually look at.
1099 let paneShowing = threadDocked && threadRootID != nil && !showThreadList
1100 let rect = paneShowing ? threadPaneFrame.rect : .zero
1101 let target: ComposerTarget = rect.contains(point) ? .pane : .main
1102 // The whole diagnosis of a future "it went to the wrong box", from
1103 // `log show` alone: the point, the pane it was tested against, and
1104 // where it ended up.
1105 Logger.identity.info("""
1106 drop router: at \(PaneDropRouter.pt(point), privacy: .public) \
1107 pane \(PaneDropRouter.rc(rect), privacy: .public) \
1108 → \(target == .pane ? "pane" : "main", privacy: .public)
1109 """)
1110 return target
1111 }
1112
1113 static let bottomSentinel = "bottom-sentinel"
1114
1115 // ---- per-thread scroll memory -------------------------------------------
1116 // The anchor is the row nearest the viewport's centre plus its fractional
1117 // travel — the same reference the prepend re-anchor uses — stored in the
1118 // model cache so it outlives this view's per-thread teardown. Captured on
1119 // a debounce after wheel scrolling goes quiet; cleared whenever a bottom
1120 // pin verifiably lands (being at the bottom IS the no-anchor state).
1121
1122 private func savedAnchor() -> NativeFeedModelCache.FeedScrollAnchor? {
1123 guard let iid = instance?.id else { return nil }
1124 return NativeFeedModelCache.shared.scrollAnchor(instance: iid, thread: threadID)
1125 }
1126
1127 private func storeAnchor(_ anchor: NativeFeedModelCache.FeedScrollAnchor?) {
1128 guard let iid = instance?.id else { return }
1129 NativeFeedModelCache.shared.setScrollAnchor(anchor, instance: iid, thread: threadID)
1130 }
1131
1132 // Read the current position out of the frame store. Near the bottom
1133 // (the newest row materialised by the viewport) counts as AT the bottom —
1134 // clearing the anchor — so a reader hovering a few points up isn't
1135 // "restored" away from arriving messages next time.
1136 private func captureAnchor() {
1137 guard instance != nil, frames.viewport.height > 0, !frames.rows.isEmpty else { return }
1138 if FeedGeometry.isNearBottom(lastRow: model.feedMessages.last.flatMap { frames.rows[$0.id] },
1139 viewportHeight: frames.viewport.height) {
1140 storeAnchor(nil)
1141 return
1142 }
1143 // A reading of nothing (every row off-screen mid-relayout) leaves the
1144 // last good anchor alone rather than clearing it.
1145 if let ref = FeedGeometry.centreRow(rows: frames.rows,
1146 viewportHeight: frames.viewport.height) {
1147 storeAnchor(ref)
1148 }
1149 }
1150
1151 // Put the saved anchor's row back where it was and VERIFY it landed —
1152 // pinUntilSettled's shape, aimed at a row instead of the bottom. Rows
1153 // materialise with real heights after the first jump, so one round is
1154 // rarely enough. A vanished anchor row (evicted history, a deleted
1155 // message) falls back to the ordinary bottom pin, anchor cleared.
1156 private func restoreUntilSettled(_ proxy: ScrollViewProxy,
1157 _ anchor: NativeFeedModelCache.FeedScrollAnchor,
1158 attempts: Int = 12, generation: Int? = nil) {
1159 guard model.ready else { return }
1160 // The reader got there first (they scrolled, or grabbed a selection,
1161 // while the verify rounds were still landing) — where they are now IS
1162 // the position, so stop correcting toward the old one. Driving implies
1163 // settled, so the feed is already revealed at their spot. Checked with
1164 // the other preconditions, BEFORE the generation bump, so a call that
1165 // does nothing retires nothing.
1166 if readerIsDriving() { return }
1167 // The third loop on this feed, under the same generation discipline as
1168 // the other two — it aims at a ROW while they aim at the bottom, so two
1169 // of them live at once is the worst version of the fight: an arrival
1170 // pin landing mid-restore scrolls to the bottom and, if it verifies
1171 // first, clears the reader's saved anchor on the way past.
1172 let gen: Int
1173 if let generation {
1174 guard generation == frames.pinGeneration else { return }
1175 gen = generation
1176 } else {
1177 frames.pinGeneration &+= 1
1178 gen = frames.pinGeneration
1179 }
1180 guard model.feedMessages.contains(where: { $0.id == anchor.rowID }) else {
1181 storeAnchor(nil)
1182 pinUntilSettled(proxy, generation: gen)
1183 return
1184 }
1185 proxy.scrollTo(anchor.rowID, anchor: UnitPoint(x: 0, y: anchor.fraction))
1186 guard attempts > 0 else {
1187 settled = true // close enough — reveal rather than hide forever
1188 return
1189 }
1190 DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
1191 guard gen == frames.pinGeneration else { return }
1192 if let f = frames.rows[anchor.rowID], frames.viewport.height > 0 {
1193 let denom = frames.viewport.height - f.height
1194 let want = denom > 0 ? anchor.fraction * denom : 0
1195 if abs(f.minY - want) <= 2 {
1196 settled = true
1197 return
1198 }
1199 }
1200 restoreUntilSettled(proxy, anchor, attempts: attempts - 1, generation: gen)
1201 }
1202 }
1203
1204 // A COLD feed stays invisible until the pin is verified flush — the reader
1205 // should find the chat already at the bottom, never watch it snap there.
1206 // A parked one starts settled (see init) so switching chats is instant.
1207 @State private var settled: Bool
1208 // The open's "click → usable feed" measurement, the iOS feed.mount twin:
1209 // begun at the opening appear, ended one turn after `settled` commits so
1210 // the reveal frame's layout is inside it. Same span name — the ledger
1211 // report digests both platforms through one seam.
1212 @State private var mountSpan: Perf.Span?
1213
1214 // Whether the newest row is genuinely resting at the viewport bottom (a
1215 // tall chat pinned) or, for a chat too short to fill the viewport, fully
1216 // visible from the top. This replaces the bare `maxY <= viewport.height + 2`
1217 // verify, which ALSO matched a feed stranded ABOVE the viewport — bug #900:
1218 // on an open, a layout race can leave the scroll offset past the content's
1219 // end, every row with a NEGATIVE maxY (last row maxY ≈ -696), and
1220 // `-696 <= 855` "revealed" empty space beyond the last message (blank until
1221 // you scrolled). A genuinely-pinned last row has a POSITIVE maxY near the
1222 // bottom; a stranded one is negative. (The bottom safe-area inset — the
1223 // composer — means a pinned maxY lands SHORT of viewport.height, so we
1224 // can't require maxY ≈ height; "in the lower half and not below the bottom
1225 // edge" is the robust band for a tall chat.)
1226 // Reads the FEED's rows (feedMessages): a thread-only reply is the fold's
1227 // newest message but has no row here — keying the verify on it would
1228 // never settle, and the feed would stay hidden.
1229 private func lastRowFlush() -> Bool {
1230 guard let id = model.feedMessages.last?.id else { return false }
1231 return FeedGeometry.lastRowIsFlush(
1232 last: frames.rows[id],
1233 first: model.feedMessages.first.flatMap { frames.rows[$0.id] },
1234 viewportHeight: frames.viewport.height)
1235 }
1236
1237 // The GENTLE bottom correction: the feed is revealed, its rows are on
1238 // screen, and something moved the composer band underneath them (a staged
1239 // photo, a reply banner arming, the typeahead strip, a draft wrapping a
1240 // line). All that is owed is "put the newest message back against the
1241 // bottom edge" — a pin and, if the content settles a little differently, at
1242 // most a couple more. Deliberately NOT pinUntilSettled: that loop's rescue
1243 // scrolls to the TOP of loaded history to force a dematerialised stack to
1244 // lay out, which is the right medicine for a wedged pane and, on a band
1245 // that moved 18pt while someone types, a full-height jump for nothing. It
1246 // also never touches `settled` — this is a correction, not a reveal.
1247 // Six rounds — three fast, then three patient (~1.1s in all), which
1248 // outlasts the +0.2s held-recheck the full loop would have run. Running out
1249 // simply STOPS: the pane is revealed and the worst case is the newest
1250 // message sitting a little behind the band until the next trigger. It must
1251 // not escalate to pinUntilSettled, which jolts to the top on its FIRST
1252 // failed verify — a keystroke must never be able to reach that. Only a
1253 // WEDGE escalates, and a wedge has nothing on screen to disturb.
1254 private func settleBottomGently(_ proxy: ScrollViewProxy, attempts: Int = 6,
1255 generation: Int? = nil) {
1256 // `settled` is a HARD precondition, and it is checked BEFORE taking a
1257 // generation: taking one retires any pin loop in flight, and this
1258 // function never reveals — so entering it unrevealed would retire the
1259 // loop that was going to reveal and leave the pane hidden. Preconditions
1260 // first, side effect second.
1261 guard settled, model.ready, let id = model.feedMessages.last?.id else { return }
1262 // The newest correction wins and older ones stand down, so keystroke
1263 // after keystroke can't stack loops that fight each other.
1264 let gen: Int
1265 if let generation {
1266 guard generation == frames.pinGeneration else { return }
1267 gen = generation
1268 } else {
1269 frames.pinGeneration &+= 1
1270 gen = frames.pinGeneration
1271 }
1272 // A WEDGE is not a gentle problem. With no rows materialised,
1273 // scrollTo(sentinel) computes against nothing every round (see
1274 // pinUntilSettled) — only the full loop's jolt to a real row gets the
1275 // offset back, and with a blank pane there is nothing on screen for
1276 // that jolt to disturb. Hand the generation over rather than spending
1277 // a round of no-op pins and leaving the feed blank until the next
1278 // arrival.
1279 guard !feedIsWedged() else { return pinUntilSettled(proxy, generation: gen) }
1280 // Is the reader still AT the bottom? Re-read every round against LIVE
1281 // geometry, not once at the trigger: between the debounce and a
1282 // deferred round this correction can be up to ~a second old, and a
1283 // reader who scrolled away in that window must be left where they are.
1284 // The 150pt slack is wider than any band move, so the row this exists
1285 // to rescue — pushed down behind a grown composer — still qualifies.
1286 guard FeedGeometry.isNearBottom(lastRow: frames.rows[id],
1287 viewportHeight: frames.viewport.height) else { return }
1288 // Never under the reader's hands — but hold the correction rather than
1289 // losing it: a trackpad fling keeps `driving` true through its whole
1290 // momentum tail, and dropping outright left the newest message behind
1291 // the band until something else happened to re-pin.
1292 guard !readerIsDriving() else {
1293 guard attempts > 1 else { return }
1294 DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
1295 settleBottomGently(proxy, attempts: attempts - 1, generation: gen)
1296 }
1297 return
1298 }
1299 proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
1300 guard attempts > 1 else { return }
1301 // Fast while the band is settling, then patient: content still
1302 // reflowing (a card finishing layout in the newest message) wants time,
1303 // not more pins.
1304 let delay = attempts > 4 ? 0.05 : 0.3
1305 DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
1306 guard gen == frames.pinGeneration,
1307 model.feedMessages.last?.id == id, !lastRowFlush() else { return }
1308 settleBottomGently(proxy, attempts: attempts - 1, generation: gen)
1309 }
1310 }
1311
1312 // Pin to the bottom sentinel and VERIFY it landed: the frame store knows
1313 // where the last row actually is, so re-pin (a few frames apart) until its
1314 // bottom sits at the viewport's bottom or the attempts run out. Bails if
1315 // the last message changes underneath (the chat moved on).
1316 //
1317 // The WEDGE needs more than retries: after a send, the composer-inset
1318 // shrink can clamp the scroll offset past the content's end with ZERO rows
1319 // materialised — and against a fully dematerialised lazy stack, the same
1320 // scrollTo(bottom) computes against nothing every round (the blank-feed
1321 // bug's stubborn form). When a verify round finds that state, jolt the
1322 // offset back into range by scrolling to the FIRST message (top) — which
1323 // forces materialisation — then resume pinning to the bottom. And never
1324 // give up while genuinely wedged: park and retry on a slower cadence
1325 // (bounded) instead of leaving a blank pane until the next arrival.
1326 private func pinUntilSettled(_ proxy: ScrollViewProxy, attempts: Int = 12,
1327 rescues: Int = 4, waits: Int = 12,
1328 generation: Int? = nil) {
1329 guard model.ready else { return } // the ready flip re-enters
1330 // A fresh pin supersedes whatever was still in flight: these loops span
1331 // many runloop turns, so without this a resize, an arrival and a
1332 // reveal each ran their own — three scrollTo streams, from three
1333 // different rounds of geometry, on one feed. Taken after the `ready`
1334 // guard, so a call that does nothing retires nothing.
1335 let gen: Int
1336 if let generation {
1337 guard generation == frames.pinGeneration else { return }
1338 gen = generation
1339 } else {
1340 frames.pinGeneration &+= 1
1341 gen = frames.pinGeneration
1342 }
1343 // Never pull the offset out from under a live scroll or selection
1344 // drag: the pin lands mid-gesture, the content jumps, and a drag that
1345 // was selecting text ends up scrolling instead. Wait for the reader's
1346 // hands to come off (bounded — a stuck `dragging` must not wedge the
1347 // feed blank forever) and pin then. A WEDGED feed is exempt: there is
1348 // nothing on screen to be reading.
1349 if readerIsDriving(), !feedIsWedged(), waits > 0 {
1350 DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
1351 pinUntilSettled(proxy, attempts: attempts, rescues: rescues, waits: waits - 1,
1352 generation: gen)
1353 }
1354 return
1355 }
1356 guard let id = model.feedMessages.last?.id else {
1357 settled = true // nothing to pin (empty chat)
1358 return
1359 }
1360 proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
1361 guard attempts > 0 else {
1362 // Out of fast retries. If the newest row is genuinely at the
1363 // bottom, reveal. Otherwise the feed is still stranded (rows above/
1364 // below the viewport, or none materialised) — jolt to a valid
1365 // anchor and retry on a slower cadence (bounded by `rescues`)
1366 // rather than revealing a blank pane.
1367 if rescues > 0, !lastRowFlush(), !model.feedMessages.isEmpty {
1368 if let first = model.feedMessages.first?.id {
1369 proxy.scrollTo(first, anchor: .top)
1370 }
1371 DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
1372 pinUntilSettled(proxy, attempts: 6, rescues: rescues - 1, waits: waits,
1373 generation: gen)
1374 }
1375 } else {
1376 settled = true // flush, or out of rescues — reveal rather than hide forever
1377 }
1378 return
1379 }
1380 DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
1381 guard gen == frames.pinGeneration else { return }
1382 guard model.feedMessages.last?.id == id else {
1383 settled = true
1384 return
1385 }
1386 if lastRowFlush() {
1387 settled = true // genuinely pinned to the bottom — reveal
1388 // A verified bottom IS the no-anchor state: an own-send (or
1389 // arrival) pin that yanked a scrolled-back reader down makes
1390 // any saved anchor stale — the next open should land here,
1391 // at the newest, not back at the old spot.
1392 storeAnchor(nil)
1393 // Flush NOW isn't flush in a beat: a send clears the staged
1394 // strip a moment after this verify passes, the composer inset
1395 // shrinks, and the content shifts under the satisfied pin —
1396 // an image bubble lands below the fold (Paul's screenshot).
1397 // Confirm the pin HELD; if it slipped either way (pushed below
1398 // OR stranded above), keep pinning.
1399 DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
1400 guard gen == frames.pinGeneration,
1401 model.feedMessages.last?.id == id, !lastRowFlush() else { return }
1402 pinUntilSettled(proxy, attempts: max(attempts - 1, 2), rescues: rescues,
1403 waits: waits, generation: gen)
1404 }
1405 return
1406 }
1407 // Not flush: the offset is stranded — rows above/below the viewport
1408 // (the #900 blank: rows materialised but all with negative maxY), or
1409 // none materialised. scrollTo(sentinel) can't pull a past-the-end
1410 // offset back on its own, so jolt to a materialised anchor (the
1411 // first message, at the top) to force a valid offset + real row
1412 // heights, THEN — a beat later, so the top scroll actually takes —
1413 // re-pin the bottom. Recursing with no gap lets the same
1414 // estimated-height overshoot recur every round, which is what made
1415 // the stranded open sit blank for ~a second before the slow rescue
1416 // path finally landed it. (Previously gated on frames.rows.isEmpty,
1417 // so a stranded-but-materialised feed never recovered at all.)
1418 if let first = model.feedMessages.first?.id {
1419 proxy.scrollTo(first, anchor: .top)
1420 DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
1421 pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues, waits: waits,
1422 generation: gen)
1423 }
1424 } else {
1425 pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues, waits: waits,
1426 generation: gen)
1427 }
1428 }
1429 }
1430
1431 // Messages' two-finger reveal: horizontal-dominant scrolls over the feed
1432 // drag the time gutter open (swipe left) or closed (swipe right),
1433 // snapping at release; vertical scrolling passes through untouched. A
1434 // local event monitor rather than an overlay view — an NSView in the way
1435 // would have to fight the scroll view for every vertical event too.
1436 private func installSwipeMonitor() {
1437 let frames = frames
1438 swipeMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in
1439 // Hidden panes (opacity 0 in RootView's ZStack) share this frame —
1440 // only the on-screen pane may claim the swipe.
1441 guard frames.isActive,
1442 let window = event.window,
1443 let content = window.contentView else { return event }
1444 // Window coords are bottom-left; SwiftUI's .global is top-left.
1445 let p = event.locationInWindow
1446 let point = CGPoint(x: p.x, y: content.bounds.height - p.y)
1447 guard paneFrame.rect.contains(point) else { return event }
1448
1449 // Any scroll over this pane captures the position: throttled
1450 // DURING the scroll (so a switch right after a fling still has a
1451 // near-current anchor — the trailing capture alone lost that
1452 // race), plus a trailing debounce for the exact resting spot.
1453 let now = ProcessInfo.processInfo.systemUptime
1454 noteReaderInput(at: now)
1455 if now - frames.lastCaptureAt > 0.15 {
1456 frames.lastCaptureAt = now
1457 captureAnchor()
1458 }
1459 frames.anchorDebounce?.cancel()
1460 let capture = DispatchWorkItem { captureAnchor() }
1461 frames.anchorDebounce = capture
1462 DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: capture)
1463
1464 // The release event arrives with ZERO deltas, so it must be
1465 // handled before any horizontal-dominance check — that guard
1466 // silently ate it and the gutter froze wherever it was left.
1467 if event.phase == .ended || event.phase == .cancelled {
1468 guard draggingTimes else { return event }
1469 draggingTimes = false
1470 // Spring-loaded release: settle open or shut with a gentle bounce.
1471 withAnimation(.spring(response: 0.35, dampingFraction: 0.65)) {
1472 timesReveal = timesReveal > Self.timeGutter / 2 ? Self.timeGutter : 0
1473 }
1474 return nil
1475 }
1476 // Momentum after a horizontal fling: swallow it so the feed
1477 // doesn't sideways-scroll, but don't keep dragging the gutter.
1478 if event.momentumPhase != [] {
1479 return abs(event.scrollingDeltaX) > abs(event.scrollingDeltaY) ? nil : event
1480 }
1481 guard abs(event.scrollingDeltaX) > abs(event.scrollingDeltaY) else { return event }
1482
1483 // Natural scrolling: fingers left → negative deltaX → reveal grows.
1484 draggingTimes = true
1485 timesReveal = min(Self.timeGutter, max(0, timesReveal - event.scrollingDeltaX))
1486 return nil // consumed
1487 }
1488 }
1489
1490 // Selecting text is the other way the reader moves this feed: a drag near
1491 // the pane's edge autoscrolls it, and AppKit does that WITHOUT scroll
1492 // events, so the swipe monitor never sees it. This monitor only watches
1493 // (every event passes through untouched) — it exists so the scroll loops
1494 // know a drag is live and hold off, and so the centre reading stays
1495 // current through an autoscroll.
1496 private func installDragMonitor() {
1497 let frames = frames
1498 dragMonitor = NSEvent.addLocalMonitorForEvents(
1499 matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp]
1500 ) { event in
1501 // The mouse-up ends the drag wherever it happens — a selection
1502 // routinely finishes outside the pane it started in — so it is
1503 // cleared before the pane test, never after it.
1504 if event.type == .leftMouseUp { frames.dragging = false; return event }
1505 guard frames.isActive,
1506 let window = event.window,
1507 let content = window.contentView else { return event }
1508 let p = event.locationInWindow
1509 let point = CGPoint(x: p.x, y: content.bounds.height - p.y)
1510 guard paneFrame.rect.contains(point) else { return event }
1511 if event.type == .leftMouseDown { frames.dragging = true }
1512 noteReaderInput(at: ProcessInfo.processInfo.systemUptime)
1513 return event
1514 }
1515 }
1516
1517 // One stamp + one fresh reading, from whichever monitor saw the input.
1518 private func noteReaderInput(at now: TimeInterval) {
1519 frames.lastInputAt = now
1520 refreshCentreRef()
1521 }
1522
1523 // Re-read where the reader is looking. A reading of nothing (mid-relayout,
1524 // no row fully on screen) keeps the last good one rather than blanking it.
1525 private func refreshCentreRef() {
1526 if let ref = FeedGeometry.centreRow(rows: frames.rows,
1527 viewportHeight: frames.viewport.height) {
1528 frames.centreRef = ref
1529 }
1530 }
1531
1532 // True while the reader is working the feed — a scroll or a drag in the
1533 // last beat. Every loop below that moves the offset defers to it.
1534 // Only once the feed is SETTLED (on screen, and the reader's): while it's
1535 // still hidden behind the opening pin, nothing they do is aimed at it.
1536 private func readerIsDriving() -> Bool {
1537 guard settled else { return false }
1538 if frames.dragging {
1539 // Trust the hardware over our own bookkeeping: a mouse-up can be
1540 // swallowed (a menu, a drag ending in another app), and a stuck
1541 // "dragging" would hold the feed's own corrections off forever.
1542 if NSEvent.pressedMouseButtons & 1 != 0 { return true }
1543 frames.dragging = false
1544 }
1545 return ProcessInfo.processInfo.systemUptime - frames.lastInputAt < 0.25
1546 }
1547
1548 // A blank pane — the offset outlived a content shrink, no row materialised
1549 // — always wins the offset back: there is nothing on screen for the reader
1550 // to be reading, so the rescue runs whatever their hands are doing.
1551 private func feedIsWedged() -> Bool {
1552 frames.rows.isEmpty && !model.feedMessages.isEmpty
1553 }
1554
1555 // Bubble row with run-grouping, mirroring the web feed: the sender name
1556 // shows once per run (groups only), and bubbles inside a run sit tighter
1557 // than bubbles that start one.
1558 // One Equatable key per feed row. SwiftUI can't prove our rows unchanged
1559 // on its own — each body pass hands MessageBubble ~nine fresh closures —
1560 // so ANY model tick (someone typing, a sender name arriving, status
1561 // flips) re-ran every visible bubble's body: seconds of main-thread
1562 // render work around activation (issue #1071, the "hangs when I load
1563 // in" bug; ClickLab wedge samples fingerprinted bubbleRow/MessageBubble).
1564 // FeedRow compares THIS key instead and skips the body when nothing the
1565 // row draws has changed.
1566 // ⚠️ Every render input must be listed — a field missing from the key
1567 // means its changes stop repainting rows.
1568 private struct FeedRowKey: Equatable {
1569 let message: ChatFeedModel.Message
1570 let previous: ChatFeedModel.Message? // grouping: separator + same-sender run
1571 let mutedFold: Bool
1572 let senderName: String
1573 let canEdit: Bool // messageActions' one state read
1574 let isGroup: Bool
1575 let themeName: String
1576 let stageWidth: CGFloat
1577 let timesReveal: CGFloat
1578 let downloadingID: String?
1579 let downloadingProgress: Double?
1580 }
1581
1582 private struct FeedRow: View, Equatable {
1583 let key: FeedRowKey
1584 // A CLOSURE, not an eager AnyView: building the row value (bubble +
1585 // nine closures + actions) per pass costs real time at -Onone even
1586 // when == then skips the body — lazy content means a skipped row
1587 // constructs nothing at all.
1588 let content: () -> AnyView
1589 static func == (a: Self, b: Self) -> Bool { a.key == b.key }
1590 var body: some View { content() }
1591 }
1592
1593 private func rowKey(_ m: ChatFeedModel.Message,
1594 previous: ChatFeedModel.Message?) -> FeedRowKey {
1595 FeedRowKey(message: m, previous: previous,
1596 mutedFold: model.foldsAsMuted(m),
1597 senderName: model.senderName(m),
1598 canEdit: model.canEdit(m),
1599 isGroup: isGroup, themeName: themeName,
1600 stageWidth: stageWidth, timesReveal: timesReveal,
1601 downloadingID: videoDownload?.id,
1602 downloadingProgress: videoDownload?.progress)
1603 }
1604
1605 @ViewBuilder
1606 private func row(_ m: ChatFeedModel.Message, previous: ChatFeedModel.Message?,
1607 proxy: ScrollViewProxy) -> some View {
1608 if m.action {
1609 // A /me-style app action line — centred, muted, no bubble
1610 // (style.css .chatapp-action).
1611 Text(m.text)
1612 .font(.system(size: 13))
1613 .foregroundStyle(.secondary)
1614 .multilineTextAlignment(.center)
1615 .frame(maxWidth: .infinity)
1616 .padding(EdgeInsets(top: 3, leading: 14, bottom: 3, trailing: 14))
1617 } else if model.foldsAsMuted(m) {
1618 mutedRow(m, previous: previous)
1619 } else {
1620 bubbleRow(m, previous: previous, proxy: proxy)
1621 }
1622 }
1623
1624 // A muted member's message (#578): the quiet stub, revealed per-message
1625 // for the session. Keeps the run/separator grouping so revealing it never
1626 // reshuffles the feed around it. Always a "them" row — own messages and
1627 // uid-less legacy ones never fold (foldsAsMuted).
1628 @ViewBuilder
1629 private func mutedRow(_ m: ChatFeedModel.Message, previous: ChatFeedModel.Message?) -> some View {
1630 let separator = timeSeparator(at: m.at, previous: previous?.at)
1631 let startsRun = separator != nil || !ChatFeedModel.sameSender(previous, m)
1632 if let separator {
1633 separatorRow(separator, at: m.at)
1634 }
1635 HStack(spacing: 0) {
1636 MutedMessageStub { model.revealMuted(m.id) }
1637 Spacer(minLength: 48)
1638 }
1639 .padding(EdgeInsets(top: startsRun ? 8 : 2, leading: 14,
1640 bottom: 2, trailing: 10))
1641 }
1642
1643 // The dated/time-only run separator — one builder for bubble and muted rows.
1644 private func separatorRow(_ separator: TimeSeparator, at: Int64) -> some View {
1645 HStack(spacing: 4) {
1646 if separator == .dated {
1647 Text(chatDayLabel(at)).fontWeight(.semibold)
1648 }
1649 Text(chatTime(at))
1650 }
1651 .font(.system(size: 12))
1652 .foregroundStyle(.secondary)
1653 .frame(maxWidth: .infinity)
1654 .padding(.top, 14)
1655 .padding(.bottom, 4)
1656 }
1657
1658 @ViewBuilder
1659 private func bubbleRow(_ m: ChatFeedModel.Message, previous: ChatFeedModel.Message?,
1660 proxy: ScrollViewProxy) -> some View {
1661 // The web's exact grouping (chat.js viewItems): a dated separator when
1662 // the feed opens or the day changes, time-only past a 15-minute gap;
1663 // a same-sender run only continues when no separator intervenes.
1664 let separator = timeSeparator(at: m.at, previous: previous?.at)
1665 let startsRun = separator != nil || !ChatFeedModel.sameSender(previous, m)
1666 if let separator {
1667 separatorRow(separator, at: m.at)
1668 }
1669 HStack(spacing: 0) {
1670 if m.mine { Spacer(minLength: 48) }
1671 MessageBubble(message: m, showSender: isGroup && startsRun,
1672 senderName: model.senderName(m),
1673 theme: theme,
1674 onJump: { target in jump(to: target, proxy: proxy) },
1675 onOpenThread: { root in openThread(root) },
1676 blobSource: { att in model.blobSource(att) },
1677 onImageTap: { att, image, frame in
1678 app.lightbox = LightboxItem(id: att.id, name: att.name,
1679 image: image, sourceFrame: frame)
1680 upgradeLightbox(att, frame: frame)
1681 },
1682 onFileTap: { att in download(att) },
1683 onVideoTap: { att, frame in playVideo(att, from: frame) },
1684 downloading: videoDownload,
1685 actions: messageActions(m, times: true),
1686 dragItem: { att in
1687 guard att.isImage || att.isVideo else { return nil }
1688 return AttachmentDragItem(filename: att.name ?? (att.isImage ? "photo.jpg" : "video.mp4"),
1689 mimeType: att.type, isImage: att.isImage,
1690 fetch: { await model.blobData(att) })
1691 },
1692 unfurl: { url in
1693 let card = await model.unfurl(url)
1694 // The link card loads AFTER the send pin ran and
1695 // grows the bubble below the viewport — if this
1696 // is the last row and the reader is at the
1697 // bottom, re-pin so the posted link stays
1698 // visible (verified, like every other pin).
1699 if card != nil, m.id == model.feedMessages.last?.id,
1700 FeedGeometry.isNearBottom(lastRow: frames.rows[m.id],
1701 viewportHeight: frames.viewport.height) {
1702 // Gentle, not the cold-open loop: a card
1703 // growing one bubble is incremental content
1704 // on a feed that is already revealed, and the
1705 // heavy loop's jolt to the top of history is
1706 // far too big an instrument for it (#1502).
1707 DispatchQueue.main.async { settleBottomGently(proxy) }
1708 }
1709 return card
1710 },
1711 onYouTube: { videoID, start, frame in
1712 app.youtubeLightbox = YouTubeLightboxItem(videoID: videoID, start: start,
1713 sourceFrame: frame)
1714 })
1715 // A FIXED-width stage for the bubble (it aligns to its side
1716 // within), sized as if the gutter were always present. Only
1717 // YOUR bubbles slide (explicit offset) when times reveal —
1718 // their bubbles are already clear of the gutter and never move.
1719 .frame(width: stageWidth,
1720 alignment: m.mine ? .trailing : .leading)
1721 .offset(x: m.mine ? -timesReveal : 0)
1722 if !m.mine { Spacer(minLength: 48) }
1723 }
1724 // The time never participates in layout — it's a trailing overlay that
1725 // slides/fades in place, so row widths are constant and the LazyVStack
1726 // can't re-centre anything as the gutter reveals.
1727 .overlay(alignment: .trailing) {
1728 if timesReveal > 0 {
1729 Text(chatTime(m.at))
1730 .font(.system(size: 11))
1731 .foregroundStyle(.secondary)
1732 .fixedSize()
1733 .offset(x: (Self.timeGutter - timesReveal) * 0.6)
1734 .opacity(timesReveal / Self.timeGutter)
1735 }
1736 }
1737 // Leading 14 aligns the bubbles' left gutter with the chat header's
1738 // (its avatar sits at 14pt).
1739 .padding(EdgeInsets(top: startsRun ? 8 : 2, leading: 14,
1740 bottom: 2, trailing: 10))
1741 .jumpWave(on: m.id == danceID, tick: danceTick)
1742 }
1743
1744 // The list ⌘R / ⌘T read — see the tick handlers above. The feed's
1745 // VISIBLE list when no thread is open: a thread-only reply is never the
1746 // "latest message" in front of the reader.
1747 private var hotkeyMessages: [ChatFeedModel.Message] {
1748 guard let rootID = threadRootID else { return model.feedMessages }
1749 return ChatFeedModel.threadItems(in: model.messages, root: rootID)
1750 }
1751
1752 // Materialised (an unrealised row has no catcher to pop a menu from) and
1753 // wholly inside the viewport, so the menu hangs off a bubble the reader
1754 // can see.
1755 private func rowOnScreen(_ id: Int64) -> Bool {
1756 guard let f = frames.rows[id] else { return false }
1757 return f.minY >= 0 && f.maxY <= frames.viewport.height
1758 }
1759
1760 // The ended-DM banner that takes the composer's place (#578): inline in
1761 // the composer's layout slot — never floating over the feed. A refused
1762 // reopen swaps the line for its reason (their DM door / a blip), the
1763 // web's dm-closed-bar behaviour.
1764 @State private var reopenBusy = false
1765 @State private var reopenNote: String?
1766
1767 private var dmClosedBanner: some View {
1768 HStack(spacing: 10) {
1769 Text(reopenNote ?? "This conversation is closed.")
1770 .font(.system(size: 13))
1771 .foregroundStyle(.secondary)
1772 .frame(maxWidth: .infinity, alignment: .leading)
1773 Button("Reopen") {
1774 guard let onReopenDM else { return }
1775 reopenNote = nil
1776 reopenBusy = true
1777 Task {
1778 switch await onReopenDM() {
1779 case .ok:
1780 break // the flag flips (store + model); the composer returns
1781 case .dmsClosed:
1782 reopenNote = "They're not accepting direct messages right now."
1783 case .failed:
1784 reopenNote = "Couldn't reopen — try again."
1785 }
1786 reopenBusy = false
1787 }
1788 }
1789 .disabled(reopenBusy || onReopenDM == nil)
1790 .accessibilityHint("Reopens this conversation for both of you")
1791 }
1792 .padding(.horizontal, 12)
1793 .padding(.vertical, 10)
1794 .frame(maxWidth: .infinity)
1795 .background(FrostedBand())
1796 }
1797
1798 // The native composer — the first native SEND path: the message is
1799 // encrypted in Swift (ChatFeedModel.send) and framed straight onto the
1800 // native WebSocket. Return sends (desktop shouldSend), ⌥Return newlines.
1801 // The bar itself is MessageComposerBar (file scope, below) — one
1802 // instantiable view, so the docked thread pane can mount a second one
1803 // with its own draft. This is the MAIN mount: user-armed reply/edit
1804 // banners (fixedReply nil), commands on, the command-host panel in the
1805 // slot, and the send/staging/host machinery below supplied as closures.
1806 private var composer: some View {
1807 MessageComposerBar(
1808 model: model,
1809 theme: theme,
1810 fixedReply: nil,
1811 allowCommands: true,
1812 placeholder: defaultCommand?.placeholder ?? "Message",
1813 // The bar's isActive is its may-claim-first-responder gate; with
1814 // the docked thread pane open, the caret belongs to the PANE's
1815 // bar, so the channel bar stands down (closeThread hands focus
1816 // back explicitly). Regaining the APP is the exception: the caret
1817 // comes back HERE even with a thread open, so this bar owns
1818 // focusOnActivate whenever its space is the one on screen.
1819 isActive: isActive && !(threadDocked && threadRootID != nil),
1820 focusOnActivate: isActive,
1821 settled: settled,
1822 formatAsYouType: formatAsYouType,
1823 roster: roster,
1824 myUserId: myUserId,
1825 hasBot: hasBot,
1826 appCommands: appCommands,
1827 hostRows: hostRows,
1828 mentionAvatar: mentionAvatar,
1829 draft: $draft,
1830 composerFocused: $composerFocused,
1831 controller: composerController,
1832 staged: staged,
1833 sendQueued: sendQueued,
1834 replyTo: replyTo,
1835 editingID: editingID,
1836 routeNotice: routeNotice,
1837 busyText: busyText,
1838 onSend: sendDraft,
1839 // The "+" and paste/drop staging are gone in a default-command
1840 // thread (To-do) — text-only surface, matching the web and iOS.
1841 onPickFiles: defaultCommand == nil ? { pickFiles(into: .main) } : nil,
1842 onUnstage: { s in
1843 staged.removeAll { $0.id == s.id }
1844 fireQueuedSend() // cancels or releases a queued send
1845 },
1846 onCancelReply: cancelReply,
1847 onCancelEdit: cancelEdit,
1848 onDraftChanged: { text in
1849 // The typing beats: alive while there's a draft, an immediate
1850 // clear when it empties (edits don't announce themselves).
1851 model.typingBeat(active: !text.isEmpty && editingID == nil)
1852 // A "/" draft pre-warms the command host, so the page has booted
1853 // by the time Enter runs the command (a cold page queues it).
1854 if text.first == "/", commandHost == nil { ensureCommandHost() }
1855 },
1856 onEscapeFallback: {
1857 // An open command panel is the most recently summoned layer —
1858 // Escape peels it (after the bar's own typeahead) before
1859 // reply/edit below (the page posts {panel: null} back, which
1860 // collapses the frame, so the panel's onClose hook always runs).
1861 if panelVisible {
1862 commandHost?.dismissPanel()
1863 return true
1864 }
1865 // This bar's OWN armed layer next — exactly what its banner
1866 // ✕ does. Handled here as well as by the ✕'s cancelAction so
1867 // the result doesn't hinge on AppKit's key-equivalent-vs-
1868 // keyDown order (whichever fires first consumes the key).
1869 if editingID != nil {
1870 cancelEdit()
1871 return true
1872 }
1873 if replyTo != nil {
1874 cancelReply()
1875 return true
1876 }
1877 // Nothing armed: a no-op, as before. The channel bar never
1878 // closes the docked pane — that's the pane bar's Escape, or
1879 // its ✕ — so a reflex Escape here can't take the thread away.
1880 return false
1881 },
1882 ownsCancelKey: !paneFocused,
1883 onEditLatest: {
1884 // ↑ edits the latest own message IN THE FEED — a thread
1885 // reply of mine is edited from the pane (its own ↑).
1886 guard let last = model.feedMessages.last(where: { $0.mine && !$0.action })
1887 else { return false }
1888 startEdit(last)
1889 return true
1890 },
1891 // Paste an image (or a copied file) straight onto the draft — it
1892 // stages as an upload, like the web composer's paste; a file DRAG
1893 // landing on the field stages too. Absent in a default-command
1894 // thread (text-only surface), so a text paste stays a plain paste.
1895 onStagePaste: defaultCommand == nil ? { stagePasteboard($0) } : nil,
1896 onStageDrop: defaultCommand == nil ? { stageDrop($0) } : nil,
1897 panel: commandPanel
1898 )
1899 }
1900
1901 // A command's results panel (the command-host webview — the page draws its
1902 // own header + ✕): in-flow above the composer's banners (the bar's panel
1903 // slot), occupying layout like the web's .cmd-panel, sized by the page's
1904 // measure. Mounted PERMANENTLY at height 0 once the host exists, for three
1905 // reasons: revealing by height (not insertion) animates as a real ease-up
1906 // instead of SwiftUI's insertion fade; the page always has the pane's true
1907 // width, so its height measure is right the first time (no post-reveal
1908 // jump); and an attached webview keeps its rendering callbacks live.
1909 @ViewBuilder private var commandPanel: some View {
1910 if let host = commandHost {
1911 // The command-host page suppresses its own border/shadow (the
1912 // webview is edge-to-edge there) — this frame is the panel's
1913 // chrome: a hairline and a subtle lift, matching the web's
1914 // .cmd-panel. Both fade with visibility so the parked 0-height
1915 // frame leaves no ghost line above the composer.
1916 CommandPanelWebView(host: host)
1917 .frame(height: panelVisible ? panelHeight : 0)
1918 .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
1919 .overlay(
1920 RoundedRectangle(cornerRadius: 10, style: .continuous)
1921 .strokeBorder(.primary.opacity(panelVisible ? 0.08 : 0), lineWidth: 1)
1922 )
1923 .shadow(color: .black.opacity(panelVisible ? 0.10 : 0), radius: 7, y: 2)
1924 .padding(.horizontal, 8)
1925 .allowsHitTesting(panelVisible)
1926 .accessibilityHidden(!panelVisible)
1927 }
1928 }
1929
1930 private var hasContent: Bool {
1931 !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !staged.isEmpty
1932 }
1933
1934 private var uploadsInFlight: Bool {
1935 staged.contains { $0.attachment == nil }
1936 }
1937
1938 // Show (or clear, with nil) the default-command push-back. Every showing
1939 // bumps the generation, so an in-flight clear timer from an EARLIER
1940 // showing — even of the identical string — can never wipe this one early.
1941 private func setRouteNotice(_ text: String?) {
1942 routeNoticeGen += 1
1943 routeNotice = text
1944 guard text != nil else { return }
1945 let gen = routeNoticeGen
1946 DispatchQueue.main.asyncAfter(deadline: .now() + 8) {
1947 if routeNoticeGen == gen { routeNotice = nil }
1948 }
1949 }
1950
1951 // The thread's command host, created on first use (and pre-warmed the
1952 // moment a draft turns into a "/" — see onChange(of: draft)). Re-points
1953 // `sink` at THIS mount every call, like the model-cache re-point pattern,
1954 // so a revisited thread's cached host talks to the pane on screen.
1955 // The app-host projection queries (docs/native-apps.md phase 5): the
1956 // model asks the thread's command host to project app events it has no
1957 // built-in projection for. Lazy — the host webview only spins up when a
1958 // thread actually contains such an event (or a command is typed).
1959 private func installProjector() {
1960 guard let instance else { return }
1961 let thread = thread, myUserId = myUserId, ikB64 = instanceKeyB64
1962 model.projector = { eventJSON, group in
1963 let host = CommandHostCache.shared.host(for: instance, thread: thread,
1964 myUserId: myUserId, instanceKeyB64: ikB64)
1965 return await host.projectEvent(eventJSON, group: group)
1966 }
1967 }
1968
1969 @discardableResult // the "/" pre-warm wants the side effect, not the host
1970 private func ensureCommandHost() -> CommandHost? {
1971 guard let instance else { return nil }
1972 let host = commandHost ?? CommandHostCache.shared.host(
1973 for: instance, thread: thread, myUserId: myUserId, instanceKeyB64: instanceKeyB64)
1974 if commandHost == nil { commandHost = host }
1975 host.sink = { event in
1976 switch event {
1977 case .panel(_, let height, let animate): // the web panel draws its own header
1978 // The app's call (spec.animate): ease the reveal, or appear
1979 // instantly. The web panel skips its in-page ease in command-
1980 // host mode, so this is the ONE animation either way.
1981 panelAnimates = animate
1982 withAnimation(animate ? .easeOut(duration: 0.22) : nil) {
1983 panelHeight = min(height, 340)
1984 panelVisible = true
1985 }
1986 case .panelClosed:
1987 withAnimation(panelAnimates ? .easeIn(duration: 0.2) : nil) {
1988 panelVisible = false
1989 }
1990 case .panelHeight(let h):
1991 // Late re-measures (fonts, images) ease too — an abrupt snap
1992 // here read as a "jump" after the reveal.
1993 if panelVisible {
1994 withAnimation(panelAnimates ? .easeInOut(duration: 0.18) : nil) {
1995 panelHeight = min(h, 340)
1996 }
1997 }
1998 case .composerInsert(let text):
1999 // A panel acting on the composer (an /emoji tap) — inserted at
2000 // the caret, like the web's insertAtCaret.
2001 composerController.insert(text, replacingUTF16: nil)
2002 composerFocused = true
2003 case .notice(let from, let text):
2004 // A command's private "only visible to you" reply — the same
2005 // inline slot the default-command push-back uses.
2006 setRouteNotice(from.isEmpty ? text : "\(from) — \(text)")
2007 case .commands(let rows):
2008 // The thread's live command catalogue landed — the forwarding
2009 // decision follows it from here, and the composer bar re-ranks
2010 // its typeahead on the rows change (onChange(of: hostRows)).
2011 hostRows = rows
2012 }
2013 }
2014 // Cross-thread command routing (docs/native-apps.md phase 3): the page
2015 // asks for one narrow thread key ({needKey}) or an app's personal
2016 // thread ({needAppThread}); answer from what THIS member already
2017 // holds — never the private key.
2018 let origin = instance.origin
2019 host.keyProvider = { tid in
2020 guard let e = SharedThreadKeys.lookup(threadID: tid), e.origin == origin else { return nil }
2021 return e.key.base64urlEncodedString()
2022 }
2023 host.appThreadProvider = { [weak store] app, title in
2024 guard let store, let id = await store.openOrCreateAppThread(app: app, title: title)
2025 else { return nil }
2026 if let key = store.threads.first(where: { $0.id == id })?.threadKeyB64, !key.isEmpty {
2027 return (id, key)
2028 }
2029 return nil
2030 }
2031 if !host.commandRows.isEmpty, hostRows.isEmpty { hostRows = host.commandRows }
2032 return host
2033 }
2034
2035 // The wire form of the draft: serialized markdown when the field is
2036 // mounted (styled runs become markers — the vector-pinned twin), the raw
2037 // draft otherwise. Commands and routing always read `draft` (the display
2038 // text), mirroring the web's plain/markdown split.
2039 private var wireDraft: String {
2040 // Opted out of format-as-you-type = typed markers are literal and
2041 // must render literally: escape them; only the WYSIWYG formats.
2042 let wire = composerController.serializedMarkdown(escapeLiterals: !formatAsYouType)
2043 return wire.isEmpty ? draft : wire
2044 }
2045
2046 private func sendDraft() {
2047 // An edit replaces the message in place — no attachments/reply staging.
2048 // cancelEdit (not inline clears) so an edit inside an open thread view
2049 // hands the composer back to the thread's staged reply.
2050 if let id = editingID {
2051 if model.edit(id, text: wireDraft) { cancelEdit() }
2052 return
2053 }
2054 guard hasContent else { return }
2055 // Slash commands (only a bare text command — attachments/reply stage a
2056 // normal message). /me broadcasts an action line, /theme applies a
2057 // theme, everything else falls through to a normal send so a bot
2058 // answers it (matching the web).
2059 if staged.isEmpty, replyTo == nil, draft.first == "/" {
2060 switch SlashCommand.parse(draft, meName: model.myScreenName) {
2061 case .me(let line):
2062 if model.sendAction(line) { draft = "" }
2063 return
2064 case .setTheme(let id):
2065 themeName = id
2066 draft = ""
2067 return
2068 case .openThemes:
2069 draft = ""
2070 openThemeSettings()
2071 return
2072 case .integrity:
2073 draft = ""
2074 integrityReport = (model.integrity?.reportLines()).map { $0.joined(separator: "\n") }
2075 ?? "Integrity monitoring isn't active for this space."
2076 return
2077 case .help, .sendAsMessage:
2078 // Not the native floor. The current app-thread's own verbs
2079 // stay native (the default-command block below); everything
2080 // else consults the thread's commandItems() rows: a
2081 // bot-answered command rides to the conversation as a
2082 // message, and every framework-owned command — /emoji, /gif,
2083 // /todo, a custom app's verb, even a typo (the web engine
2084 // answers "Unknown command — try /help" as a private notice
2085 // instead of posting the literal) — forwards to the command
2086 // host. Cleared immediately, like the web composer —
2087 // runCommand consumes a command attempt synchronously (a
2088 // booting page queues it).
2089 let ownVerb = defaultCommand.map {
2090 AppDefaultCommand.routing(draft, spec: $0) != .passthrough
2091 } ?? false
2092 if !ownVerb, let host = ensureCommandHost(),
2093 CommandRouting.route(draft, rows: hostRows.isEmpty ? host.commandRows : hostRows) == .host {
2094 cmdLog.notice("forwarding to the command host: \(draft, privacy: .public)")
2095 host.run(draft)
2096 draft = "" // onChange(of: draft) ends the typing beat
2097 return
2098 }
2099 break // fall through — a bot answers /help and bot commands
2100 }
2101 }
2102 // Default-command thread (To-do, Reminders): plain text — or one of the
2103 // app's own verbs — becomes the app event instead of a chat message
2104 // nobody reads; a bare verb is consumed, never posted. Async because
2105 // Reminders schedules server-side first; the sync routing() pre-check
2106 // decides whether the draft is ours at all (passthrough → normal send).
2107 // The routing contract is shared with iOS via routeDefaultCommand.
2108 if staged.isEmpty, replyTo == nil, let dc = defaultCommand,
2109 AppDefaultCommand.routing(draft, spec: dc) != .passthrough {
2110 // One routing in flight at a time: the draft deliberately stays
2111 // visible during the schedule round-trip, so a second Enter must
2112 // not double-create the reminder.
2113 guard !dcRouting else { return }
2114 dcRouting = true
2115 let text = draft
2116 Task { @MainActor in
2117 defer { dcRouting = false }
2118 switch await model.routeDefaultCommand(text, spec: dc) {
2119 case .sent:
2120 // Clear only what we sent — typing during the schedule
2121 // round-trip must never be wiped by a stale completion.
2122 if draft == text { draft = "" }
2123 setRouteNotice(nil) // a success retires any stale push-back
2124 case .rejected(let prompt):
2125 // The app pushing back ("when should I remind you?") — the
2126 // draft stays put to fix; this line is the instruction.
2127 setRouteNotice(prompt)
2128 case .kept, .notHandled:
2129 break // draft stays put (down socket)
2130 }
2131 }
2132 return
2133 }
2134 // Mid-upload, the send queues instead of blocking (web PR #269): the
2135 // button spins and fireQueuedSend releases it when the batch resolves.
2136 guard !uploadsInFlight else {
2137 sendQueued = true
2138 return
2139 }
2140 // Only clear once the send reaches the socket — a down socket keeps the
2141 // draft to resend, never silently drops it (the web's rule).
2142 if model.send(wireDraft, attachments: staged.compactMap(\.attachment), replyTo: replyTo) {
2143 draft = ""
2144 staged = []
2145 // Overlay model only: sending from an open thread view keeps it
2146 // open and re-stages the reply to its root, like iMessage (web
2147 // parity). Docked, the channel bar stays on the channel — the
2148 // pane's own composer carries the thread (sendPaneDraft).
2149 if threadRootID != nil, !threadDocked { stageThreadReply() } else { replyTo = nil }
2150 sendQueued = false
2151 }
2152 }
2153
2154 // /themes (or a bare/unknown /theme) opens the Settings window, where the
2155 // theme picker lives — the native equivalent of the web's picker sheet.
2156 private func openThemeSettings() {
2157 NSApp.activate(ignoringOtherApps: true)
2158 if NSApp.responds(to: Selector(("showSettingsWindow:"))) {
2159 NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
2160 } else {
2161 NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
2162 }
2163 }
2164
2165 // ---- the Threads list (chat.js paintThreadList / the ▤ Threads row) -----
2166
2167 // This chat's threads as a right-hand column — the SAME 340pt dock the
2168 // thread pane uses, with the same chrome, because it is the same slot.
2169 // Rows open a thread, which swaps this column for that thread's pane.
2170 private var threadListPane: some View {
2171 // openThread drops the list itself (one dock), so the column swaps in
2172 // one update rather than sliding out and back in.
2173 ReplyThreadListView(model: model, theme: theme,
2174 onOpen: { root in openThread(root) },
2175 onClose: closeThreadList)
2176 .frame(width: 340)
2177 .background(theme.panel.resolved(scheme))
2178 // The list OWNS the focus while it's up (openThreadList claims it),
2179 // so Escape lands here rather than on the composer it covered —
2180 // whose own Escape means "cancel the reply", not "close this".
2181 // Never destructive: unlike a composer, an index holds nothing you
2182 // could lose, so it needs no draft guard.
2183 .focusable()
2184 .focused($threadListFocused)
2185 // …but it is not a tab stop, so it draws no ring: an index is not
2186 // a control, and macOS would outline the whole 340pt column. The
2187 // rows inside are real buttons and keep their own focus rings, so
2188 // keyboard focus stays visible where it means something.
2189 .focusEffectDisabled()
2190 .onExitCommand { closeThreadList() }
2191 // A complementary landmark beside the feed, never a modal — the
2192 // same standing the thread pane has.
2193 .accessibilityElement(children: .contain)
2194 .accessibilityLabel("Threads")
2195 }
2196
2197 // The list borrows the dock from whatever is in it (see the body) — an
2198 // open thread is left alone, composer and all, and comes back when the
2199 // list closes. It does take the CARET, though: a column you can't dismiss
2200 // from the keyboard is a trap, and the covered composer must not keep
2201 // first responder under it.
2202 private func openThreadList() {
2203 withAnimation(.easeOut(duration: 0.18)) { showThreadList = true }
2204 guard isActive else { return }
2205 paneFocused = false
2206 composerFocused = false
2207 threadListFocused = true
2208 }
2209
2210 private func closeThreadList() {
2211 guard showThreadList else { return }
2212 withAnimation(.easeOut(duration: 0.15)) { showThreadList = false }
2213 // Closing a column must never leave the caret nowhere. Handing the
2214 // dock back to a thread that was already open puts it in that pane's
2215 // composer, where the reader left it; otherwise it goes to the channel
2216 // bar. Both gated on the pane actually being on screen — every visited
2217 // space stays mounted, and a parked one must never steal first
2218 // responder from the visible space.
2219 guard isActive else { return }
2220 if threadRootID != nil, threadDocked {
2221 paneFocused = true
2222 } else {
2223 composerFocused = true
2224 }
2225 }
2226
2227 // The header kebab's Threads row, arriving over MacAppState (the header is
2228 // painted a view up). Consumed once, like a search hit's jump — and only
2229 // by the feed the request names, since every visited space stays mounted.
2230 //
2231 // Also drained on appear, for the same reason consumeJump is: the header
2232 // is drawn in states where this feed isn't mounted at all (a group in
2233 // Interface mode, an app thread's pane), so a click there waits here and
2234 // opens the list when the conversation comes back — rather than being
2235 // silently dropped.
2236 private func consumeThreadListRequest() {
2237 guard isActive, app.nativeReplyThreadList?.thread == threadID else { return }
2238 app.nativeReplyThreadList = nil
2239 openThreadList()
2240 }
2241
2242 // ---- reply-thread view (chat.js openThread/closeThread/stageThreadReply) --
2243
2244 // The lifted thread panel over the dimmed feed pane. Rows reuse the exact
2245 // MessageBubble wiring the feed uses (right-click menu, lightboxes,
2246 // drag-out), so everything keeps working inside the view; reply actions in
2247 // here resolve to the thread's ROOT via the same startReply path. Escape
2248 // closes it through the reply banner's ✕ (.cancelAction → cancelReply).
2249 private func threadOverlay(_ rootID: Int64) -> some View {
2250 ReplyThreadView(model: model, rootID: rootID, isGroup: isGroup,
2251 jumpTarget: threadJump, jumpTick: threadJumpTick,
2252 onClose: closeThread,
2253 onRootPaged: {
2254 // The root just paged in — refresh the banner to its
2255 // live snapshot, unless an edit borrowed the composer.
2256 if editingID == nil, replyTo?.id == rootID { stageThreadReply() }
2257 },
2258 row: { m, showSender in threadBubble(m, showSender: showSender) })
2259 }
2260
2261 // The DOCKED thread pane (spec §6): ReplyThreadView's panel content —
2262 // header/✕, paging note, the root and its replies as one list — as a
2263 // flush right-hand column with its OWN composer at the bottom, the web's
2264 // .chatapp-thread-panel--docked. Root-paging and the empty-thread
2265 // auto-close live in ReplyThreadView and ride along unchanged. No
2266 // onRootPaged: there's no staged banner to refresh — the pane's reply
2267 // target derives fresh at send. Its own menu scope, like the overlay, so
2268 // ⌘T pops the catcher on the surface in front of the reader.
2269 private func threadPane(_ rootID: Int64) -> some View {
2270 VStack(spacing: 0) {
2271 ReplyThreadView(model: model, rootID: rootID, isGroup: isGroup, docked: true,
2272 jumpTarget: threadJump, jumpTick: threadJumpTick,
2273 onClose: closeThread,
2274 row: { m, showSender in
2275 threadBubble(m, showSender: showSender, pane: true)
2276 })
2277 // An ended DM refuses new messages — the pane goes composer-less,
2278 // like the channel bar giving way to the closed banner.
2279 if !model.dmClosed {
2280 Divider()
2281 paneComposer
2282 }
2283 }
2284 .frame(width: 340)
2285 // The web dock's background: var(--panel), not the overlay's frosted
2286 // material — a flush column sits on the window, not over the feed.
2287 .background(theme.panel.resolved(scheme))
2288 .environment(\.messageMenuScope, threadMenuScope)
2289 // The web dock is a complementary landmark ("Reply thread"); the
2290 // panel content inside carries the header/controls.
2291 .accessibilityElement(children: .contain)
2292 .accessibilityLabel("Reply thread")
2293 // Track this column's window frame so a drop can be routed by where
2294 // it landed (dropTarget(at:)) — read lazily, like paneFrame.
2295 .trackingFrame(into: threadPaneFrame)
2296 }
2297
2298 // The pane's composer — a SECOND MessageComposerBar over its own state
2299 // set. fixedReply marks it a pane bar (no reply banner, no ✕ — the
2300 // target is the thread's root, derived fresh at send in sendPaneDraft);
2301 // commands stay off, so "/" text sends verbatim (spec risk 3). The edit
2302 // banner DOES render here (§10.5): editing a thread reply from the pane
2303 // is legitimate, with its ✕ cancelling back to the plain reply state.
2304 private var paneComposer: some View {
2305 MessageComposerBar(
2306 model: model,
2307 theme: theme,
2308 fixedReply: { threadRootID.map { model.threadReplyRef(root: $0) } },
2309 allowCommands: false,
2310 placeholder: "Reply…",
2311 isActive: isActive,
2312 // The pane never takes the caret on app activation — the channel
2313 // bar does (see its focusOnActivate). Opening the thread, replying
2314 // to a pane bubble and a notification landing all still focus here
2315 // explicitly; this is only about coming back to the app.
2316 focusOnActivate: false,
2317 settled: settled,
2318 formatAsYouType: formatAsYouType,
2319 roster: roster,
2320 myUserId: myUserId,
2321 hasBot: hasBot,
2322 appCommands: [],
2323 hostRows: [],
2324 mentionAvatar: mentionAvatar,
2325 draft: $paneDraft,
2326 composerFocused: $paneFocused,
2327 controller: paneController,
2328 staged: paneStaged,
2329 sendQueued: paneSendQueued,
2330 replyTo: nil,
2331 editingID: paneEditingID,
2332 routeNotice: nil,
2333 busyText: paneBusyText,
2334 onSend: sendPaneDraft,
2335 onPickFiles: defaultCommand == nil ? { pickFiles(into: .pane) } : nil,
2336 onUnstage: { s in
2337 paneStaged.removeAll { $0.id == s.id }
2338 firePaneQueuedSend() // cancels or releases a queued send
2339 },
2340 onCancelReply: {}, // never rendered — fixedReply is non-nil
2341 onCancelEdit: cancelPaneEdit,
2342 onDraftChanged: { text in
2343 // Same thread, same typing beats; no "/" host pre-warm — the
2344 // pane runs no commands.
2345 model.typingBeat(active: !text.isEmpty && paneEditingID == nil)
2346 },
2347 onEscapeFallback: {
2348 // The pane's ladder, innermost first (after the bar's own
2349 // typeahead): an armed edit cancels — the same peel as its
2350 // banner ✕, handled here too so it doesn't hinge on AppKit's
2351 // key-equivalent-vs-keyDown order — then typed content makes
2352 // Escape a no-op (keyboard Escape is never destructive of a
2353 // draft or staged files, the channel bar's rule; a deliberate
2354 // close with content is the ✕), and only an EMPTY pane
2355 // composer closes the thread — the overlay's contract.
2356 if paneEditingID != nil {
2357 cancelPaneEdit()
2358 return true
2359 }
2360 if paneHasContent { return false }
2361 closeThread()
2362 return true
2363 },
2364 ownsCancelKey: paneFocused,
2365 onEditLatest: {
2366 // ↑ edits the latest own message IN THIS THREAD (root
2367 // included — it's pinned right here), not the channel's.
2368 guard let rootID = threadRootID,
2369 let last = ChatFeedModel.threadItems(in: model.messages, root: rootID)
2370 .last(where: { $0.mine && !$0.action })
2371 else { return false }
2372 startPaneEdit(last)
2373 return true
2374 },
2375 onStagePaste: defaultCommand == nil ? { stagePasteboard($0, into: .pane) } : nil,
2376 onStageDrop: defaultCommand == nil ? { stageDrop($0, into: .pane) } : nil,
2377 panel: EmptyView()
2378 )
2379 }
2380
2381 private var paneHasContent: Bool {
2382 !paneDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !paneStaged.isEmpty
2383 }
2384
2385 private var paneUploadsInFlight: Bool {
2386 paneStaged.contains { $0.attachment == nil }
2387 }
2388
2389 private var paneWireDraft: String {
2390 let wire = paneController.serializedMarkdown(escapeLiterals: !formatAsYouType)
2391 return wire.isEmpty ? paneDraft : wire
2392 }
2393
2394 // The pane's send: a replyTo reply flagged thread-only (ReplyRef.thread
2395 // — it lives in the thread, never as a main-feed bubble; Slack's model)
2396 // — no command routing, no default-command detour (matching the web pane
2397 // composer). The reply target derives FRESH here (ChatFeedModel
2398 // .threadReplyRef): the root may have paged in since the pane opened, and
2399 // a loaded root snapshots better than the frozen quote.
2400 private func sendPaneDraft() {
2401 // An edit replaces the message in place — same rule as the channel bar.
2402 if let id = paneEditingID {
2403 if model.edit(id, text: paneWireDraft) { cancelPaneEdit() }
2404 return
2405 }
2406 guard paneHasContent, let rootID = threadRootID else { return }
2407 // Mid-upload, the send queues instead of blocking (web PR #269).
2408 guard !paneUploadsInFlight else {
2409 paneSendQueued = true
2410 return
2411 }
2412 // Only clear once the send reaches the socket — a down socket keeps
2413 // the draft to resend, never silently drops it (the web's rule).
2414 if model.send(paneWireDraft, attachments: paneStaged.compactMap(\.attachment),
2415 replyTo: model.threadReplyRef(root: rootID).inThread()) {
2416 paneDraft = ""
2417 paneStaged = []
2418 paneSendQueued = false
2419 paneFocused = true // the next reply continues the thread (web parity)
2420 }
2421 }
2422
2423 private func firePaneQueuedSend() {
2424 guard paneSendQueued else { return }
2425 guard paneHasContent else {
2426 paneSendQueued = false
2427 return
2428 }
2429 if !paneUploadsInFlight {
2430 paneSendQueued = false
2431 sendPaneDraft()
2432 }
2433 }
2434
2435 private func startPaneEdit(_ m: ChatFeedModel.Message) {
2436 paneEditingID = m.id
2437 paneDraft = m.raw.text ?? m.text
2438 paneFocused = true
2439 }
2440
2441 private func cancelPaneEdit() {
2442 paneEditingID = nil
2443 paneDraft = ""
2444 }
2445
2446 // The feed hands the lightbox its downsampled thumbnail so the zoom opens
2447 // instantly; the full-fidelity decode (off-main, cached under its own size
2448 // class) swaps in underneath once it lands — unless the lightbox has
2449 // already moved on or closed. (Animated GIFs decode full in the feed, so
2450 // the swap is a cache hit and a no-op.)
2451 private func upgradeLightbox(_ att: Attachment, frame: CGRect) {
2452 guard let source = model.blobSource(att) else { return }
2453 Task {
2454 guard let full = await BlobImageLoader.shared.image(source),
2455 app.lightbox?.id == att.id else { return }
2456 app.lightbox = LightboxItem(id: att.id, name: att.name,
2457 image: full, sourceFrame: frame)
2458 }
2459 }
2460
2461 // One catalog for the feed and the thread view — reply/edit/delete/copy/
2462 // react (the feed adds Show Times), plus Mute/Unmute-sender (#578) on
2463 // others' uid-carrying messages (uid-less legacy ones can't be keyed).
2464 private func messageActions(_ m: ChatFeedModel.Message, times: Bool) -> MessageActions {
2465 var actions = MessageActions(
2466 canEdit: model.canEdit(m),
2467 onReply: { startReply(m) },
2468 onReplyInThread: { replyInThread(m) },
2469 onEdit: { startEdit(m) },
2470 onDelete: { model.deleteMessage(m.id) },
2471 onCopy: {
2472 NSPasteboard.general.clearContents()
2473 NSPasteboard.general.setString(m.text, forType: .string)
2474 },
2475 onReact: { emoji in model.toggleReaction(m.id, emoji: emoji) })
2476 // "Convert to thread" — only where there IS an inline chain under this
2477 // message to move, which is also what keeps it off a message nobody
2478 // answered and off the pane's bubbles (nothing inline hangs off one).
2479 // Anyone in the chat may convert: it reorganises where messages render,
2480 // it doesn't touch what anyone wrote. The gate is the folded flag (this
2481 // runs for every row on screen); the chain itself is walked at the
2482 // click, so what moves is what's on screen at that moment.
2483 if m.hasInlineChain {
2484 actions.onConvertToThread = {
2485 let ids = ChatFeedModel.inlineChainIDs(in: model.messages, root: m.id)
2486 if !ids.isEmpty { convertTarget = ConvertTarget(root: m.id, ids: ids) }
2487 }
2488 }
2489 if times {
2490 actions.timesShown = timesShown
2491 actions.onToggleTimes = {
2492 withAnimation(.easeOut(duration: 0.25)) {
2493 timesReveal = timesShown ? 0 : Self.timeGutter
2494 }
2495 }
2496 }
2497 if let onMutePerson, !m.mine, let uid = m.raw.uid, !uid.isEmpty {
2498 let muted = model.mutedPeople.contains(uid)
2499 actions.senderMuted = muted
2500 actions.senderLabel = model.senderName(m)
2501 actions.onMutePerson = { onMutePerson(uid, !muted) }
2502 }
2503 // Report to the space's admins (#578) — same gate as the mute: never
2504 // your own messages, and a uid to report (legacy uid-less messages
2505 // name no one).
2506 if let onReport, !m.mine, let uid = m.raw.uid, !uid.isEmpty {
2507 actions.onReport = { onReport(m) }
2508 }
2509 return actions
2510 }
2511
2512 // Routing (spec §10.5): Reply/Edit on a bubble rendered INSIDE the docked
2513 // pane target the PANE's bar — Reply just puts the caret there (its fixed
2514 // reply already targets this thread's root), Edit stages on it. Main-feed
2515 // bubbles keep the channel bar (messageActions above), unchanged.
2516 private func paneMessageActions(_ m: ChatFeedModel.Message) -> MessageActions {
2517 var actions = messageActions(m, times: false)
2518 actions.onReply = { paneFocused = true }
2519 // "Reply in Thread" from inside the pane would resolve to the thread
2520 // already open and do exactly what Reply does here (put the caret in
2521 // the pane's bar) — two rows for one act is noise, so the pane's
2522 // menus omit it. (The web keeps it as a harmless refocus; the Mac
2523 // NSMenu is denser, so it drops.)
2524 actions.onReplyInThread = nil
2525 // Nor "Convert to thread": a bubble in the pane is already in a thread,
2526 // so inlineChainIDs finds nothing under it anyway — except on the
2527 // thread's own ROOT, which can still have inline replies out in the
2528 // feed. Tidying those is a thing you do while looking at the feed.
2529 actions.onConvertToThread = nil
2530 actions.onEdit = { startPaneEdit(m) }
2531 return actions
2532 }
2533
2534 @ViewBuilder
2535 private func threadBubble(_ m: ChatFeedModel.Message, showSender: Bool,
2536 pane: Bool = false) -> some View {
2537 // The muted fold holds inside the thread view too (#578).
2538 if model.foldsAsMuted(m) {
2539 MutedMessageStub { model.revealMuted(m.id) }
2540 } else {
2541 threadMessageBubble(m, showSender: showSender, pane: pane)
2542 }
2543 }
2544
2545 private func threadMessageBubble(_ m: ChatFeedModel.Message, showSender: Bool,
2546 pane: Bool = false) -> some View {
2547 MessageBubble(message: m, showSender: showSender,
2548 senderName: model.senderName(m),
2549 theme: theme,
2550 blobSource: { att in model.blobSource(att) },
2551 onImageTap: { att, image, frame in
2552 app.lightbox = LightboxItem(id: att.id, name: att.name,
2553 image: image, sourceFrame: frame)
2554 upgradeLightbox(att, frame: frame)
2555 },
2556 onFileTap: { att in download(att) },
2557 onVideoTap: { att, frame in playVideo(att, from: frame) },
2558 downloading: videoDownload,
2559 actions: pane ? paneMessageActions(m) : messageActions(m, times: false),
2560 dragItem: { att in
2561 guard att.isImage || att.isVideo else { return nil }
2562 return AttachmentDragItem(filename: att.name ?? (att.isImage ? "photo.jpg" : "video.mp4"),
2563 mimeType: att.type, isImage: att.isImage,
2564 fetch: { await model.blobData(att) })
2565 },
2566 unfurl: { url in await model.unfurl(url) },
2567 onYouTube: { videoID, start, frame in
2568 app.youtubeLightbox = YouTubeLightboxItem(videoID: videoID, start: start,
2569 sourceFrame: frame)
2570 })
2571 // The jump wave, on the thread surface (jumpIntoThread's target).
2572 .jumpWave(on: m.id == threadDanceID, tick: threadDanceTick)
2573 }
2574
2575 // A jump whose target lives only in its thread — loaded, but absent from
2576 // the transcript (mainFeedItems: a thread reply with its root on screen):
2577 // open the docked pane on the root (a no-op when already open there),
2578 // then land on THAT reply inside it and wave it — the feed jump's two
2579 // beats, on the pane. The web lands inside .chatapp-thread-msgs the same
2580 // way, so a search hit or media-sheet jump never dead-ends on "the
2581 // newest reply" instead of the one asked for.
2582 private func jumpIntoThread(_ target: ChatFeedModel.Message) {
2583 guard let root = ChatFeedModel.threadRootID(of: target) else { return }
2584 openThread(root)
2585 threadJump = target.id
2586 threadJumpTick += 1
2587 DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
2588 threadDanceID = target.id
2589 threadDanceTick += 1
2590 }
2591 }
2592
2593 // "Reply in Thread" (thread-pane spec §3.5; web openReplyThread): open the
2594 // message's thread — threadTarget semantics, so a message already IN a
2595 // thread continues it while anything else roots a NEW thread on itself —
2596 // with the caret in the pane's bar (openThread claims that focus). The
2597 // thread already open on that root: just the caret, the same as Reply on
2598 // one of the pane's own bubbles. This verb is the ONLY way a thread ever
2599 // begins; nothing is absorbed into one by replying.
2600 private func replyInThread(_ m: ChatFeedModel.Message) {
2601 guard let rootID = model.threadTarget(for: m).id, rootID > 0 else { return }
2602 if threadRootID == rootID {
2603 if threadDocked { paneFocused = true } else { stageThreadReply() }
2604 } else {
2605 openThread(rootID)
2606 }
2607 }
2608
2609 private func openThread(_ rootID: Int64) {
2610 // Whether the right column is ALREADY standing, read before
2611 // closeThreadList() below clears the flag it depends on.
2612 let columnStanding = showThreadList || threadRootID != nil
2613 // One right dock: a thread takes it back from the Threads list,
2614 // however the thread was opened (a row, a tally, a jump) — the list is
2615 // never left over the pane. Ahead of the guard, so a row for the
2616 // thread ALREADY open still returns you to it, caret and all: the
2617 // guard below leaves early, and nothing after it would run.
2618 if showThreadList {
2619 // Same transaction as the open below where one follows the other,
2620 // so the column's contents cross-fade once instead of racing two
2621 // animations against each other.
2622 closeThreadList()
2623 if threadRootID == rootID { return }
2624 }
2625 guard rootID > 0, threadRootID != rootID else { return }
2626 // A fresh open lands at the newest reply — never on a stale jump
2627 // target from an earlier one (jumpIntoThread re-aims right after).
2628 threadJump = nil
2629 // Switching roots with the pane open: the old thread's half-typed
2630 // draft or in-flight edit must not leak into the new one.
2631 if threadDocked, threadRootID != nil { resetPaneComposer() }
2632 // Animate the ARRIVAL of the column, not a swap inside one that is
2633 // already standing: coming from the list, the cross-fade above is the
2634 // whole transition, and animating the mount as well is what made a
2635 // first-time pane grow into place.
2636 if columnStanding {
2637 threadRootID = rootID
2638 } else {
2639 withAnimation(.easeOut(duration: 0.18)) { threadRootID = rootID }
2640 }
2641 if threadDocked {
2642 // The channel bar stays on the channel — no staging; the pane's
2643 // composer derives its reply fresh at send. Opening puts the
2644 // caret in the pane's bar (its onAppear claims focus on first
2645 // mount; this write covers a root SWITCH, when the bar is
2646 // already mounted — the channel field resigns via onFocusChange).
2647 paneFocused = true
2648 } else {
2649 stageThreadReply()
2650 }
2651 }
2652
2653 // Stage the composer's reply on the thread's root — the view's composer
2654 // contract ("Replying to <root author>"). A reply displaces an in-flight
2655 // edit; desktop focuses the composer, like the web. Flagged thread-only:
2656 // a send from an open thread view lives in the thread (the same flag the
2657 // docked pane's bar sets), never as a main-feed bubble.
2658 private func stageThreadReply() {
2659 guard let rootID = threadRootID else { return }
2660 editingID = nil
2661 replyTo = model.threadReplyRef(root: rootID).inThread()
2662 composerFocused = true
2663 }
2664
2665 private func closeThread() {
2666 guard let rootID = threadRootID else { return }
2667 withAnimation(.easeOut(duration: 0.15)) { threadRootID = nil }
2668 threadJump = nil
2669 if threadDocked {
2670 // The pane's composer dies with the pane (the web's docked pane
2671 // block is destroyed on close); focus returns to the channel bar
2672 // — but only on the pane actually on screen: every visited space
2673 // stays mounted hidden, and the empty-thread auto-close fires on
2674 // parked views too, which must never steal first responder from
2675 // the visible space (the same isActive gate every focus claim
2676 // in this file wears).
2677 resetPaneComposer()
2678 paneFocused = false
2679 if isActive { composerFocused = true }
2680 } else {
2681 // Leaving the overlay drops its staged reply — never an unrelated
2682 // one staged since (an edit clears replyTo itself, so that
2683 // survives). Docked, a reply armed on the root from the MAIN feed
2684 // is the reader's own staging — leave it alone.
2685 if replyTo?.id == rootID { replyTo = nil }
2686 }
2687 }
2688
2689 // ---- the docked pane's composer state (thread-pane spec §6) ------------
2690
2691 private func resetPaneComposer() {
2692 paneDraft = ""
2693 paneStaged = []
2694 paneEditingID = nil
2695 paneSendQueued = false
2696 paneBusyText = nil
2697 }
2698
2699 // ---- reply / edit staging (the composer banners) ----------------------
2700
2701 private func startReply(_ m: ChatFeedModel.Message) {
2702 editingID = nil
2703 // Belt for the parked overlay path. Mac v1 always docks
2704 // (`threadDocked` is a constant true), and docked pane bubbles route
2705 // to the pane's own bar (paneMessageActions), so this branch is
2706 // unreachable today — it exists so the overlay layout, if it's ever
2707 // switched back on here, can't regress the way it would without it:
2708 // under the overlay the channel bar IS the open thread's reply box, so
2709 // a Reply on one of the view's bubbles must stage the root WITH the
2710 // thread flag rather than quote that bubble into the transcript.
2711 if let rootID = threadRootID, !threadDocked, model.threadTarget(for: m).id == rootID {
2712 stageThreadReply()
2713 return
2714 }
2715 // Plain Reply quotes the EXACT message tapped — never a root walk.
2716 // Replying to a reply stacks another quote in the feed; joining a
2717 // thread is the other verb's job ("Reply in Thread"), and only that
2718 // verb's.
2719 replyTo = model.quoteTarget(for: m)
2720 composerFocused = true
2721 }
2722
2723 // While the OVERLAY thread view is open its staged reply IS the view's
2724 // composer state — cancelling it (the banner's ✕, or Escape via
2725 // .cancelAction) means leaving the thread, web parity. Docked, the
2726 // channel bar's banner is always the reader's own staging: cancel just
2727 // cancels, and the pane closes through its own Escape/✕.
2728 private func cancelReply() {
2729 if threadRootID != nil, !threadDocked { closeThread() } else { replyTo = nil }
2730 }
2731
2732 private func startEdit(_ m: ChatFeedModel.Message) {
2733 replyTo = nil
2734 editingID = m.id
2735 draft = m.raw.text ?? m.text
2736 composerFocused = true
2737 }
2738
2739 private func cancelEdit() {
2740 editingID = nil
2741 draft = ""
2742 // Overlay model: an edit inside an open thread view borrowed the
2743 // composer (startEdit clears replyTo); hand it back so the next send
2744 // still continues the thread (web cancelEdit). Docked edits ride the
2745 // pane's own bar (cancelPaneEdit), so nothing to hand back.
2746 if threadRootID != nil, !threadDocked { stageThreadReply() }
2747 }
2748
2749 // A queued send resolves whenever the staged set changes: nothing left to
2750 // send cancels it; the last upload finishing releases it.
2751 private func fireQueuedSend() {
2752 guard sendQueued else { return }
2753 guard hasContent else {
2754 sendQueued = false
2755 return
2756 }
2757 if !uploadsInFlight {
2758 sendQueued = false
2759 sendDraft()
2760 }
2761 }
2762
2763 // Which composer a staged file lands on: the channel bar or the docked
2764 // thread pane's. Threaded through the one staging/upload pipeline below
2765 // so both bars share it — each keeps its own chip list and busy line.
2766 private enum ComposerTarget { case main, pane }
2767
2768 private func stagedBinding(_ target: ComposerTarget) -> Binding<[Staged]> {
2769 target == .main ? $staged : $paneStaged
2770 }
2771
2772 private func setStageBusy(_ text: String?, _ target: ComposerTarget) {
2773 switch target {
2774 case .main: busyText = text
2775 case .pane: paneBusyText = text
2776 }
2777 }
2778
2779 // Stage photos / allowlisted documents: read, downscale+re-encode (images),
2780 // encrypt, upload — all native (ChatFeedModel.uploadImage/uploadFile); the
2781 // chip flips from spinner to icon when its blob id comes back.
2782 private func pickFiles(into target: ComposerTarget = .main) {
2783 let panel = NSOpenPanel()
2784 panel.canChooseFiles = true
2785 panel.allowsMultipleSelection = true
2786 panel.begin { response in
2787 guard response == .OK else { return }
2788 for url in panel.urls { stage(url, into: target) }
2789 switch target {
2790 case .main: composerFocused = true
2791 case .pane: paneFocused = true
2792 }
2793 }
2794 }
2795
2796 // Pasted content (from MacComposerField's paste override): file URLs stage
2797 // directly; raw image data stages as a PNG (like the web composer's
2798 // paste-to-upload). Returns whether anything staged, so the field knows
2799 // the paste was (partly) consumed. Drops ride their own pipeline
2800 // (stageDrop) — a drag can also carry file PROMISES, which a paste never does.
2801 private func stagePasteboard(_ pb: NSPasteboard, into target: ComposerTarget = .main) -> Bool {
2802 if let urls = pb.readObjects(forClasses: [NSURL.self]) as? [URL] {
2803 let files = urls.filter(\.isFileURL)
2804 if !files.isEmpty {
2805 files.forEach { stage($0, into: target) }
2806 return true
2807 }
2808 }
2809 for type in [NSPasteboard.PasteboardType.png, .tiff] {
2810 guard let data = pb.data(forType: type) else { continue }
2811 stage(data: data, name: "pasted.png", type: type == .png ? .png : .tiff,
2812 into: target)
2813 return true
2814 }
2815 return false
2816 }
2817
2818 // ONE pipeline for every AppKit drag (the window drop zone, the composer
2819 // field's backstop): real file URLs stage directly; file promises — how
2820 // most apps vend drags (CleanShot, Photos, browsers) — are received into
2821 // a temp folder and staged from there; raw image data stages as a file.
2822 // URLs win over promises when a drag carries both, so one item never
2823 // stages twice.
2824 @discardableResult
2825 private func stageDrop(_ pb: NSPasteboard, into target: ComposerTarget = .main) -> Bool {
2826 // Verification tap (types, never content) — drags arrive in wildly
2827 // different shapes per source app; this is how the next "my drag
2828 // doesn't work" gets diagnosed without an instrumented build.
2829 Logger.identity.info("drop pasteboard types: \((pb.types ?? []).map(\.rawValue).joined(separator: ", "), privacy: .public)")
2830 if let urls = pb.readObjects(forClasses: [NSURL.self],
2831 options: [.urlReadingFileURLsOnly: true]) as? [URL],
2832 !urls.isEmpty {
2833 urls.forEach { stage($0, into: target) }
2834 return true
2835 }
2836 if let receivers = pb.readObjects(forClasses: [NSFilePromiseReceiver.self]) as? [NSFilePromiseReceiver],
2837 !receivers.isEmpty {
2838 let dir = FileManager.default.temporaryDirectory
2839 .appendingPathComponent("drop-" + UUID().uuidString, isDirectory: true)
2840 try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
2841 for receiver in receivers {
2842 receiver.receivePromisedFiles(atDestination: dir, options: [:],
2843 operationQueue: Self.promiseQueue) { url, error in
2844 if let error {
2845 Logger.identity.error("drop promise failed: \(String(describing: error), privacy: .public)")
2846 return
2847 }
2848 DispatchQueue.main.async { stage(url, into: target) }
2849 }
2850 }
2851 return true
2852 }
2853 for type in [NSPasteboard.PasteboardType.png, .tiff] {
2854 guard let data = pb.data(forType: type) else { continue }
2855 stage(data: data, name: type == .png ? "dropped.png" : "dropped.tiff",
2856 type: type == .png ? .png : .tiff, into: target)
2857 return true
2858 }
2859 return false
2860 }
2861
2862 // Promise fulfilment happens off-main (the source app writes the file);
2863 // staging hops back to main.
2864 private static let promiseQueue: OperationQueue = {
2865 let q = OperationQueue()
2866 q.qualityOfService = .userInitiated
2867 return q
2868 }()
2869
2870 private func stage(_ url: URL, into target: ComposerTarget = .main) {
2871 // A dropped/picked file can be tens of MB — the read happens off main
2872 // (Data(contentsOf:) is a synchronous disk read on the staging frame,
2873 // #465's Mac twin).
2874 Task {
2875 guard let data = await Task.detached(priority: .userInitiated,
2876 operation: { try? Data(contentsOf: url) }).value
2877 else { return }
2878 stage(data: data, name: url.lastPathComponent,
2879 type: UTType(filenameExtension: url.pathExtension), into: target)
2880 }
2881 }
2882
2883 private func stage(data: Data, name: String, type: UTType?,
2884 into target: ComposerTarget = .main) {
2885 // The target bar's chip list (main or the docked pane's) — the whole
2886 // pipeline below reads/writes through this one binding.
2887 let staged = stagedBinding(target)
2888 let isImage = type?.conforms(to: .image) ?? false
2889 let isVideo = type?.conforms(to: .movie) ?? false
2890 // Any file type stages — size is the only gate (matching the web).
2891 if isVideo, data.count > maxVideoUploadBytes {
2892 setStageBusy("\(name): videos are capped at 50 MB", target)
2893 DispatchQueue.main.asyncAfter(deadline: .now() + 3) { setStageBusy(nil, target) }
2894 return
2895 }
2896 let entry = Staged(name: name, isImage: isImage)
2897 staged.wrappedValue.append(entry)
2898 if isImage {
2899 // The chip's thumbnail is a full image decode + downscale — off
2900 // main like the upload's own processing; the chip upgrades from
2901 // ring-only the moment it lands (same shape as the video poster).
2902 let entryID = entry.id
2903 Task {
2904 if let thumb = await Task.detached(priority: .userInitiated,
2905 operation: { processImage(data, maxDim: 288) }).value,
2906 let i = staged.wrappedValue.firstIndex(where: { $0.id == entryID }) {
2907 staged.wrappedValue[i].preview = PlatformImage(data: thumb.bytes)
2908 }
2909 }
2910 }
2911 if isVideo {
2912 // Poster thumbnails arrive async — the chip upgrades from ring-only
2913 // to a real frame the moment extraction lands.
2914 let entryID = entry.id
2915 Task {
2916 if let poster = await videoPosterImage(data: data, mimeType: type?.preferredMIMEType),
2917 let i = staged.wrappedValue.firstIndex(where: { $0.id == entryID }) {
2918 staged.wrappedValue[i].preview = poster
2919 }
2920 }
2921 }
2922 let onProgress: @MainActor (Double) -> Void = { p in
2923 if let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) {
2924 staged.wrappedValue[i].progress = p
2925 }
2926 }
2927 Task {
2928 let att: Attachment?
2929 if isImage {
2930 att = await model.uploadImage(data, name: name, onProgress: onProgress)
2931 } else if isVideo {
2932 att = await model.uploadVideo(data, name: name, mimeType: type?.preferredMIMEType,
2933 onProgress: onProgress)
2934 } else {
2935 att = await model.uploadFile(data, name: name, mimeType: type?.preferredMIMEType,
2936 onProgress: onProgress)
2937 }
2938 if let att, let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) {
2939 staged.wrappedValue[i].attachment = att
2940 } else {
2941 staged.wrappedValue.removeAll { $0.id == entry.id }
2942 if att == nil { setStageBusy("couldn't upload \(name)", target) }
2943 DispatchQueue.main.asyncAfter(deadline: .now() + 3) { setStageBusy(nil, target) }
2944 }
2945 // The batch resolving may release a queued send — on ITS bar.
2946 switch target {
2947 case .main: fireQueuedSend()
2948 case .pane: firePaneQueuedSend()
2949 }
2950 }
2951 }
2952
2953 // A pending open-on-message request (search, or a notification tap's
2954 // stamped message): once this feed is the target and ready, land on the
2955 // message — jump() pages back for it through reveal() when it isn't
2956 // loaded, so a target that can't be reached is explained inline like every
2957 // other jump instead of doing nothing. (This used to carry its own
2958 // hand-rolled walk ending in a bare `return`.)
2959 private func consumeJump(_ proxy: ScrollViewProxy) {
2960 guard let j = app.nativeJump, j.thread == threadID, model.ready else { return }
2961 app.nativeJump = nil
2962 jump(to: j.message, proxy: proxy)
2963 }
2964
2965 private func jump(to id: Int64, proxy: ScrollViewProxy) {
2966 guard let target = model.messages.first(where: { $0.id == id }) else {
2967 // Not loaded at all — older than the page we hold. Page back for
2968 // it rather than doing nothing: this is the ordinary case for the
2969 // quote card above a plain reply on a cold-opened chat.
2970 reveal(id, proxy: proxy)
2971 return
2972 }
2973 // Loaded but not in the transcript = a thread-only reply whose root
2974 // is loaded (mainFeedItems): its bubble is in the pane, so land
2975 // there. (With the root unloaded it degrades to an inline bubble and
2976 // the ordinary scroll below finds it.)
2977 if !model.feedMessages.contains(where: { $0.id == id }) {
2978 jumpIntoThread(target)
2979 return
2980 }
2981 // Already fully in view (with a little headroom for the wave)? Don't
2982 // yank the feed around — just wave where it stands.
2983 if let f = frames.rows[id], f.minY >= 12, f.maxY <= frames.viewport.height - 4 {
2984 danceID = id
2985 danceTick += 1
2986 return
2987 }
2988 // Not visible: scroll it near the top, leaving headroom above so the
2989 // wave's tiptoe stretch + wag never clip at the viewport edge.
2990 withAnimation { proxy.scrollTo(id, anchor: UnitPoint(x: 0, y: 0.06)) }
2991 // Let the scroll land first, then dance.
2992 DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
2993 danceID = id
2994 danceTick += 1
2995 }
2996 }
2997
2998 // Page history back for a jump target that isn't loaded, then land on it —
2999 // the web's revealMessage. Never a silent no-op: the wait says so inline,
3000 // and a walk that can't reach the message says which kind of can't
3001 // (deleted vs. further back than one jump goes). One walk at a time.
3002 //
3003 // Cannot recurse: model.pageBack returning .found means the message IS in
3004 // model.messages, so the jump below takes its loaded path and can't come
3005 // back here.
3006 private func reveal(_ id: Int64, proxy: ScrollViewProxy) {
3007 guard revealing == nil, id > 0 else { return }
3008 revealing = id
3009 showFeedNote("Looking for that message…", sticky: true) // the outcome replaces it
3010 Task {
3011 let reach = await model.pageBack(to: id)
3012 revealing = nil
3013 showFeedNote(ChatFeedModel.jumpReachNote(reach))
3014 guard reach == .found, model.messages.contains(where: { $0.id == id }) else { return }
3015 try? await Task.sleep(nanoseconds: 150_000_000) // let the prepended rows land
3016 jump(to: id, proxy: proxy)
3017 }
3018 }
3019
3020 // The note answers something the reader just did, so it clears itself
3021 // rather than going stale behind a thread switch — the transient-status
3022 // convention busyText/routeNotice already use here. A note that's still
3023 // true while work is in flight ("Looking for that message…") is `sticky`:
3024 // it's replaced by the outcome, never timed out from under a walk that
3025 // runs long. The generation counter keeps every clear cancel-safe, like
3026 // routeNoticeGen.
3027 private static let feedNoteSeconds: TimeInterval = 6
3028 private func showFeedNote(_ text: String?, sticky: Bool = false) {
3029 feedNote = text
3030 feedNoteGen += 1
3031 guard text != nil, !sticky else { return }
3032 let gen = feedNoteGen
3033 DispatchQueue.main.asyncAfter(deadline: .now() + Self.feedNoteSeconds) {
3034 if feedNoteGen == gen { feedNote = nil }
3035 }
3036 }
3037
3038 // Download an attachment: fetch + decrypt, save straight to ~/Downloads
3039 // (unique name, Finder reveal) — the same Safari-like convention the
3040 // webview's WKDownload path uses. Writing plaintext is fine here: it's the
3041 // member's own deliberate save, exactly like the web's file-chip click.
3042 private func download(_ att: Attachment) {
3043 Task {
3044 guard let data = await model.blobData(att) else { return }
3045 save(data, name: att.name ?? "file")
3046 }
3047 }
3048
3049 private func save(_ data: Data, name: String) {
3050 let dir = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
3051 ?? FileManager.default.temporaryDirectory
3052 let dest = uniqueDestination(in: dir, filename: name)
3053 guard (try? data.write(to: dest, options: .atomic)) != nil else { return }
3054 NSWorkspace.shared.activateFileViewerSelecting([dest])
3055 }
3056
3057 // Play a video in the lightbox: decrypt into memory, serve it to AVPlayer
3058 // through MemoryVideoAsset (never touching disk). The web's canPlayType
3059 // probe becomes AVAsset's isPlayable — an unplayable codec falls back to
3060 // a plain download, like the web's download-only card.
3061 private func playVideo(_ att: Attachment, from frame: CGRect) {
3062 Task {
3063 // No filename caption under the composer — the video card grows its
3064 // own progress ring (videoDownload below), which is feedback enough.
3065 // The web's 150ms grace: only a fetch that outlives it grows a ring.
3066 let started = Date()
3067 defer {
3068 busyText = nil
3069 videoDownload = nil
3070 }
3071 let data = await model.blobData(att) { progress in
3072 guard Date().timeIntervalSince(started) > 0.15 else { return }
3073 videoDownload = (att.id, progress)
3074 }
3075 guard let data else {
3076 busyText = "couldn't fetch that video"
3077 try? await Task.sleep(for: .seconds(2))
3078 return
3079 }
3080 let loader = MemoryVideoAsset(data: data, mimeType: att.type)
3081 let asset = loader.makeAsset()
3082 let playable = (try? await asset.load(.isPlayable)) ?? false
3083 guard playable else {
3084 save(data, name: att.name ?? "video")
3085 return
3086 }
3087 app.videoLightbox = VideoLightboxItem(
3088 id: att.id, name: att.name,
3089 player: AVPlayer(playerItem: AVPlayerItem(asset: asset)),
3090 loader: loader,
3091 videoSize: CGSize(width: att.w ?? 0, height: att.h ?? 0),
3092 sourceFrame: frame)
3093 }
3094 }
3095
3096 // "name.ext", then "name 2.ext", … — the Finder convention (the same helper
3097 // as ChatFeedWebView's; folds into one place when the webview retires).
3098 private func uniqueDestination(in dir: URL, filename: String) -> URL {
3099 let fm = FileManager.default
3100 let name = filename.isEmpty ? "download" : filename
3101 var candidate = dir.appendingPathComponent(name)
3102 guard fm.fileExists(atPath: candidate.path) else { return candidate }
3103 let ext = (name as NSString).pathExtension
3104 let base = (name as NSString).deletingPathExtension
3105 var n = 2
3106 repeat {
3107 let next = ext.isEmpty ? "\(base) \(n)" : "\(base) \(n).\(ext)"
3108 candidate = dir.appendingPathComponent(next)
3109 n += 1
3110 } while fm.fileExists(atPath: candidate.path)
3111 return candidate
3112 }
3113}
3114
3115// Attachments staged on the draft: picked → uploading (spinner) → ready.
3116// Several can ride one message, like the web's pending list. File scope:
3117// NativeFeedView owns the upload pipeline that fills these; MessageComposerBar
3118// renders them as chips.
3119struct Staged: Identifiable {
3120 let id = UUID()
3121 let name: String
3122 let isImage: Bool
3123 var preview: PlatformImage? // image picks show a real thumbnail (web-style)
3124 var progress = 0.0 // request-body bytes sent, 0…1
3125 var attachment: Attachment? // nil while the encrypt+upload is in flight
3126}
3127
3128// The chat composer as ONE instantiable view — extracted from NativeFeedView
3129// so the docked reply-thread pane can mount a SECOND composer, with its own
3130// draft and typeahead, beside the main bar. The bar owns only what is
3131// genuinely composer-internal (caret, measured field height, the typeahead);
3132// everything code outside the composer reaches — the draft (startEdit writes
3133// it), focus (pane switches claim/release it), reply/edit staging (thread
3134// open/close, ⌘R), the staged uploads (the window drop zone feeds them), the
3135// command host — stays with the container and arrives as bindings/values/
3136// closures, so a second instance is just a second container state set.
3137//
3138// The two presentation knobs (the thread-pane spec §6):
3139// - fixedReply: nil = the MAIN bar — user-armed reply/edit banners. Non-nil =
3140// a pane bar: the reply target derives fresh at send time (inside the
3141// container's onSend), so no reply banner and no cancel-✕ render here.
3142// - allowCommands: false = no slash-command typeahead — "/" text sends
3143// verbatim (the container's onSend skips command routing and passes no
3144// panel). @mention and :emoji: completion still work.
3145struct MessageComposerBar<Panel: View>: View {
3146 let model: ChatFeedModel
3147 let theme: ChatTheme
3148 let fixedReply: (() -> ReplyRef?)?
3149 let allowCommands: Bool
3150 let placeholder: String
3151 // The container's pane-visibility + reveal gates the focus handlers key
3152 // off — every opened instance's pane stays mounted hidden (RootView's
3153 // ZStack), so only the on-screen bar may claim first responder.
3154 let isActive: Bool
3155 // …and which bar takes the caret when the APP comes forward, which is not
3156 // the same question: with a thread pane open the channel bar stands down
3157 // from claiming on open/reveal (isActive false — the pane owns the caret
3158 // there), yet regaining the app must still land in the CHANNEL's box, not
3159 // the thread's (Paul, 2026-08-29). Exactly one bar per screen says true.
3160 let focusOnActivate: Bool
3161 let settled: Bool
3162 let formatAsYouType: Bool
3163 // Typeahead inputs (the web shell.js typeaheadItems' arguments).
3164 let roster: [RosterEntry]
3165 let myUserId: String
3166 let hasBot: Bool
3167 let appCommands: [(name: String, desc: String)]
3168 let hostRows: [CommandRow]
3169 let mentionAvatar: (String) -> AvatarSource?
3170 // Container-owned composer state (written from outside the bar — see the
3171 // header comment).
3172 @Binding var draft: String
3173 @Binding var composerFocused: Bool
3174 // Programmatic edits (typeahead splice, panel inserts) act on the view via
3175 // the controller, so the caret survives them. Container-held: the command
3176 // host's sink inserts through it.
3177 let controller: MacComposerController
3178 let staged: [Staged]
3179 let sendQueued: Bool
3180 let replyTo: ReplyRef?
3181 let editingID: Int64?
3182 let routeNotice: String?
3183 let busyText: String?
3184 // Actions the container supplies — the send path, the staging pipeline
3185 // and the command host stay with it (they reach thread/instance state).
3186 let onSend: () -> Void
3187 let onPickFiles: (() -> Void)? // nil hides the "+" (default-command threads)
3188 let onUnstage: (Staged) -> Void
3189 let onCancelReply: () -> Void
3190 let onCancelEdit: () -> Void
3191 let onDraftChanged: (String) -> Void // typing beat + "/" host pre-warm
3192 let onEscapeFallback: () -> Bool // the container's next Escape layer (command panel)
3193 // Whether THIS bar's banner ✕ carries the Escape key equivalent
3194 // (.cancelAction). Two bars can show banners at once (channel + docked
3195 // pane), and two cancelAction buttons would race on AppKit's
3196 // key-equivalent walk — so the container hands the key to exactly one:
3197 // the bar whose field has focus, the channel bar when focus is in
3198 // neither (today's single-bar behaviour).
3199 let ownsCancelKey: Bool
3200 // ↑ on an empty draft edits the latest editable message in THIS bar's
3201 // SCOPE — the channel feed, or the docked pane's thread — so the
3202 // container finds it and stages the edit; returns whether it did.
3203 let onEditLatest: () -> Bool
3204 let onStagePaste: ((NSPasteboard) -> Bool)?
3205 let onStageDrop: ((NSPasteboard) -> Bool)?
3206 // The container's command-panel slot, between the typeahead and the
3207 // banners (the main bar mounts the command-host webview here; a
3208 // commands-off bar passes an EmptyView).
3209 let panel: Panel
3210
3211 // ---- bar-owned state ----------------------------------------------------
3212 // The caret as a UTF-16 offset into `draft` — real caret tracking for the
3213 // typeahead (the old TextField could only assume caret-at-end).
3214 @State private var composerCaret = 0
3215 // The field's measured height (1–9 lines), pushed by the field on every
3216 // content/width/font change — SwiftUI never re-asks a representable.
3217 @State private var composerHeight: CGFloat = 22
3218 // The active typeahead: the token under the caret + its ranked candidates.
3219 @State private var taToken: ComposerToken?
3220 @State private var taItems: [TypeaheadItem] = []
3221 @State private var taIndex = 0
3222
3223 @EnvironmentObject private var app: MacAppState
3224 @Environment(\.chatFontSize) private var chatFontSize
3225 @Environment(\.colorScheme) private var scheme
3226
3227 var body: some View {
3228 VStack(alignment: .leading, spacing: 4) {
3229 panel
3230 if fixedReply == nil, let replyTo {
3231 composerBanner("Replying to \(replyTo.name ?? "")",
3232 detail: replyTo.snippet,
3233 cancelLabel: "Cancel reply") { onCancelReply() }
3234 }
3235 if editingID != nil {
3236 composerBanner("Editing message", detail: nil,
3237 cancelLabel: "Cancel edit") { onCancelEdit() }
3238 }
3239 VStack(spacing: 0) {
3240 // A default command's push-back ("when should I remind you?")
3241 // — inline above the composer, occupying layout, never floating.
3242 if let routeNotice {
3243 Text(routeNotice)
3244 .font(.caption)
3245 .foregroundStyle(.secondary)
3246 .frame(maxWidth: .infinity, alignment: .leading)
3247 .padding(.horizontal, 12)
3248 .padding(.top, 6)
3249 }
3250 composerRow
3251 // Transient feedback only (downloads, upload errors) — no
3252 // permanent status line.
3253 if let busyText {
3254 Text(busyText)
3255 .font(.caption2)
3256 .foregroundStyle(.secondary)
3257 .frame(maxWidth: .infinity)
3258 .padding(4)
3259 }
3260 }
3261 .frame(maxWidth: .infinity)
3262 // iMessage-style: a within-window frost (FrostedBand) — matches the
3263 // chat stream's surface exactly at rest (blurring the same window
3264 // background it sits on) while messages scrolling underneath ghost
3265 // through the glass. Only the input row wears it — the typeahead/
3266 // banners above stay transparent floaters.
3267 .background(FrostedBand())
3268 }
3269 // The typeahead is a drop-UP that OVERLAYS the feed, never a row in
3270 // this stack. This whole VStack is the bottom safeAreaInset's content,
3271 // which SHRINKS the feed's scroll view — and the strip is one row per
3272 // candidate, re-ranked on every keystroke, so `/t` → `/th` → `/thr`
3273 // moved the entire transcript a row at a time as the list narrowed.
3274 // Transparent was not enough: transparent still occupies layout.
3275 // Web parity — style.css `.typeahead` is absolutely positioned against
3276 // `.composer-float`, a zero-height anchor at the top of the composer
3277 // stack, for exactly this reason. (The staged-attachment strip stays in
3278 // flow on purpose, on both platforms: floating THAT covered tappable
3279 // things at the feed's bottom edge.)
3280 //
3281 // The alignment-guide flip is the whole trick: aligned `.top`, the
3282 // overlay reports its own BOTTOM as its top edge, so it sits directly
3283 // above the band and rises with it — a reply banner arming or a photo
3284 // staging pushes the strip up, with no height measured anywhere.
3285 // .topLEADING, and the width claimed explicitly: measured off-screen, a
3286 // plain `.top` overlay centres its child on the band and lets it take
3287 // its ideal width, where the in-flow VStack (alignment: .leading) gave
3288 // it the full width, left-aligned. Without both of these the card
3289 // floats centred and narrow instead of tracking the composer's edges.
3290 .overlay(alignment: .topLeading) {
3291 // The guide flip MUST sit outside the conditional. Measured
3292 // off-screen: a guide set on a view INSIDE an `if` never reaches
3293 // the overlay — the overlay aligns the _ConditionalContent
3294 // container and reads its default `.top`, so the card was laid out
3295 // with its top on the band's top and ran down over the composer
3296 // and off the bottom of the window. Outside the `if` it shifts by
3297 // the card's full height (measured: 360…458 vs 262…360 for a 98pt
3298 // card over a band at 360…400).
3299 typeaheadDropUp
3300 .alignmentGuide(.top) { $0[.bottom] }
3301 }
3302 .onChange(of: draft) { _, text in
3303 refreshTypeahead()
3304 onDraftChanged(text)
3305 }
3306 // Caret moves (clicks / arrows) can enter or leave a token with the
3307 // text unchanged — re-check, like the web composer's keyup handler.
3308 .onChange(of: composerCaret) { _, _ in refreshTypeahead() }
3309 // The thread's live command rows landing re-ranks an open "/" menu —
3310 // this stands in for the direct refreshTypeahead() the container's
3311 // command-host sink used to make when the state all lived together.
3312 .onChange(of: hostRows) { _, _ in refreshTypeahead() }
3313 }
3314
3315 // The token under the REAL caret (MacComposerField tracks it). The field
3316 // reports UTF-16, the tokenizer speaks Characters — convert at the
3317 // boundary, exactly as the iOS screens do.
3318 // The typeahead card, or nothing. Split out so the drop-up's alignment
3319 // guide can be applied OUTSIDE this conditional (see the overlay above).
3320 @ViewBuilder private var typeaheadDropUp: some View {
3321 if !taItems.isEmpty, let token = taToken {
3322 TypeaheadStrip(items: taItems, selected: taIndex,
3323 mentionAvatar: mentionAvatar) { pick($0, token: token) }
3324 .frame(maxWidth: .infinity, alignment: .leading)
3325 .padding(.horizontal, 10)
3326 .padding(.bottom, 4)
3327 }
3328 }
3329
3330 private func refreshTypeahead() {
3331 let utf16 = min(max(composerCaret, 0), draft.utf16.count)
3332 let idx = String.Index(utf16Offset: utf16, in: draft)
3333 let charCaret = draft.distance(from: draft.startIndex, to: idx)
3334 taToken = composerToken(draft, caret: charCaret)
3335 // A commands-off bar never menus a "/" draft — the text sends
3336 // verbatim, matching the web pane composer (cfg.commands:false).
3337 if !allowCommands, taToken?.kind == .command { taToken = nil }
3338 taItems = taToken.map {
3339 typeaheadItems($0, roster: roster, myUserId: myUserId, hasBot: hasBot,
3340 appCommands: appCommands, displayNames: model.senderNames,
3341 rows: hostRows)
3342 } ?? []
3343 taIndex = 0
3344 }
3345
3346 // Accept a candidate: splice it over the token (trigger → caret), exactly
3347 // like the web's acceptTypeahead.
3348 private func pick(_ item: TypeaheadItem, token: ComposerToken) {
3349 // Splice trigger → caret, PRESERVING the tail after the caret (the old
3350 // TextField assumed caret-at-end and dropped it). Characters → UTF-16
3351 // at the boundary; the field edits itself via the controller, so the
3352 // caret lands right after the insert.
3353 let chars = Array(draft)
3354 let utf16 = min(max(composerCaret, 0), draft.utf16.count)
3355 let idx = String.Index(utf16Offset: utf16, in: draft)
3356 let charCaret = draft.distance(from: draft.startIndex, to: idx)
3357 let start = min(token.start, chars.count)
3358 let end = min(max(charCaret, start), chars.count)
3359 let head = String(chars[0..<start])
3360 let span = String(chars[start..<end])
3361 controller.insert(item.insert, replacingUTF16: NSRange(
3362 location: (head as NSString).length, length: (span as NSString).length))
3363 taToken = nil
3364 taItems = []
3365 composerFocused = true
3366 }
3367
3368 // The staged attachments, Messages-style: INSIDE the input pill, above
3369 // the text, so the attachment and its caption visibly travel as one
3370 // message. Images show a real rounded thumbnail with the ✕ overlapping
3371 // its corner and the upload ring over the middle; files keep a pill.
3372 private var stagedChips: some View {
3373 HStack(alignment: .top, spacing: 8) {
3374 ForEach(staged) { s in
3375 if let preview = s.preview {
3376 imageChip(s, preview: preview)
3377 } else {
3378 pillChip(s)
3379 }
3380 }
3381 }
3382 .padding(.horizontal, 12)
3383 .padding(.top, 10)
3384 }
3385
3386 private func imageChip(_ s: Staged, preview: PlatformImage) -> some View {
3387 ZStack {
3388 Image(platform: preview)
3389 .resizable()
3390 .scaledToFill()
3391 .frame(width: 96, height: 96)
3392 .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
3393 if s.attachment == nil {
3394 uploadRing(s.progress)
3395 .frame(width: 26, height: 26)
3396 }
3397 }
3398 // Video thumbs wear a small play badge so they read as clips.
3399 .overlay(alignment: .bottomLeading) {
3400 if !s.isImage {
3401 Image(systemName: "play.fill")
3402 .font(.system(size: 9))
3403 .foregroundStyle(.white)
3404 .padding(4)
3405 .background(Circle().fill(.black.opacity(0.55)))
3406 .padding(4)
3407 }
3408 }
3409 .overlay(alignment: .topTrailing) { unstageButton(s).offset(x: 7, y: -7) }
3410 .padding(.top, 7) // room for the overlapping ✕
3411 .shadow(color: .black.opacity(0.18), radius: 5, y: 2)
3412 }
3413
3414 private func pillChip(_ s: Staged) -> some View {
3415 HStack(spacing: 6) {
3416 if s.attachment == nil {
3417 uploadRing(s.progress).frame(width: 15, height: 15)
3418 } else {
3419 Text(fileIcon(s.name)).font(.system(size: 15))
3420 }
3421 Text(s.name).font(.system(size: 13)).lineLimit(1).truncationMode(.middle)
3422 unstageButton(s)
3423 }
3424 .padding(.horizontal, 9)
3425 .padding(.vertical, 6)
3426 .background(Capsule().fill(.primary.opacity(0.06)))
3427 .frame(maxWidth: 200)
3428 }
3429
3430 private func uploadRing(_ progress: Double) -> some View {
3431 ZStack {
3432 Circle().stroke(.white.opacity(0.45), lineWidth: 3)
3433 Circle()
3434 .trim(from: 0, to: max(0.03, progress))
3435 .stroke(.white, style: StrokeStyle(lineWidth: 3, lineCap: .round))
3436 .rotationEffect(.degrees(-90))
3437 .animation(.linear(duration: 0.1), value: progress)
3438 }
3439 .shadow(color: .black.opacity(0.4), radius: 2)
3440 }
3441
3442 private func unstageButton(_ s: Staged) -> some View {
3443 Button {
3444 onUnstage(s)
3445 } label: {
3446 Image(systemName: "xmark.circle.fill")
3447 .font(.system(size: 15))
3448 .symbolRenderingMode(.palette)
3449 .foregroundStyle(.white, .gray)
3450 }
3451 .buttonStyle(.plain)
3452 .accessibilityLabel("Remove \(s.name)")
3453 }
3454
3455 private var composerRow: some View {
3456 HStack(alignment: .bottom, spacing: 8) {
3457 // The web composer's "+": stage photos and allowlisted documents.
3458 // The container passes nil in a default-command thread (To-do) —
3459 // text-only surface, matching the web and iOS composers.
3460 if let onPickFiles {
3461 Button(action: onPickFiles) {
3462 Image(systemName: "plus.circle.fill")
3463 .font(.system(size: 28)) // same weight as the send button
3464 // Messages.app: a real glyph on the disc — white in
3465 // dark mode, dark grey in light — not the knocked-out
3466 // transparent plus the monochrome fill gave.
3467 .symbolRenderingMode(.palette)
3468 .foregroundStyle(scheme == .dark ? Color.white : Color(white: 0.3),
3469 .tertiary)
3470 }
3471 .buttonStyle(.plain)
3472 .accessibilityLabel("Attach files")
3473 .padding(.bottom, 4)
3474 }
3475 // ONE pill around attachments and text, the Messages read: what's
3476 // staged and what's typed are visibly the same message.
3477 VStack(alignment: .leading, spacing: 0) {
3478 if !staged.isEmpty {
3479 stagedChips
3480 }
3481 // The NSTextView-backed field (MacComposerField): a real caret —
3482 // mid-text typeahead, Shift/⌥+Return newlines AT the caret (#819),
3483 // caret-preserving splices — and the base the rich stages of #890
3484 // build on. Plain text at this stage. Typeahead keys route through
3485 // the handlers: ↑↓ move, Tab/Return accept, Esc dismisses — Return
3486 // falls through to send when no menu is up; ↑ on an empty draft
3487 // edits the last message.
3488 MacComposerField(
3489 text: $draft,
3490 focused: $composerFocused,
3491 height: $composerHeight,
3492 caret: $composerCaret,
3493 placeholder: placeholder,
3494 fontSize: chatFontSize, // the feed's reading size, like the web composer
3495 // Queued behind an upload = the message is committed; editing
3496 // what's about to auto-send would race it.
3497 isDisabled: sendQueued,
3498 formatAsYouType: formatAsYouType,
3499 controller: controller,
3500 onSend: onSend,
3501 onMoveUp: {
3502 if !taItems.isEmpty {
3503 taIndex = (taIndex + taItems.count - 1) % taItems.count
3504 return true
3505 }
3506 if draft.isEmpty, editingID == nil { return onEditLatest() }
3507 return false
3508 },
3509 onMoveDown: {
3510 guard !taItems.isEmpty else { return false }
3511 taIndex = (taIndex + 1) % taItems.count
3512 return true
3513 },
3514 onAccept: {
3515 guard !taItems.isEmpty, let token = taToken else { return false }
3516 pick(taItems[taIndex], token: token)
3517 return true
3518 },
3519 onEscape: {
3520 if !taItems.isEmpty {
3521 taToken = nil
3522 taItems = []
3523 return true
3524 }
3525 // Deeper Escape layers belong to the container (the
3526 // main bar peels an open command panel here, before
3527 // its reply/edit banners).
3528 return onEscapeFallback()
3529 },
3530 onStagePaste: onStagePaste,
3531 onStageDrop: onStageDrop
3532 )
3533 // The field's height is pushed by the field itself (measured
3534 // 1–9 lines) — SwiftUI never re-asks a representable when only
3535 // its content changes.
3536 .frame(height: composerHeight)
3537 // Claim the row's full width (the + and send buttons keep
3538 // their fixed sizes) — without this the HStack centres a
3539 // short pill around the representable's measured width.
3540 .frame(maxWidth: .infinity)
3541 .padding(.horizontal, 14)
3542 .padding(.vertical, 9)
3543 // Regaining the app drops the caret into the message box, the
3544 // same courtesy the webview shell extends (focusComposer) —
3545 // but ONLY from the pane actually on screen (every instance's
3546 // pane stays mounted at opacity 0, and their writes were
3547 // stealing focus from the visible composer), never over an
3548 // open ⌘K palette (its own handler re-asserts its field), and
3549 // from the CHANNEL's bar rather than an open thread's
3550 // (focusOnActivate — you come back to the conversation, not
3551 // to the reply you were parked in).
3552 .onReceive(NotificationCenter.default.publisher(
3553 for: NSApplication.didBecomeActiveNotification)) { _ in
3554 guard focusOnActivate, !app.showPalette else { return }
3555 DispatchQueue.main.async {
3556 if !app.showPalette { composerFocused = true }
3557 }
3558 }
3559 // Focus on open — asserted again a beat later AND when the
3560 // feed reveals: a FocusState write in onAppear races the
3561 // freshly-inserted (still invisible) field and loses.
3562 .onAppear {
3563 guard isActive else { return }
3564 composerFocused = true
3565 DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
3566 if isActive { composerFocused = true }
3567 }
3568 }
3569 .onChange(of: settled) { _, isSettled in
3570 if isSettled, isActive { composerFocused = true }
3571 }
3572 }
3573 // The web composer: a bordered pill on the panel background,
3574 // not a filled grey field.
3575 .background(RoundedRectangle(cornerRadius: 19, style: .continuous)
3576 .fill(.background))
3577 .overlay(RoundedRectangle(cornerRadius: 19, style: .continuous)
3578 .strokeBorder(.quaternary, lineWidth: 1))
3579 Button(action: onSend) {
3580 if sendQueued {
3581 // Queued behind an in-flight upload — the web's .send-waiting.
3582 ProgressView()
3583 .controlSize(.small)
3584 .frame(width: 28, height: 28)
3585 } else {
3586 Image(systemName: "arrow.up.circle.fill")
3587 .font(.system(size: 28))
3588 .foregroundStyle(hasContent ? AnyShapeStyle(Color.accentColor)
3589 : AnyShapeStyle(.tertiary))
3590 }
3591 }
3592 .buttonStyle(.plain)
3593 .disabled(!hasContent)
3594 .accessibilityLabel("Send")
3595 .padding(.bottom, 4)
3596 }
3597 // Messages.app gives the input pill real breathing room below — the
3598 // old 6pt read as cramped against the window edge.
3599 .padding(EdgeInsets(top: 8, leading: 10, bottom: 12, trailing: 10))
3600 }
3601
3602 // The container's send path keeps its own twin (fireQueuedSend) — both
3603 // read the same draft + staged, so they can't disagree.
3604 private var hasContent: Bool {
3605 !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !staged.isEmpty
3606 }
3607
3608 private func composerBanner(_ title: String, detail: String?, cancelLabel: String,
3609 onCancel: @escaping () -> Void) -> some View {
3610 HStack(spacing: 8) {
3611 VStack(alignment: .leading, spacing: 1) {
3612 Text(title).font(.system(size: 12, weight: .semibold))
3613 if let detail, !detail.isEmpty {
3614 Text(detail).font(.system(size: 12)).foregroundStyle(.secondary).lineLimit(1)
3615 }
3616 }
3617 Spacer()
3618 Button(action: onCancel) {
3619 Image(systemName: "xmark.circle.fill")
3620 .font(.system(size: 13))
3621 .foregroundStyle(.tertiary)
3622 }
3623 .buttonStyle(.plain)
3624 .keyboardShortcut(ownsCancelKey ? .cancelAction : nil)
3625 .accessibilityLabel(cancelLabel)
3626 }
3627 .padding(.horizontal, 12)
3628 .padding(.vertical, 8)
3629 // Opaque panel card: the banner floats over the feed since the inset
3630 // lost its .bar slab, and messages/typing showed straight through it.
3631 // A stroked edge, NOT a shadow — a shadow inside safeAreaInset made
3632 // the hosted scroll content vanish entirely on macOS.
3633 .background(RoundedRectangle(cornerRadius: 10, style: .continuous)
3634 .fill(theme.panel.resolved(scheme)))
3635 .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous)
3636 .strokeBorder(.primary.opacity(0.10)))
3637 .padding(.horizontal, 10)
3638 .padding(.top, 6)
3639 }
3640}
3641
3642