eleven / messenger Public

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

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).
27final class PaneDropRouter {
28 static let shared = PaneDropRouter()
29 private struct Entry {
30 let token: UUID
31 let handler: (NSPasteboard) -> Void
32 }
33 // Keyed by the pane's instance origin — hidden panes stay registered
34 // (they stay mounted); selection picks between them at drop time.
35 private var entries: [String: Entry] = [:]
36
37 func register(_ key: String, token: UUID,
38 handler: @escaping (NSPasteboard) -> Void) {
39 Logger.identity.info("drop router: register \(key, privacy: .public)")
40 entries[key] = Entry(token: token, handler: handler)
41 }
42 // Token-checked so a within-pane thread switch can't misfire: the old
43 // feed's onDisappear may land after the new feed registered the same key.
44 func unregister(_ key: String, token: UUID) {
45 guard entries[key]?.token == token else { return }
46 Logger.identity.info("drop router: unregister \(key, privacy: .public)")
47 entries[key] = nil
48 }
49 // Drives the drag's + badge: no registered feed for the selected pane
50 // (locked pane, default-command thread, first-run) → the window rejects
51 // the drag, matching the old empty-type-list gate.
52 func canDrop(over key: String?) -> Bool {
53 key.flatMap { entries[$0] } != nil
54 }
55 func deliver(_ pasteboard: NSPasteboard, over key: String?) -> Bool {
56 guard let key, let entry = entries[key] else {
57 Logger.identity.error("drop router: no feed for \(key ?? "<no selection>", privacy: .public) — drop bounced")
58 return false
59 }
60 Logger.identity.info("drop router: deliver to \(key, privacy: .public)")
61 entry.handler(pasteboard)
62 return true
63 }
64}
65
66// One thread's natively-folded feed — the real native chat surface (mounted by
67// the takeover's NativeChatView and the Debug diff pane alike), rendered with
68// the shared MessageBubble leaf views.
69// leaf view (LChatCore). Own view so the ChatFeedModel's lifetime is tied
70// (via .id above) to the picked thread.
71struct NativeFeedView: View {
72 @StateObject private var model: ChatFeedModel
73 @Environment(\.chatFontSize) private var chatFontSize
74 private let isGroup: Bool
75 private let roster: [RosterEntry]
76 private let myUserId: String
77 // A bot in the thread is what makes /commands answerable (web threadHasBot).
78 private let hasBot: Bool
79 // The message a quote-tap jumped to does a little dance so the eye lands on
80 // it. danceTick retriggers the animation even for the same target.
81 @State private var danceID: Int64?
82 @State private var danceTick = 0
83 // Live row frames in the scroll viewport, written by row geometry probes and
84 // read imperatively by jump(). A plain reference (NOT @State): frames change
85 // on every scroll tick, and none of the UI derives from them — state here
86 // would re-render the whole list per tick.
87 // ObservableObject (with no @Published — geometry writes must never
88 // re-render the list) so @StateObject pins ONE instance across struct
89 // re-inits: the swipe monitor closure and the geometry probes have to be
90 // reading and writing the same store.
91 private final class FrameStore: ObservableObject {
92 var rows: [Int64: CGRect] = [:]
93 var viewport: CGSize = .zero
94 // RootView keeps every opened instance's pane MOUNTED at opacity 0, so
95 // hidden panes share the visible one's frame; the swipe monitor must
96 // only act for the pane actually on screen. A box (not view state) so
97 // the monitor closure reads the live value.
98 var isActive = true
99 // Debounces the scroll-position capture (the per-thread anchor): the
100 // wheel monitor fires per tick, and reading the row frames mid-fling
101 // is churn for nothing — capture once the scroll goes quiet.
102 var anchorDebounce: DispatchWorkItem?
103 // Throttle stamp for the DURING-scroll captures. The trailing
104 // debounce alone lost the race that mattered: scroll up, click
105 // another chat within a beat — teardown cancelled the pending
106 // capture and no anchor was ever saved (Paul's "state isn't being
107 // saved at all"). Capturing every ~150ms mid-scroll keeps the anchor
108 // never more than a few frames stale, whatever happens next.
109 var lastCaptureAt: TimeInterval = 0
110 }
111 @StateObject private var frames = FrameStore()
112
113 private let mentionAvatar: (String) -> AvatarSource?
114
115 private let isActive: Bool
116 @AppStorage("nativeTheme") private var themeName = "classic"
117 private var theme: ChatTheme { .named(themeName) }
118 @Environment(\.colorScheme) private var scheme
119
120 init(model: ChatFeedModel, thread: ChatThread, myUserId: String, isGroup: Bool,
121 isActive: Bool = true, roster: [RosterEntry],
122 instance: SavedInstance? = nil, instanceKeyB64: String? = nil,
123 store: ThreadStore? = nil,
124 mentionAvatar: @escaping (String) -> AvatarSource?,
125 onReopenDM: (() async -> LChatAPI.ReopenDM)? = nil,
126 onMutePerson: ((String, Bool) -> Void)? = nil,
127 onReport: ((ChatFeedModel.Message) -> Void)? = nil,
128 onSettled: (() -> Void)? = nil,
129 onMemberProfile: ((String) -> Void)? = nil) {
130 // The model arrives PARKED (NativeFeedModelCache) — folded messages and
131 // a live socket survive leaving the chat, so re-opening never flashes.
132 _model = StateObject(wrappedValue: model)
133 // EVERY open starts hidden until its position verifiably lands —
134 // bottom pin or anchor restore alike. The first cut of this revealed
135 // parked models from the first frame ("instant"), but rows
136 // materialise with real heights after that frame, and with tall
137 // bubbles (code blocks) the correction was a visible jump on every
138 // switch — Paul's report, twice. A parked model settles in a verify
139 // round or two (~50–100ms of pane background), which reads as a fast
140 // render; a visible jump reads as broken. No jumps > instant.
141 _settled = State(initialValue: false)
142 self.isGroup = isGroup
143 self.isActive = isActive
144 self.roster = roster
145 self.myUserId = myUserId
146 self.mentionAvatar = mentionAvatar
147 let members = Set(thread.members)
148 self.hasBot = roster.contains { members.contains($0.user_id) && $0.isAgent }
149 self.appIntro = thread.appId.flatMap { AppChatProjection.intros[$0] }
150 // The app-thread's default command (To-do: plain text adds a to-do) —
151 // the web's defaultCommandFor, so every composer routes identically.
152 self.defaultCommand = AppDefaultCommand.spec(appID: thread.appId, hasBot: self.hasBot)
153 // The thread's app-local verbs for the "/" typeahead (/add in To-do),
154 // then the command-host verbs (/emoji) — every native composer offers
155 // what the per-thread command webview can answer (see sendDraft).
156 self.appCommands = AppDefaultCommand.localCommands(appID: thread.appId) + WebPanelCommands.catalog
157 self.threadID = thread.id
158 // The command host needs the full thread (key) + instance to seed its
159 // webview; nil (a mount without an instance) just disables forwarding.
160 self.thread = thread
161 self.instance = instance
162 self.instanceKeyB64 = instanceKeyB64
163 self.store = store
164 self.onReopenDM = onReopenDM
165 self.onMutePerson = onMutePerson
166 self.onReport = onReport
167 self.onSettled = onSettled
168 self.onMemberProfile = onMemberProfile
169 }
170
171 private let defaultCommand: AppDefaultCommand.Spec?
172 private let appCommands: [(name: String, desc: String)]
173
174 private let threadID: String
175 private let thread: ChatThread
176 private let instance: SavedInstance?
177 private let instanceKeyB64: String?
178 // For the command host's needAppThread answers (open-or-create a personal
179 // app thread) — nil just leaves cross-thread commands unresolved.
180 private let store: ThreadStore?
181 // The closed banner's Reopen (#578) — routed through the mounting view's
182 // ThreadStore so the sidebar row updates with the composer. nil (a mount
183 // without a store, e.g. the Debug pane) just hides the button's action.
184 private let onReopenDM: (() async -> LChatAPI.ReopenDM)?
185 // Mute/unmute a message's sender (#578) — the container (NativeChatView)
186 // routes it to ThreadStore.setPersonMuted; nil hides the menu item.
187 private let onMutePerson: ((String, Bool) -> Void)?
188 // Report a message's sender to the admins (#578) — the container owns the
189 // sheet (it holds the ThreadStore with the roster + keys to seal to);
190 // nil hides the menu item.
191 private let onReport: ((ChatFeedModel.Message) -> Void)?
192 // Fired every time the feed's position verifiably lands (settled flips
193 // true) — the chat switcher uses it to drop the outgoing feed it holds
194 // over the settle beat.
195 private let onSettled: (() -> Void)?
196 // A tapped @mention chip — the container opens the member's profile card.
197 private let onMemberProfile: ((String) -> Void)?
198
199 // The companion app's start-of-history intro (chatIntro on the web),
200 // shown above the earliest message once all history is loaded.
201 private let appIntro: String?
202
203 // The lightbox lives on MacAppState (RootView overlays it window-wide).
204 @EnvironmentObject private var app: MacAppState
205 // Transient status-bar line while a video/file fetches ("nothing happened"
206 // is worse than a quiet caption; the web shows a radial ring here).
207 @State private var busyText: String?
208 // A default command's push-back line ("when should I remind you?") — its
209 // OWN slot, not busyText: the upload paths clear busyText on their own 3s
210 // timers and would wipe an unrelated instruction. The generation counter
211 // makes every clear timer cancel-safe (a re-shown identical prompt must
212 // not be wiped by the previous showing's timer), and dcRouting keeps one
213 // async routing in flight at a time (no double-created reminders).
214 @State private var routeNotice: String?
215 @State private var routeNoticeGen = 0
216 @State private var dcRouting = false
217 // The thread's COMMAND HOST — the hidden per-thread webview that runs
218 // mini-app slash commands (/emoji) and shows their panels; the panel
219 // reveals the webview above the composer (see CommandPanelWebView.swift).
220 @State private var commandHost: CommandHost?
221 // This thread's commandItems() rows (the host reports them once ready) —
222 // they feed the "/" typeahead and the forward-or-send routing decision.
223 @State private var hostRows: [CommandRow] = []
224 @State private var panelVisible = false
225 @State private var panelHeight: CGFloat = 240
226 @State private var panelAnimates = true // the open post's animate flag, reused on close
227 // The native composer's draft — cleared only when a send actually reaches
228 // the server (model.send returns false on a down socket, keeping the text).
229 @State private var draft = ""
230 @State private var integrityReport: String? // /integrity read-out (tamper evidence)
231 // Plain @State: MacComposerField drives first responder from it directly.
232 @State private var composerFocused = false
233 // The caret as a UTF-16 offset into `draft` — real caret tracking for the
234 // typeahead (the old TextField could only assume caret-at-end).
235 @State private var composerCaret = 0
236 // The field's measured height (1–9 lines), pushed by the field on every
237 // content/width/font change — SwiftUI never re-asks a representable.
238 @State private var composerHeight: CGFloat = 22
239 // Programmatic edits (typeahead splice, panel inserts) act on the view via
240 // the controller, so the caret survives them.
241 @State private var composerController = MacComposerController()
242 @AppStorage("formatAsYouType") private var formatAsYouType = true
243 // Attachments staged on the draft: picked → uploading (spinner) → ready.
244 // Several can ride one message, like the web's pending list.
245 private struct Staged: Identifiable {
246 let id = UUID()
247 let name: String
248 let isImage: Bool
249 var preview: PlatformImage? // image picks show a real thumbnail (web-style)
250 var progress = 0.0 // request-body bytes sent, 0…1
251 var attachment: Attachment? // nil while the encrypt+upload is in flight
252 }
253 @State private var staged: [Staged] = []
254 // Send pressed mid-upload (the web's PR #269): the button swaps its arrow
255 // for a spinner and the message goes out the moment the uploads finish.
256 @State private var sendQueued = false
257 // Staged reply target / message being edited (the composer banners).
258 @State private var replyTo: ReplyRef?
259 @State private var editingID: Int64?
260 // The open reply-thread view's root id (nil = closed) and the header
261 // stand-in while that root isn't loaded — frozen at open, like the web's
262 // threadFallback (chat.js openThread's ref argument).
263 @State private var threadRootID: Int64?
264 @State private var threadFallback: ReplyRef?
265 // This surface's message menus, keyed by message id, so ⌘T can pop the one
266 // belonging to the right bubble (issue #392). A plain reference handed down
267 // the Environment: the catchers register themselves as they mount, and
268 // nothing here ever observes it.
269 // One per surface, never shared: a message showing in BOTH the feed and an
270 // open reply-thread view has a catcher in each, and a single scope would
271 // hand ⌘T whichever mounted last.
272 @State private var menuScope = MessageMenuScope()
273 @State private var threadMenuScope = MessageMenuScope()
274 // Messages' "Show Times": how far the time gutter is revealed (0…44).
275 // Driven continuously by two-finger horizontal swipes over the feed and
276 // snapped open/closed on release, or toggled whole from the context menu.
277 // Deliberately NOT persisted — it's a peek, not a mode.
278 @State private var timesReveal: CGFloat = 0
279 // The feed's window frame + the swipe monitor handle, for routing
280 // horizontal scroll events (vertical ones pass through untouched). The
281 // frame is a LiveFrame — read lazily at scroll-event time, never tracked
282 // with a per-frame `onChange(of: CGRect)` (window-resize churn — #899).
283 @State private var paneFrame = LiveFrame()
284 @State private var swipeMonitor: Any?
285 // Identifies this feed's registration on the shared drop router, so a
286 // stale unregister (a thread switch's old feed disappearing after the new
287 // one registered the same pane key) can't evict the rightful receiver.
288 @State private var dropToken = UUID()
289 // True while a horizontal swipe is actively dragging the gutter — the
290 // zero-delta release event snaps only when a drag was in flight.
291 @State private var draggingTimes = false
292 // The bubble stage is sized from the VIEWPORT (minus row padding 20, run
293 // spacer 48, and the gutter — reserved whether or not it's on screen, so
294 // revealing times never resizes a bubble), capped at a readable measure.
295 // State, not a frame-store read: window resizes must re-render the rows.
296 static let timeGutter: CGFloat = 44
297 @State private var stageWidth: CGFloat = 368
298 @State private var viewportHeight: CGFloat = 0
299
300 private func updateStageWidth(_ viewport: CGSize) {
301 viewportHeight = viewport.height
302 // Quantised to 16pt steps: a live divider drag fires per-pixel size
303 // changes, and re-wrapping every row each tick is the flashing/jumping
304 // Paul saw — most ticks now change nothing.
305 let raw = min(660, max(300, viewport.width - 20 - 48 - Self.timeGutter))
306 let stepped = (raw / 16).rounded(.down) * 16
307 if stepped != stageWidth { stageWidth = stepped }
308 }
309
310 private var timesShown: Bool { timesReveal > 0 }
311 // The clip currently downloading + its 0…1 progress; drives the radial
312 // ring on its card. Shown only once the fetch outlives the web's 150ms
313 // grace, so a cached/fast load never flashes an empty ring.
314 @State private var videoDownload: (id: String, progress: Double)?
315
316 // Jump-to-latest: shown once the reader has scrolled a MODERATE distance
317 // up — much wider than the 150pt auto-pin slack used elsewhere in this
318 // file, so the button doesn't appear for merely reading the newest
319 // couple of messages. Toggled by the sentinel's geometry probe below,
320 // written only on the boolean's actual edges (not every scroll tick) so
321 // it never becomes another per-frame @Published-style re-render source
322 // (see the FrameStore comment above on why `frames` itself isn't state).
323 @State private var jumpButtonVisible = false
324 static let jumpButtonGap: CGFloat = 400
325 // Legacy (always-shown) scrollbars claim ~15pt of layout width on the
326 // trailing edge, so the scroll CONTENT — and with it the bubbles' right
327 // edge — ends short of the ScrollView frame the jump button's overlay
328 // measures from. The content probe records the difference so the button
329 // hugs the bubbles' actual edge in both scroller modes. Integer-guarded:
330 // during a live divider drag viewport and content shrink in lockstep, so
331 // the inset is constant and this never becomes a per-pixel re-render.
332 @State private var scrollerInset: CGFloat = 0
333
334 private func updateScrollerInset(contentWidth: CGFloat) {
335 let inset = max(0, (frames.viewport.width - contentWidth).rounded())
336 if inset != scrollerInset { scrollerInset = inset }
337 }
338
339 var body: some View {
340 ScrollViewReader { proxy in
341 // A ScrollView + LazyVStack, NOT a List: List's NSTableView-backed
342 // rows clip their bounds, which beheaded the wave animation — stack
343 // children can overdraw their row freely.
344 ScrollView {
345 LazyVStack(spacing: 0) {
346 if let appIntro, model.ready, model.exhausted || model.messages.isEmpty {
347 Text(appIntro)
348 .font(.system(size: 13))
349 .foregroundStyle(.secondary)
350 .multilineTextAlignment(.center)
351 .frame(maxWidth: 440)
352 .padding(.vertical, 28)
353 .padding(.horizontal, 16)
354 .frame(maxWidth: .infinity)
355 }
356 ForEach(Array(model.messages.enumerated()), id: \.element.id) { i, m in
357 let previous = i > 0 ? model.messages[i - 1] : nil
358 FeedRow(key: rowKey(m, previous: previous),
359 content: { AnyView(row(m, previous: previous, proxy: proxy)) })
360 .equatable()
361 .id(m.id)
362 // Deleted messages implode — a quick shrink-and-
363 // fade where the bubble stood.
364 .transition(.opacity) // plain fade — a scale-implosion removal blanked the whole feed on inset changes
365 // Geometry probe for "is this row already in view?"
366 // (jump-without-scroll). Writes to the frame store,
367 // never to state.
368 .background(GeometryReader { g in
369 // Direct side-effect write on every layout pass —
370 // NOT `.onChange(of: CGRect)`. Observing this
371 // per-frame value fired SwiftUI's "onChange(of:
372 // CGRect) action tried to update multiple times
373 // per frame" churn (×every visible row) during a
374 // window maximise/restore (#899). The store has
375 // no @Published, so the write invalidates nothing
376 // (see FrameStore) — this keeps `rows` just as
377 // current, minus the observation.
378 let _ = (frames.rows[m.id] = g.frame(in: .named("feed")))
379 Color.clear
380 .onDisappear { frames.rows.removeValue(forKey: m.id) }
381 })
382 // Scroll-back: the oldest row materialising means the
383 // reader is nearing the top — fold the previous page,
384 // then re-anchor it so history grows upward without
385 // the viewport jumping (the web's prepend anchoring).
386 .onAppear {
387 // Fire while the reader is still ~8 rows from
388 // the top: the page splices in off-screen and
389 // the seam is never visible at reading pace.
390 // model.loadOlder self-gates (ready flag +
391 // cooldown), so a prepend can't cascade-load
392 // the whole history. The `settled` gate stops
393 // the opening pin-jump (which transiently
394 // materialises top rows) from prefetching
395 // pages nobody asked for.
396 guard settled, i < 8, model.ready, !model.exhausted else { return }
397 let anchorID = m.id
398 // The trigger row often materialises while still
399 // ABOVE the viewport (lazy lookahead), so its own
400 // position is useless for restoring. Anchor on
401 // whichever row is closest to the viewport's
402 // centre instead — solidly on screen, valid
403 // UnitPoint — and put IT back exactly where it
404 // was, after the prepend has laid out.
405 let mid = frames.viewport.height / 2
406 let ref = frames.rows
407 .filter { $0.value.minY >= 0 && $0.value.maxY <= frames.viewport.height }
408 .min { abs($0.value.midY - mid) < abs($1.value.midY - mid) }
409 Task {
410 await model.loadOlder()
411 guard model.messages.first?.id != anchorID else { return }
412 DispatchQueue.main.async {
413 if let ref, frames.viewport.height > ref.value.height {
414 let f = max(0, min(1, ref.value.minY /
415 (frames.viewport.height - ref.value.height)))
416 proxy.scrollTo(ref.key, anchor: UnitPoint(x: 0, y: f))
417 } else {
418 proxy.scrollTo(anchorID, anchor: .top)
419 }
420 }
421 }
422 }
423 // Optimistic send (web parity): the copy already shows
424 // instantly and the confirm swaps it in place; once it's
425 // gone slow (5s unconfirmed) the bubble dims, with a
426 // VoiceOver cue so the delay isn't conveyed by opacity
427 // alone.
428 .opacity(m.pending && m.slow ? 0.55 : 1)
429 .accessibilityHint(m.pending ? (m.slow ? "Still sending" : "Sending") : "")
430 }
431 // The web's typing bubble: three bouncing dots, them-side,
432 // living IN the feed (the old composer-inset text line's
433 // background clipped the bubble beneath it).
434 if !model.typing.isEmpty {
435 HStack(spacing: 0) {
436 TypingBubble(theme: theme,
437 label: isGroup ? typingSummary(model.typing) : nil)
438 Spacer(minLength: 48)
439 }
440 .padding(EdgeInsets(top: 8, leading: 12, bottom: 2, trailing: 12))
441 .transition(.opacity)
442 }
443 // The pin target: a fixed-height sentinel BELOW the last
444 // row. scrollTo(lastRow) computes against the row's
445 // ESTIMATED height while it's unmaterialised — a tall
446 // final message lands the feed one message short, and
447 // every retry redoes the same wrong math. The sentinel's
448 // height is exact, so "sentinel at the bottom" is always
449 // truly the bottom.
450 Color.clear.frame(height: 1).id(Self.bottomSentinel)
451 }
452 .padding(.vertical, 6)
453 // Short chats read from the TOP: without this the bottom
454 // anchor bottom-aligns content shorter than the viewport.
455 .frame(minHeight: max(0, viewportHeight), alignment: .top)
456 // Jump-button geometry probe: the CONTAINER's frame, not the
457 // bottom sentinel's — a leaf row gets pruned by LazyVStack
458 // once it's far enough off-screen (see the per-row probe
459 // above, which does exactly that on .onDisappear), so reading
460 // gap off the sentinel would freeze the wrong value the
461 // moment it's culled, well before a real "moderate scroll"
462 // threshold. The VStack's own extent is never culled — SwiftUI
463 // needs it for the scroll math regardless of which rows are
464 // realised — so its maxY always reflects the true content
465 // bottom. State only flips at the threshold crossing, so
466 // scrolling itself never re-renders the feed.
467 .background(GeometryReader { g in
468 let gap = g.frame(in: .named("feed")).maxY - frames.viewport.height
469 Color.clear
470 .onAppear {
471 updateJumpButtonVisible(gap: gap)
472 updateScrollerInset(contentWidth: g.size.width)
473 }
474 .onChange(of: gap) { _, gap in updateJumpButtonVisible(gap: gap) }
475 .onChange(of: g.size.width) { _, w in updateScrollerInset(contentWidth: w) }
476 })
477 }
478 .coordinateSpace(name: "feed")
479 // Every bubble's menu catcher registers itself here as it mounts,
480 // so ⌘T can pop the right one (issue #392).
481 .environment(\.messageMenuScope, menuScope)
482 .background(GeometryReader { g in
483 Color.clear
484 .onAppear {
485 frames.viewport = g.size
486 updateStageWidth(g.size)
487 }
488 .onChange(of: g.size) { _, s in
489 frames.viewport = s
490 updateStageWidth(s)
491 }
492 })
493 // Follow the newest message like the web feed's pin-to-bottom —
494 // but only when the reader is AT the bottom (or it's their own
495 // send). The previous last row still being materialised near the
496 // viewport is the "at bottom" signal; yanking someone out of
497 // scroll-back for every incoming message made busy groups
498 // unreadable.
499 .onChange(of: model.messages.last?.id) { old, id in
500 guard id != nil else { return }
501 let mine = model.messages.last?.mine == true
502 // Near-bottom only when the previous last row is MATERIALISED
503 // near the viewport. Defaulting the unknown case to true
504 // treated a reader deep in history (old row destroyed, no
505 // frame) as at-the-bottom and yanked them down on arrivals.
506 let nearBottom = old == nil || old.flatMap { frames.rows[$0]?.minY }
507 .map { $0 < frames.viewport.height + 150 } ?? false
508 // No materialised rows at all while messages exist = the feed
509 // is showing blank space (the offset outlived a content
510 // shrink) — always re-pin our way out of that.
511 let wedged = frames.rows.isEmpty && !model.messages.isEmpty
512 // A VERIFIED pin, not a one-shot scrollTo: the send shrinks
513 // the composer inset (draft clearing) mid-jump and rows
514 // materialise with real heights, so the content can resize
515 // under a one-shot target and leave the viewport clamped past
516 // the real end — a fully blank feed. pinUntilSettled re-pins
517 // against measured geometry until the last row is truly flush.
518 if mine || nearBottom || wedged { pinUntilSettled(proxy) }
519 }
520 // The typing bubble appearing at the bottom should scroll into
521 // view when the reader is already there.
522 .onChange(of: model.typing.isEmpty) { _, empty in
523 if !empty, let last = model.messages.last?.id,
524 let f = frames.rows[last], f.minY < frames.viewport.height + 150 {
525 proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
526 }
527 }
528 // Staged attachments live INSIDE the input pill, so staging and
529 // unstaging resize the composer inset — keep the newest message
530 // pinned through it when the reader was at the bottom.
531 .onChange(of: staged.isEmpty) { _, _ in
532 if let last = model.messages.last?.id,
533 let f = frames.rows[last], f.minY < frames.viewport.height + 150 {
534 pinUntilSettled(proxy)
535 }
536 }
537 // Opening a chat lands at the newest message — and STAYS there:
538 // one pin isn't enough, because rows materialise with their real
539 // (often taller) heights after the jump and shift the content a
540 // message short. Re-pin until the frame store confirms the last
541 // row is actually flush with the viewport bottom.
542 .onChange(of: model.ready) { _, ready in
543 guard ready else { return }
544 if let anchor = savedAnchor() {
545 restoreUntilSettled(proxy, anchor)
546 } else {
547 pinUntilSettled(proxy)
548 }
549 consumeJump(proxy)
550 }
551 // Search hits land on their message: page history until the
552 // target is loaded, then jump-and-wave.
553 .onChange(of: app.nativeJump) { _, _ in consumeJump(proxy) }
554 // ⌘R / ⌘T (Message ▸ Reply to / React to Latest Message, issue
555 // #392) — the web feed's two keyboard message verbs, which the Mac
556 // never had. Both act on the surface in FRONT of the reader: the
557 // reply-thread view's messages while it's open, the feed's
558 // otherwise.
559 .onChange(of: app.replyLatestTick) { _, _ in
560 guard isActive, !model.dmClosed,
561 let m = ChatFeedModel.latestReplyTarget(in: hotkeyMessages) else { return }
562 startReply(m)
563 }
564 .onChange(of: app.reactLatestTick) { _, _ in
565 guard isActive,
566 let m = ChatFeedModel.latestReactionTarget(in: hotkeyMessages) else { return }
567 // The menu hangs off the bubble, so the bubble has to be on
568 // screen — and materialised at all, or there's no catcher to
569 // pop it from. A feed scrolled back into history scrolls first
570 // and pops once the row has landed. (The thread view scrolls
571 // itself and opens at the bottom, so it's exempt.)
572 let scope = threadRootID == nil ? menuScope : threadMenuScope
573 if threadRootID == nil, !rowOnScreen(m.id) {
574 withAnimation { proxy.scrollTo(m.id, anchor: .bottom) }
575 DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
576 scope.popUpMenu(for: m.id)
577 }
578 return
579 }
580 // Never straight from the onChange: popping runs a modal
581 // tracking loop, and this one is still inside a view update.
582 DispatchQueue.main.async { scope.popUpMenu(for: m.id) }
583 }
584 // A Text Size change (⌘+/⌘−, Settings slider) relayouts every row
585 // at once; a shrink can leave the scroll offset clamped past the
586 // new content end — a fully blank feed, the same wedge the
587 // message-arrival handler guards against (and which un-wedged
588 // itself only on the next arrival). Re-pin immediately when the
589 // reader was at the bottom; from deep in history, give layout a
590 // beat and rescue only if the viewport actually came up empty, so
591 // a scrolled-back reader isn't yanked to the newest message.
592 .onChange(of: chatFontSize) { _, _ in
593 let nearBottom = model.messages.last.flatMap { frames.rows[$0.id]?.minY }
594 .map { $0 < frames.viewport.height + 150 } ?? false
595 if nearBottom {
596 pinUntilSettled(proxy)
597 return
598 }
599 DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
600 if frames.rows.isEmpty && !model.messages.isEmpty { pinUntilSettled(proxy) }
601 }
602 }
603 .onAppear {
604 // The mount clock starts at the opening appear only (the view
605 // is rebuilt per thread switch, so one appear = one open).
606 if mountSpan == nil, !settled {
607 mountSpan = Perf.begin("feed.mount", slowMs: 500)
608 }
609 // Switching BACK to a thread the reader had scrolled away from
610 // restores their spot instead of pinning to the bottom — the
611 // anchor lives in the model cache (the view and its scroll
612 // offset are rebuilt per thread; the cache survives). Hidden
613 // while the restore lands (one or two verify rounds) so the
614 // switch never shows the position being corrected — the jump
615 // Paul reported. No anchor = the reader left at the bottom =
616 // today's pin, still visible from the first frame.
617 if let anchor = savedAnchor() {
618 if settled { settled = false }
619 restoreUntilSettled(proxy, anchor)
620 } else {
621 pinUntilSettled(proxy)
622 }
623 consumeJump(proxy)
624 }
625 // Ends the mount span one turn after the reveal commits, so the
626 // revealed frame's layout is measured — cold fetch, pin/restore
627 // verify rounds and all. Also the switcher's signal to drop the
628 // outgoing feed it held over this beat.
629 .onChange(of: settled) { _, isSettled in
630 guard isSettled else { return }
631 onSettled?()
632 guard let span = mountSpan else { return }
633 mountSpan = nil
634 let count = model.messages.count
635 DispatchQueue.main.async { span.end("\(count) messages") }
636 }
637 // Anchor to the bottom BEFORE first layout — a parked/prewarmed
638 // model arrives with messages already folded, and the onAppear pin
639 // races layout and loses (the land-at-top bug, same as iOS).
640 .defaultScrollAnchor(.bottom)
641 // The feed reveals only once history has folded AND the pin has
642 // verifiably landed — a parked chat settles in a frame or two, a
643 // cold one shows the loader; either way the reader finds it
644 // already at the bottom, never mid-snap. The reveal is a hard cut
645 // (no animation): with messages in hand, switching chats must
646 // read as instant, not a cross-fade.
647 .opacity(model.ready && settled ? 1 : 0)
648 .overlay {
649 if !model.ready {
650 ProgressView().controlSize(.small)
651 }
652 }
653 // Jump-to-latest (web parity): bottom-right of the feed, shown
654 // once jumpButtonVisible flips (the sentinel probe above). Sized to
655 // this composer's 28pt +/send discs, not the web's 40px (its
656 // composer circles ARE 40px, so 40 reads proportionate there and
657 // oversized here). Insets: trailing matches bubbleRow's 10pt row
658 // padding — plus the legacy scrollbar's width when one is shown
659 // (scrollerInset) — so the disc's right edge sits exactly on the
660 // bubbles' right edge in both scroller modes; the bottom gap
661 // equals it.
662 .overlay(alignment: .bottomTrailing) {
663 if model.ready && settled && jumpButtonVisible {
664 Button {
665 withAnimation(.easeOut(duration: 0.2)) {
666 proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
667 }
668 } label: {
669 Image(systemName: "chevron.down")
670 .font(.system(size: 12, weight: .semibold))
671 .frame(width: 28, height: 28)
672 .background(.regularMaterial, in: Circle())
673 .overlay(Circle().strokeBorder(.quaternary, lineWidth: 1))
674 }
675 .buttonStyle(.plain)
676 .accessibilityLabel("Jump to latest")
677 .help("Jump to latest") // mouse-hover tooltip (the web button's title=)
678 .padding(.trailing, 10 + scrollerInset)
679 .padding(.bottom, 10)
680 .transition(.opacity)
681 }
682 }
683 }
684 // The thread view lifts over the FEED pane only (the web's
685 // .chatapp-feedwrap overlay) — applied before the composer inset, so
686 // the real composer below stays live, staged to the thread's root.
687 .overlay {
688 if let rootID = threadRootID {
689 threadOverlay(rootID)
690 .environment(\.messageMenuScope, threadMenuScope)
691 .transition(.opacity.combined(with: .offset(y: 12)))
692 }
693 }
694 // spacing 0: the default safe-area spacing left a visible dead strip
695 // between the scrollbar's end (and the jump button's gap) and the
696 // composer's top edge.
697 .safeAreaInset(edge: .bottom, spacing: 0) {
698 // An ended DM (#578): the server refuses new messages, so the
699 // composer gives way to the closed banner — the one honest action
700 // left is Reopen.
701 if model.dmClosed {
702 dmClosedBanner
703 } else {
704 // The material band lives on the input row INSIDE `composer`,
705 // never on this whole inset: staged previews/banners float
706 // transparent above it, so the band can't grow and curtain the
707 // chat (the failure that got the original full-inset .bar slab
708 // removed).
709 composer
710 }
711 }
712 // Tamper-evidence failure banner (docs/tamper-evidence.md) — only when
713 // this space fails an integrity check; "What's this?" fills the report
714 // alert below.
715 .safeAreaInset(edge: .top, spacing: 0) {
716 if let instance {
717 IntegrityBanner(monitor: NativeFeedModelCache.shared.integrityMonitor(for: instance)) {
718 integrityReport = NativeFeedModelCache.shared
719 .integrityMonitor(for: instance).reportLines().joined(separator: "\n")
720 }
721 }
722 }
723 .alert("Integrity check", isPresented: .init(
724 get: { integrityReport != nil }, set: { if !$0 { integrityReport = nil } })
725 ) {
726 Button("OK", role: .cancel) { integrityReport = nil }
727 } message: {
728 Text(integrityReport ?? "")
729 }
730 .task(id: instance?.id) {
731 if let instance { await NativeFeedModelCache.shared.pollIntegrity(for: instance) }
732 }
733 // Track the feed's window frame so the swipe monitor only claims
734 // scroll events that happen over it (read lazily — see paneFrame).
735 .trackingFrame(into: paneFrame)
736 .onAppear {
737 model.start() // idempotent — a parked model is already live
738 frames.isActive = isActive
739 installSwipeMonitor()
740 registerForDrops()
741 installProjector()
742 }
743 .onChange(of: isActive) { _, active in
744 frames.isActive = active
745 // Release or claim the composer with the pane: a hidden pane's
746 // stale focused=true would keep async-stealing first responder
747 // from the visible pane's field (the old @FocusState dropped
748 // itself automatically; the representable's @State cannot).
749 composerFocused = active
750 }
751 .onDisappear {
752 // Deliberately NOT model.stop(): parked models keep their socket
753 // (the FeedWebCache rule); the cache's eviction stops them.
754 if let swipeMonitor { NSEvent.removeMonitor(swipeMonitor) }
755 swipeMonitor = nil
756 // One last capture at teardown, belt to the throttle's braces —
757 // captureAnchor no-ops if the row frames have already been
758 // cleared by the children's own teardown (order isn't
759 // guaranteed), in which case the throttled capture stands.
760 frames.anchorDebounce?.cancel()
761 frames.anchorDebounce = nil
762 captureAnchor()
763 if let key = instance?.origin {
764 PaneDropRouter.shared.unregister(key, token: dropToken)
765 }
766 }
767 // Drop-to-attach is NOT a per-feed .onDrop: every visited space stays
768 // alive in RootView's ZStack, and drop-region resolution picks the
769 // topmost region regardless of opacity/allowsHitTesting — a hidden
770 // pane's region either stole the drop (staging into the wrong space's
771 // composer) or, gated, dead-ended the drag with no + badge at all.
772 // Instead RootView owns the ONE drop surface for the whole window
773 // and PaneDropRouter (top of file) hands each drop to whichever
774 // registered feed belongs to the pane selected at drag time.
775 // @mentions: the roster index bubbles style "@Name" runs against
776 // (feed rows and the reply-thread view alike), and the chip-tap
777 // interception — a MentionLink URL opens the member's profile card;
778 // every real link keeps the system route.
779 .environment(\.mentionIndex, model.mentionIndex)
780 .environment(\.openURL, OpenURLAction { url in
781 guard let uid = MentionLink.uid(from: url), let onMemberProfile
782 else { return .systemAction }
783 onMemberProfile(uid)
784 return .handled
785 })
786 }
787
788 private func updateJumpButtonVisible(gap: CGFloat) {
789 let show = gap > Self.jumpButtonGap
790 if show != jumpButtonVisible { jumpButtonVisible = show }
791 }
792
793 // Register this feed as its pane's drop receiver for as long as it is
794 // mounted — visibility is the drop surface's problem, not ours. A
795 // default-command thread (text-only surface) or an instance-less mount
796 // (the Debug diff pane) registers nothing, so the drag shows no + badge
797 // there — same behaviour the per-feed type-list gate used to give.
798 private func registerForDrops() {
799 guard defaultCommand == nil, let key = instance?.origin else { return }
800 PaneDropRouter.shared.register(key, token: dropToken) { stageDrop($0) }
801 }
802
803 static let bottomSentinel = "bottom-sentinel"
804
805 // ---- per-thread scroll memory -------------------------------------------
806 // The anchor is the row nearest the viewport's centre plus its fractional
807 // travel — the same reference the prepend re-anchor uses — stored in the
808 // model cache so it outlives this view's per-thread teardown. Captured on
809 // a debounce after wheel scrolling goes quiet; cleared whenever a bottom
810 // pin verifiably lands (being at the bottom IS the no-anchor state).
811
812 private func savedAnchor() -> NativeFeedModelCache.FeedScrollAnchor? {
813 guard let iid = instance?.id else { return nil }
814 return NativeFeedModelCache.shared.scrollAnchor(instance: iid, thread: threadID)
815 }
816
817 private func storeAnchor(_ anchor: NativeFeedModelCache.FeedScrollAnchor?) {
818 guard let iid = instance?.id else { return }
819 NativeFeedModelCache.shared.setScrollAnchor(anchor, instance: iid, thread: threadID)
820 }
821
822 // Read the current position out of the frame store. Near the bottom
823 // (the newest row materialised by the viewport) counts as AT the bottom —
824 // clearing the anchor — so a reader hovering a few points up isn't
825 // "restored" away from arriving messages next time.
826 private func captureAnchor() {
827 guard instance != nil, frames.viewport.height > 0, !frames.rows.isEmpty else { return }
828 if let last = model.messages.last?.id, let f = frames.rows[last],
829 f.maxY <= frames.viewport.height + 150 {
830 storeAnchor(nil)
831 return
832 }
833 let mid = frames.viewport.height / 2
834 guard let ref = frames.rows
835 .filter({ $0.value.minY >= 0 && $0.value.maxY <= frames.viewport.height })
836 .min(by: { abs($0.value.midY - mid) < abs($1.value.midY - mid) })
837 else {
838 // No fully-visible row — one taller-than-the-viewport bubble (a
839 // long code block) fills the screen. Anchor its top; scrollTo
840 // can't express "row top minus 300pt", so this is the closest
841 // honest restore.
842 if let giant = frames.rows.first(where: { $0.value.minY < mid && $0.value.maxY > mid }) {
843 storeAnchor(.init(rowID: giant.key, fraction: 0))
844 }
845 return
846 }
847 let denom = frames.viewport.height - ref.value.height
848 let fraction = denom > 0 ? max(0, min(1, ref.value.minY / denom)) : 0
849 storeAnchor(.init(rowID: ref.key, fraction: fraction))
850 }
851
852 // Put the saved anchor's row back where it was and VERIFY it landed —
853 // pinUntilSettled's shape, aimed at a row instead of the bottom. Rows
854 // materialise with real heights after the first jump, so one round is
855 // rarely enough. A vanished anchor row (evicted history, a deleted
856 // message) falls back to the ordinary bottom pin, anchor cleared.
857 private func restoreUntilSettled(_ proxy: ScrollViewProxy,
858 _ anchor: NativeFeedModelCache.FeedScrollAnchor,
859 attempts: Int = 12) {
860 guard model.ready else { return }
861 guard model.messages.contains(where: { $0.id == anchor.rowID }) else {
862 storeAnchor(nil)
863 pinUntilSettled(proxy)
864 return
865 }
866 proxy.scrollTo(anchor.rowID, anchor: UnitPoint(x: 0, y: anchor.fraction))
867 guard attempts > 0 else {
868 settled = true // close enough — reveal rather than hide forever
869 return
870 }
871 DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
872 if let f = frames.rows[anchor.rowID], frames.viewport.height > 0 {
873 let denom = frames.viewport.height - f.height
874 let want = denom > 0 ? anchor.fraction * denom : 0
875 if abs(f.minY - want) <= 2 {
876 settled = true
877 return
878 }
879 }
880 restoreUntilSettled(proxy, anchor, attempts: attempts - 1)
881 }
882 }
883
884 // A COLD feed stays invisible until the pin is verified flush — the reader
885 // should find the chat already at the bottom, never watch it snap there.
886 // A parked one starts settled (see init) so switching chats is instant.
887 @State private var settled: Bool
888 // The open's "click → usable feed" measurement, the iOS feed.mount twin:
889 // begun at the opening appear, ended one turn after `settled` commits so
890 // the reveal frame's layout is inside it. Same span name — the ledger
891 // report digests both platforms through one seam.
892 @State private var mountSpan: Perf.Span?
893
894 // Whether the newest row is genuinely resting at the viewport bottom (a
895 // tall chat pinned) or, for a chat too short to fill the viewport, fully
896 // visible from the top. This replaces the bare `maxY <= viewport.height + 2`
897 // verify, which ALSO matched a feed stranded ABOVE the viewport — bug #900:
898 // on an open, a layout race can leave the scroll offset past the content's
899 // end, every row with a NEGATIVE maxY (last row maxY ≈ -696), and
900 // `-696 <= 855` "revealed" empty space beyond the last message (blank until
901 // you scrolled). A genuinely-pinned last row has a POSITIVE maxY near the
902 // bottom; a stranded one is negative. (The bottom safe-area inset — the
903 // composer — means a pinned maxY lands SHORT of viewport.height, so we
904 // can't require maxY ≈ height; "in the lower half and not below the bottom
905 // edge" is the robust band for a tall chat.)
906 private func lastRowFlush() -> Bool {
907 guard let id = model.messages.last?.id, let f = frames.rows[id] else { return false }
908 let vh = frames.viewport.height
909 guard vh > 0 else { return false }
910 // Short chat: the whole log fits (content height = last.maxY -
911 // first.minY, independent of the offset), top-aligned — flush means the
912 // first row starts at/after the top and the last row's bottom isn't
913 // shoved past the viewport bottom.
914 if let firstID = model.messages.first?.id, let ff = frames.rows[firstID],
915 f.maxY - ff.minY <= vh {
916 return ff.minY >= -2 && f.maxY <= vh + 2
917 }
918 // Tall chat: the newest row's bottom must be in the lower half of the
919 // viewport and not below its bottom edge — never the negative maxY of
920 // the stranded-above wedge.
921 return f.maxY > vh * 0.5 && f.maxY <= vh + 2
922 }
923
924 // Pin to the bottom sentinel and VERIFY it landed: the frame store knows
925 // where the last row actually is, so re-pin (a few frames apart) until its
926 // bottom sits at the viewport's bottom or the attempts run out. Bails if
927 // the last message changes underneath (the chat moved on).
928 //
929 // The WEDGE needs more than retries: after a send, the composer-inset
930 // shrink can clamp the scroll offset past the content's end with ZERO rows
931 // materialised — and against a fully dematerialised lazy stack, the same
932 // scrollTo(bottom) computes against nothing every round (the blank-feed
933 // bug's stubborn form). When a verify round finds that state, jolt the
934 // offset back into range by scrolling to the FIRST message (top) — which
935 // forces materialisation — then resume pinning to the bottom. And never
936 // give up while genuinely wedged: park and retry on a slower cadence
937 // (bounded) instead of leaving a blank pane until the next arrival.
938 private func pinUntilSettled(_ proxy: ScrollViewProxy, attempts: Int = 12,
939 rescues: Int = 4) {
940 guard model.ready else { return } // the ready flip re-enters
941 guard let id = model.messages.last?.id else {
942 settled = true // nothing to pin (empty chat)
943 return
944 }
945 proxy.scrollTo(Self.bottomSentinel, anchor: .bottom)
946 guard attempts > 0 else {
947 // Out of fast retries. If the newest row is genuinely at the
948 // bottom, reveal. Otherwise the feed is still stranded (rows above/
949 // below the viewport, or none materialised) — jolt to a valid
950 // anchor and retry on a slower cadence (bounded by `rescues`)
951 // rather than revealing a blank pane.
952 if rescues > 0, !lastRowFlush(), !model.messages.isEmpty {
953 if let first = model.messages.first?.id {
954 proxy.scrollTo(first, anchor: .top)
955 }
956 DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
957 pinUntilSettled(proxy, attempts: 6, rescues: rescues - 1)
958 }
959 } else {
960 settled = true // flush, or out of rescues — reveal rather than hide forever
961 }
962 return
963 }
964 DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
965 guard model.messages.last?.id == id else {
966 settled = true
967 return
968 }
969 if lastRowFlush() {
970 settled = true // genuinely pinned to the bottom — reveal
971 // A verified bottom IS the no-anchor state: an own-send (or
972 // arrival) pin that yanked a scrolled-back reader down makes
973 // any saved anchor stale — the next open should land here,
974 // at the newest, not back at the old spot.
975 storeAnchor(nil)
976 // Flush NOW isn't flush in a beat: a send clears the staged
977 // strip a moment after this verify passes, the composer inset
978 // shrinks, and the content shifts under the satisfied pin —
979 // an image bubble lands below the fold (Paul's screenshot).
980 // Confirm the pin HELD; if it slipped either way (pushed below
981 // OR stranded above), keep pinning.
982 DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
983 guard model.messages.last?.id == id, !lastRowFlush() else { return }
984 pinUntilSettled(proxy, attempts: max(attempts - 1, 2), rescues: rescues)
985 }
986 return
987 }
988 // Not flush: the offset is stranded — rows above/below the viewport
989 // (the #900 blank: rows materialised but all with negative maxY), or
990 // none materialised. scrollTo(sentinel) can't pull a past-the-end
991 // offset back on its own, so jolt to a materialised anchor (the
992 // first message, at the top) to force a valid offset + real row
993 // heights, THEN — a beat later, so the top scroll actually takes —
994 // re-pin the bottom. Recursing with no gap lets the same
995 // estimated-height overshoot recur every round, which is what made
996 // the stranded open sit blank for ~a second before the slow rescue
997 // path finally landed it. (Previously gated on frames.rows.isEmpty,
998 // so a stranded-but-materialised feed never recovered at all.)
999 if let first = model.messages.first?.id {
1000 proxy.scrollTo(first, anchor: .top)
1001 DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
1002 pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues)
1003 }
1004 } else {
1005 pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues)
1006 }
1007 }
1008 }
1009
1010 // Messages' two-finger reveal: horizontal-dominant scrolls over the feed
1011 // drag the time gutter open (swipe left) or closed (swipe right),
1012 // snapping at release; vertical scrolling passes through untouched. A
1013 // local event monitor rather than an overlay view — an NSView in the way
1014 // would have to fight the scroll view for every vertical event too.
1015 private func installSwipeMonitor() {
1016 let frames = frames
1017 swipeMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in
1018 // Hidden panes (opacity 0 in RootView's ZStack) share this frame —
1019 // only the on-screen pane may claim the swipe.
1020 guard frames.isActive,
1021 let window = event.window,
1022 let content = window.contentView else { return event }
1023 // Window coords are bottom-left; SwiftUI's .global is top-left.
1024 let p = event.locationInWindow
1025 let point = CGPoint(x: p.x, y: content.bounds.height - p.y)
1026 guard paneFrame.rect.contains(point) else { return event }
1027
1028 // Any scroll over this pane captures the position: throttled
1029 // DURING the scroll (so a switch right after a fling still has a
1030 // near-current anchor — the trailing capture alone lost that
1031 // race), plus a trailing debounce for the exact resting spot.
1032 let now = ProcessInfo.processInfo.systemUptime
1033 if now - frames.lastCaptureAt > 0.15 {
1034 frames.lastCaptureAt = now
1035 captureAnchor()
1036 }
1037 frames.anchorDebounce?.cancel()
1038 let capture = DispatchWorkItem { captureAnchor() }
1039 frames.anchorDebounce = capture
1040 DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: capture)
1041
1042 // The release event arrives with ZERO deltas, so it must be
1043 // handled before any horizontal-dominance check — that guard
1044 // silently ate it and the gutter froze wherever it was left.
1045 if event.phase == .ended || event.phase == .cancelled {
1046 guard draggingTimes else { return event }
1047 draggingTimes = false
1048 // Spring-loaded release: settle open or shut with a gentle bounce.
1049 withAnimation(.spring(response: 0.35, dampingFraction: 0.65)) {
1050 timesReveal = timesReveal > Self.timeGutter / 2 ? Self.timeGutter : 0
1051 }
1052 return nil
1053 }
1054 // Momentum after a horizontal fling: swallow it so the feed
1055 // doesn't sideways-scroll, but don't keep dragging the gutter.
1056 if event.momentumPhase != [] {
1057 return abs(event.scrollingDeltaX) > abs(event.scrollingDeltaY) ? nil : event
1058 }
1059 guard abs(event.scrollingDeltaX) > abs(event.scrollingDeltaY) else { return event }
1060
1061 // Natural scrolling: fingers left → negative deltaX → reveal grows.
1062 draggingTimes = true
1063 timesReveal = min(Self.timeGutter, max(0, timesReveal - event.scrollingDeltaX))
1064 return nil // consumed
1065 }
1066 }
1067
1068 // Bubble row with run-grouping, mirroring the web feed: the sender name
1069 // shows once per run (groups only), and bubbles inside a run sit tighter
1070 // than bubbles that start one.
1071 // One Equatable key per feed row. SwiftUI can't prove our rows unchanged
1072 // on its own — each body pass hands MessageBubble ~nine fresh closures —
1073 // so ANY model tick (someone typing, a sender name arriving, status
1074 // flips) re-ran every visible bubble's body: seconds of main-thread
1075 // render work around activation (issue #1071, the "hangs when I load
1076 // in" bug; ClickLab wedge samples fingerprinted bubbleRow/MessageBubble).
1077 // FeedRow compares THIS key instead and skips the body when nothing the
1078 // row draws has changed.
1079 // ⚠️ Every render input must be listed — a field missing from the key
1080 // means its changes stop repainting rows.
1081 private struct FeedRowKey: Equatable {
1082 let message: ChatFeedModel.Message
1083 let previous: ChatFeedModel.Message? // grouping: separator + same-sender run
1084 let mutedFold: Bool
1085 let senderName: String
1086 let canEdit: Bool // messageActions' one state read
1087 let isGroup: Bool
1088 let themeName: String
1089 let stageWidth: CGFloat
1090 let timesReveal: CGFloat
1091 let downloadingID: String?
1092 let downloadingProgress: Double?
1093 }
1094
1095 private struct FeedRow: View, Equatable {
1096 let key: FeedRowKey
1097 // A CLOSURE, not an eager AnyView: building the row value (bubble +
1098 // nine closures + actions) per pass costs real time at -Onone even
1099 // when == then skips the body — lazy content means a skipped row
1100 // constructs nothing at all.
1101 let content: () -> AnyView
1102 static func == (a: Self, b: Self) -> Bool { a.key == b.key }
1103 var body: some View { content() }
1104 }
1105
1106 private func rowKey(_ m: ChatFeedModel.Message,
1107 previous: ChatFeedModel.Message?) -> FeedRowKey {
1108 FeedRowKey(message: m, previous: previous,
1109 mutedFold: model.foldsAsMuted(m),
1110 senderName: model.senderName(m),
1111 canEdit: model.canEdit(m),
1112 isGroup: isGroup, themeName: themeName,
1113 stageWidth: stageWidth, timesReveal: timesReveal,
1114 downloadingID: videoDownload?.id,
1115 downloadingProgress: videoDownload?.progress)
1116 }
1117
1118 @ViewBuilder
1119 private func row(_ m: ChatFeedModel.Message, previous: ChatFeedModel.Message?,
1120 proxy: ScrollViewProxy) -> some View {
1121 if m.action {
1122 // A /me-style app action line — centred, muted, no bubble
1123 // (style.css .chatapp-action).
1124 Text(m.text)
1125 .font(.system(size: 13))
1126 .foregroundStyle(.secondary)
1127 .multilineTextAlignment(.center)
1128 .frame(maxWidth: .infinity)
1129 .padding(EdgeInsets(top: 3, leading: 14, bottom: 3, trailing: 14))
1130 } else if model.foldsAsMuted(m) {
1131 mutedRow(m, previous: previous)
1132 } else {
1133 bubbleRow(m, previous: previous, proxy: proxy)
1134 }
1135 }
1136
1137 // A muted member's message (#578): the quiet stub, revealed per-message
1138 // for the session. Keeps the run/separator grouping so revealing it never
1139 // reshuffles the feed around it. Always a "them" row — own messages and
1140 // uid-less legacy ones never fold (foldsAsMuted).
1141 @ViewBuilder
1142 private func mutedRow(_ m: ChatFeedModel.Message, previous: ChatFeedModel.Message?) -> some View {
1143 let separator = timeSeparator(at: m.at, previous: previous?.at)
1144 let startsRun = separator != nil || !ChatFeedModel.sameSender(previous, m)
1145 if let separator {
1146 separatorRow(separator, at: m.at)
1147 }
1148 HStack(spacing: 0) {
1149 MutedMessageStub { model.revealMuted(m.id) }
1150 Spacer(minLength: 48)
1151 }
1152 .padding(EdgeInsets(top: startsRun ? 8 : 2, leading: 14,
1153 bottom: 2, trailing: 10))
1154 }
1155
1156 // The dated/time-only run separator — one builder for bubble and muted rows.
1157 private func separatorRow(_ separator: TimeSeparator, at: Int64) -> some View {
1158 HStack(spacing: 4) {
1159 if separator == .dated {
1160 Text(chatDayLabel(at)).fontWeight(.semibold)
1161 }
1162 Text(chatTime(at))
1163 }
1164 .font(.system(size: 12))
1165 .foregroundStyle(.secondary)
1166 .frame(maxWidth: .infinity)
1167 .padding(.top, 14)
1168 .padding(.bottom, 4)
1169 }
1170
1171 @ViewBuilder
1172 private func bubbleRow(_ m: ChatFeedModel.Message, previous: ChatFeedModel.Message?,
1173 proxy: ScrollViewProxy) -> some View {
1174 // The web's exact grouping (chat.js viewItems): a dated separator when
1175 // the feed opens or the day changes, time-only past a 15-minute gap;
1176 // a same-sender run only continues when no separator intervenes.
1177 let separator = timeSeparator(at: m.at, previous: previous?.at)
1178 let startsRun = separator != nil || !ChatFeedModel.sameSender(previous, m)
1179 if let separator {
1180 separatorRow(separator, at: m.at)
1181 }
1182 HStack(spacing: 0) {
1183 if m.mine { Spacer(minLength: 48) }
1184 MessageBubble(message: m, showSender: isGroup && startsRun,
1185 senderName: model.senderName(m),
1186 theme: theme,
1187 onJump: { target in jump(to: target, proxy: proxy) },
1188 onOpenThread: { root in openThread(root) },
1189 blobSource: { att in model.blobSource(att) },
1190 onImageTap: { att, image, frame in
1191 app.lightbox = LightboxItem(id: att.id, name: att.name,
1192 image: image, sourceFrame: frame)
1193 upgradeLightbox(att, frame: frame)
1194 },
1195 onFileTap: { att in download(att) },
1196 onVideoTap: { att, frame in playVideo(att, from: frame) },
1197 downloading: videoDownload,
1198 actions: messageActions(m, times: true),
1199 dragItem: { att in
1200 guard att.isImage || att.isVideo else { return nil }
1201 return AttachmentDragItem(filename: att.name ?? (att.isImage ? "photo.jpg" : "video.mp4"),
1202 mimeType: att.type, isImage: att.isImage,
1203 fetch: { await model.blobData(att) })
1204 },
1205 unfurl: { url in
1206 let card = await model.unfurl(url)
1207 // The link card loads AFTER the send pin ran and
1208 // grows the bubble below the viewport — if this
1209 // is the last row and the reader is at the
1210 // bottom, re-pin so the posted link stays
1211 // visible (verified, like every other pin).
1212 if card != nil, m.id == model.messages.last?.id,
1213 let f = frames.rows[m.id],
1214 f.minY < frames.viewport.height + 150 {
1215 DispatchQueue.main.async { pinUntilSettled(proxy) }
1216 }
1217 return card
1218 },
1219 onYouTube: { videoID, start, frame in
1220 app.youtubeLightbox = YouTubeLightboxItem(videoID: videoID, start: start,
1221 sourceFrame: frame)
1222 })
1223 // A FIXED-width stage for the bubble (it aligns to its side
1224 // within), sized as if the gutter were always present. Only
1225 // YOUR bubbles slide (explicit offset) when times reveal —
1226 // their bubbles are already clear of the gutter and never move.
1227 .frame(width: stageWidth,
1228 alignment: m.mine ? .trailing : .leading)
1229 .offset(x: m.mine ? -timesReveal : 0)
1230 if !m.mine { Spacer(minLength: 48) }
1231 }
1232 // The time never participates in layout — it's a trailing overlay that
1233 // slides/fades in place, so row widths are constant and the LazyVStack
1234 // can't re-centre anything as the gutter reveals.
1235 .overlay(alignment: .trailing) {
1236 if timesReveal > 0 {
1237 Text(chatTime(m.at))
1238 .font(.system(size: 11))
1239 .foregroundStyle(.secondary)
1240 .fixedSize()
1241 .offset(x: (Self.timeGutter - timesReveal) * 0.6)
1242 .opacity(timesReveal / Self.timeGutter)
1243 }
1244 }
1245 // Leading 14 aligns the bubbles' left gutter with the chat header's
1246 // (its avatar sits at 14pt).
1247 .padding(EdgeInsets(top: startsRun ? 8 : 2, leading: 14,
1248 bottom: 2, trailing: 10))
1249 .jumpWave(on: m.id == danceID, tick: danceTick)
1250 }
1251
1252 // The list ⌘R / ⌘T read — see the tick handlers above.
1253 private var hotkeyMessages: [ChatFeedModel.Message] {
1254 guard let rootID = threadRootID else { return model.messages }
1255 return ChatFeedModel.threadItems(in: model.messages, root: rootID)
1256 }
1257
1258 // Materialised (an unrealised row has no catcher to pop a menu from) and
1259 // wholly inside the viewport, so the menu hangs off a bubble the reader
1260 // can see.
1261 private func rowOnScreen(_ id: Int64) -> Bool {
1262 guard let f = frames.rows[id] else { return false }
1263 return f.minY >= 0 && f.maxY <= frames.viewport.height
1264 }
1265
1266 // The native composer — the first native SEND path: the message is
1267 // encrypted in Swift (ChatFeedModel.send) and framed straight onto the
1268 // native WebSocket. Return sends (desktop shouldSend), ⌥Return newlines.
1269 // The active typeahead: the token under the caret + its ranked candidates.
1270 @State private var taToken: ComposerToken?
1271 @State private var taItems: [TypeaheadItem] = []
1272 @State private var taIndex = 0
1273
1274 // The ended-DM banner that takes the composer's place (#578): inline in
1275 // the composer's layout slot — never floating over the feed. A refused
1276 // reopen swaps the line for its reason (their DM door / a blip), the
1277 // web's dm-closed-bar behaviour.
1278 @State private var reopenBusy = false
1279 @State private var reopenNote: String?
1280
1281 private var dmClosedBanner: some View {
1282 HStack(spacing: 10) {
1283 Text(reopenNote ?? "This conversation is closed.")
1284 .font(.system(size: 13))
1285 .foregroundStyle(.secondary)
1286 .frame(maxWidth: .infinity, alignment: .leading)
1287 Button("Reopen") {
1288 guard let onReopenDM else { return }
1289 reopenNote = nil
1290 reopenBusy = true
1291 Task {
1292 switch await onReopenDM() {
1293 case .ok:
1294 break // the flag flips (store + model); the composer returns
1295 case .dmsClosed:
1296 reopenNote = "They're not accepting direct messages right now."
1297 case .failed:
1298 reopenNote = "Couldn't reopen — try again."
1299 }
1300 reopenBusy = false
1301 }
1302 }
1303 .disabled(reopenBusy || onReopenDM == nil)
1304 .accessibilityHint("Reopens this conversation for both of you")
1305 }
1306 .padding(.horizontal, 12)
1307 .padding(.vertical, 10)
1308 .frame(maxWidth: .infinity)
1309 .background(FrostedBand())
1310 }
1311
1312 private var composer: some View {
1313 VStack(alignment: .leading, spacing: 4) {
1314 if !taItems.isEmpty, let token = taToken {
1315 TypeaheadStrip(items: taItems, selected: taIndex,
1316 mentionAvatar: mentionAvatar) { pick($0, token: token) }
1317 .padding(.horizontal, 10)
1318 }
1319 // A command's results panel (the command-host webview — the page
1320 // draws its own header + ✕): in-flow above the banners, occupying
1321 // layout like the web's .cmd-panel, sized by the page's measure.
1322 // Mounted PERMANENTLY at height 0 once the host exists, for three
1323 // reasons: revealing by height (not insertion) animates as a real
1324 // ease-up instead of SwiftUI's insertion fade; the page always has
1325 // the pane's true width, so its height measure is right the first
1326 // time (no post-reveal jump); and an attached webview keeps its
1327 // rendering callbacks live.
1328 if let host = commandHost {
1329 // The command-host page suppresses its own border/shadow (the
1330 // webview is edge-to-edge there) — this frame is the panel's
1331 // chrome: a hairline and a subtle lift, matching the web's
1332 // .cmd-panel. Both fade with visibility so the parked 0-height
1333 // frame leaves no ghost line above the composer.
1334 CommandPanelWebView(host: host)
1335 .frame(height: panelVisible ? panelHeight : 0)
1336 .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
1337 .overlay(
1338 RoundedRectangle(cornerRadius: 10, style: .continuous)
1339 .strokeBorder(.primary.opacity(panelVisible ? 0.08 : 0), lineWidth: 1)
1340 )
1341 .shadow(color: .black.opacity(panelVisible ? 0.10 : 0), radius: 7, y: 2)
1342 .padding(.horizontal, 8)
1343 .allowsHitTesting(panelVisible)
1344 .accessibilityHidden(!panelVisible)
1345 }
1346 if let replyTo {
1347 composerBanner("Replying to \(replyTo.name ?? "")",
1348 detail: replyTo.snippet) { cancelReply() }
1349 }
1350 if editingID != nil {
1351 composerBanner("Editing message", detail: nil) { cancelEdit() }
1352 }
1353 VStack(spacing: 0) {
1354 // A default command's push-back ("when should I remind you?")
1355 // — inline above the composer, occupying layout, never floating.
1356 if let routeNotice {
1357 Text(routeNotice)
1358 .font(.caption)
1359 .foregroundStyle(.secondary)
1360 .frame(maxWidth: .infinity, alignment: .leading)
1361 .padding(.horizontal, 12)
1362 .padding(.top, 6)
1363 }
1364 composerRow
1365 // Transient feedback only (downloads, upload errors) — no
1366 // permanent status line.
1367 if let busyText {
1368 Text(busyText)
1369 .font(.caption2)
1370 .foregroundStyle(.secondary)
1371 .frame(maxWidth: .infinity)
1372 .padding(4)
1373 }
1374 }
1375 .frame(maxWidth: .infinity)
1376 // iMessage-style: a within-window frost (FrostedBand) — matches the
1377 // chat stream's surface exactly at rest (blurring the same window
1378 // background it sits on) while messages scrolling underneath ghost
1379 // through the glass. Only the input row wears it — the typeahead/
1380 // banners above stay transparent floaters.
1381 .background(FrostedBand())
1382 }
1383 .onChange(of: draft) { _, text in
1384 refreshTypeahead()
1385 // The typing beats: alive while there's a draft, an immediate
1386 // clear when it empties (edits don't announce themselves).
1387 model.typingBeat(active: !text.isEmpty && editingID == nil)
1388 // A "/" draft pre-warms the command host, so the page has booted
1389 // by the time Enter runs the command (a cold page queues it).
1390 if text.first == "/", commandHost == nil { ensureCommandHost() }
1391 }
1392 // Caret moves (clicks / arrows) can enter or leave a token with the
1393 // text unchanged — re-check, like the web composer's keyup handler.
1394 .onChange(of: composerCaret) { _, _ in refreshTypeahead() }
1395 }
1396
1397 // The token under the REAL caret (MacComposerField tracks it). The field
1398 // reports UTF-16, the tokenizer speaks Characters — convert at the
1399 // boundary, exactly as the iOS screens do.
1400 private func refreshTypeahead() {
1401 let utf16 = min(max(composerCaret, 0), draft.utf16.count)
1402 let idx = String.Index(utf16Offset: utf16, in: draft)
1403 let charCaret = draft.distance(from: draft.startIndex, to: idx)
1404 taToken = composerToken(draft, caret: charCaret)
1405 taItems = taToken.map {
1406 typeaheadItems($0, roster: roster, myUserId: myUserId, hasBot: hasBot,
1407 appCommands: appCommands, displayNames: model.senderNames,
1408 rows: hostRows)
1409 } ?? []
1410 taIndex = 0
1411 }
1412
1413 // Accept a candidate: splice it over the token (trigger → caret), exactly
1414 // like the web's acceptTypeahead.
1415 private func pick(_ item: TypeaheadItem, token: ComposerToken) {
1416 // Splice trigger → caret, PRESERVING the tail after the caret (the old
1417 // TextField assumed caret-at-end and dropped it). Characters → UTF-16
1418 // at the boundary; the field edits itself via the controller, so the
1419 // caret lands right after the insert.
1420 let chars = Array(draft)
1421 let utf16 = min(max(composerCaret, 0), draft.utf16.count)
1422 let idx = String.Index(utf16Offset: utf16, in: draft)
1423 let charCaret = draft.distance(from: draft.startIndex, to: idx)
1424 let start = min(token.start, chars.count)
1425 let end = min(max(charCaret, start), chars.count)
1426 let head = String(chars[0..<start])
1427 let span = String(chars[start..<end])
1428 composerController.insert(item.insert, replacingUTF16: NSRange(
1429 location: (head as NSString).length, length: (span as NSString).length))
1430 taToken = nil
1431 taItems = []
1432 composerFocused = true
1433 }
1434
1435 // The staged attachments, Messages-style: INSIDE the input pill, above
1436 // the text, so the attachment and its caption visibly travel as one
1437 // message. Images show a real rounded thumbnail with the ✕ overlapping
1438 // its corner and the upload ring over the middle; files keep a pill.
1439 private var stagedChips: some View {
1440 HStack(alignment: .top, spacing: 8) {
1441 ForEach(staged) { s in
1442 if let preview = s.preview {
1443 imageChip(s, preview: preview)
1444 } else {
1445 pillChip(s)
1446 }
1447 }
1448 }
1449 .padding(.horizontal, 12)
1450 .padding(.top, 10)
1451 }
1452
1453 private func imageChip(_ s: Staged, preview: PlatformImage) -> some View {
1454 ZStack {
1455 Image(platform: preview)
1456 .resizable()
1457 .scaledToFill()
1458 .frame(width: 96, height: 96)
1459 .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
1460 if s.attachment == nil {
1461 uploadRing(s.progress)
1462 .frame(width: 26, height: 26)
1463 }
1464 }
1465 // Video thumbs wear a small play badge so they read as clips.
1466 .overlay(alignment: .bottomLeading) {
1467 if !s.isImage {
1468 Image(systemName: "play.fill")
1469 .font(.system(size: 9))
1470 .foregroundStyle(.white)
1471 .padding(4)
1472 .background(Circle().fill(.black.opacity(0.55)))
1473 .padding(4)
1474 }
1475 }
1476 .overlay(alignment: .topTrailing) { unstageButton(s).offset(x: 7, y: -7) }
1477 .padding(.top, 7) // room for the overlapping ✕
1478 .shadow(color: .black.opacity(0.18), radius: 5, y: 2)
1479 }
1480
1481 private func pillChip(_ s: Staged) -> some View {
1482 HStack(spacing: 6) {
1483 if s.attachment == nil {
1484 uploadRing(s.progress).frame(width: 15, height: 15)
1485 } else {
1486 Text(fileIcon(s.name)).font(.system(size: 15))
1487 }
1488 Text(s.name).font(.system(size: 13)).lineLimit(1).truncationMode(.middle)
1489 unstageButton(s)
1490 }
1491 .padding(.horizontal, 9)
1492 .padding(.vertical, 6)
1493 .background(Capsule().fill(.primary.opacity(0.06)))
1494 .frame(maxWidth: 200)
1495 }
1496
1497 private func uploadRing(_ progress: Double) -> some View {
1498 ZStack {
1499 Circle().stroke(.white.opacity(0.45), lineWidth: 3)
1500 Circle()
1501 .trim(from: 0, to: max(0.03, progress))
1502 .stroke(.white, style: StrokeStyle(lineWidth: 3, lineCap: .round))
1503 .rotationEffect(.degrees(-90))
1504 .animation(.linear(duration: 0.1), value: progress)
1505 }
1506 .shadow(color: .black.opacity(0.4), radius: 2)
1507 }
1508
1509 private func unstageButton(_ s: Staged) -> some View {
1510 Button {
1511 staged.removeAll { $0.id == s.id }
1512 fireQueuedSend() // cancels or releases a queued send
1513 } label: {
1514 Image(systemName: "xmark.circle.fill")
1515 .font(.system(size: 15))
1516 .symbolRenderingMode(.palette)
1517 .foregroundStyle(.white, .gray)
1518 }
1519 .buttonStyle(.plain)
1520 }
1521
1522 private var composerRow: some View {
1523 HStack(alignment: .bottom, spacing: 8) {
1524 // The web composer's "+": stage photos and allowlisted documents.
1525 // Gone in a default-command thread (To-do) — text-only surface,
1526 // matching the web and iOS composers.
1527 if defaultCommand == nil {
1528 Button(action: pickFiles) {
1529 Image(systemName: "plus.circle.fill")
1530 .font(.system(size: 28)) // same weight as the send button
1531 // Messages.app: a real glyph on the disc — white in
1532 // dark mode, dark grey in light — not the knocked-out
1533 // transparent plus the monochrome fill gave.
1534 .symbolRenderingMode(.palette)
1535 .foregroundStyle(scheme == .dark ? Color.white : Color(white: 0.3),
1536 .tertiary)
1537 }
1538 .buttonStyle(.plain)
1539 .padding(.bottom, 4)
1540 }
1541 // ONE pill around attachments and text, the Messages read: what's
1542 // staged and what's typed are visibly the same message.
1543 VStack(alignment: .leading, spacing: 0) {
1544 if !staged.isEmpty {
1545 stagedChips
1546 }
1547 // The NSTextView-backed field (MacComposerField): a real caret —
1548 // mid-text typeahead, Shift/⌥+Return newlines AT the caret (#819),
1549 // caret-preserving splices — and the base the rich stages of #890
1550 // build on. Plain text at this stage. Typeahead keys route through
1551 // the handlers: ↑↓ move, Tab/Return accept, Esc dismisses — Return
1552 // falls through to send when no menu is up; ↑ on an empty draft
1553 // edits the last message.
1554 MacComposerField(
1555 text: $draft,
1556 focused: $composerFocused,
1557 height: $composerHeight,
1558 caret: $composerCaret,
1559 placeholder: defaultCommand?.placeholder ?? "Message",
1560 fontSize: chatFontSize, // the feed's reading size, like the web composer
1561 // Queued behind an upload = the message is committed; editing
1562 // what's about to auto-send would race it.
1563 isDisabled: sendQueued,
1564 formatAsYouType: formatAsYouType,
1565 controller: composerController,
1566 onSend: sendDraft,
1567 onMoveUp: {
1568 if !taItems.isEmpty {
1569 taIndex = (taIndex + taItems.count - 1) % taItems.count
1570 return true
1571 }
1572 if draft.isEmpty, editingID == nil,
1573 let last = model.messages.last(where: { $0.mine && !$0.action }) {
1574 startEdit(last)
1575 return true
1576 }
1577 return false
1578 },
1579 onMoveDown: {
1580 guard !taItems.isEmpty else { return false }
1581 taIndex = (taIndex + 1) % taItems.count
1582 return true
1583 },
1584 onAccept: {
1585 guard !taItems.isEmpty, let token = taToken else { return false }
1586 pick(taItems[taIndex], token: token)
1587 return true
1588 },
1589 onEscape: {
1590 if !taItems.isEmpty {
1591 taToken = nil
1592 taItems = []
1593 return true
1594 }
1595 // An open command panel is the most recently summoned
1596 // layer — Escape peels it before reply/edit below (the
1597 // page posts {panel: null} back, which collapses the
1598 // frame, so the panel's onClose hook always runs).
1599 if panelVisible {
1600 commandHost?.dismissPanel()
1601 return true
1602 }
1603 return false
1604 },
1605 // Paste an image (or a copied file) straight onto the draft —
1606 // it stages as an upload, like the web composer's paste.
1607 // Absent in a default-command thread (text-only surface), so a
1608 // text paste there stays a plain text paste.
1609 onStagePaste: defaultCommand == nil ? { stagePasteboard($0) } : nil,
1610 // A file DRAG landing on the field stages too — the backstop
1611 // behind the window-wide drop zone, so NSTextView's default
1612 // (paste the file's PATH as text) is never the outcome.
1613 onStageDrop: defaultCommand == nil ? { stageDrop($0) } : nil
1614 )
1615 // The field's height is pushed by the field itself (measured
1616 // 1–9 lines) — SwiftUI never re-asks a representable when only
1617 // its content changes.
1618 .frame(height: composerHeight)
1619 // Claim the row's full width (the + and send buttons keep
1620 // their fixed sizes) — without this the HStack centres a
1621 // short pill around the representable's measured width.
1622 .frame(maxWidth: .infinity)
1623 .padding(.horizontal, 14)
1624 .padding(.vertical, 9)
1625 // Regaining the app drops the caret into the message box, the
1626 // same courtesy the webview shell extends (focusComposer) —
1627 // but ONLY from the pane actually on screen (every instance's
1628 // pane stays mounted at opacity 0, and their writes were
1629 // stealing focus from the visible composer), and never over
1630 // an open ⌘K palette (its own handler re-asserts its field).
1631 .onReceive(NotificationCenter.default.publisher(
1632 for: NSApplication.didBecomeActiveNotification)) { _ in
1633 guard isActive, !app.showPalette else { return }
1634 DispatchQueue.main.async {
1635 if !app.showPalette { composerFocused = true }
1636 }
1637 }
1638 // Focus on open — asserted again a beat later AND when the
1639 // feed reveals: a FocusState write in onAppear races the
1640 // freshly-inserted (still invisible) field and loses.
1641 .onAppear {
1642 guard isActive else { return }
1643 composerFocused = true
1644 DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
1645 if isActive { composerFocused = true }
1646 }
1647 }
1648 .onChange(of: settled) { _, isSettled in
1649 if isSettled, isActive { composerFocused = true }
1650 }
1651 }
1652 // The web composer: a bordered pill on the panel background,
1653 // not a filled grey field.
1654 .background(RoundedRectangle(cornerRadius: 19, style: .continuous)
1655 .fill(.background))
1656 .overlay(RoundedRectangle(cornerRadius: 19, style: .continuous)
1657 .strokeBorder(.quaternary, lineWidth: 1))
1658 Button(action: sendDraft) {
1659 if sendQueued {
1660 // Queued behind an in-flight upload — the web's .send-waiting.
1661 ProgressView()
1662 .controlSize(.small)
1663 .frame(width: 28, height: 28)
1664 } else {
1665 Image(systemName: "arrow.up.circle.fill")
1666 .font(.system(size: 28))
1667 .foregroundStyle(hasContent ? AnyShapeStyle(Color.accentColor)
1668 : AnyShapeStyle(.tertiary))
1669 }
1670 }
1671 .buttonStyle(.plain)
1672 .disabled(!hasContent)
1673 .padding(.bottom, 4)
1674 }
1675 // Messages.app gives the input pill real breathing room below — the
1676 // old 6pt read as cramped against the window edge.
1677 .padding(EdgeInsets(top: 8, leading: 10, bottom: 12, trailing: 10))
1678 }
1679
1680 private var hasContent: Bool {
1681 !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !staged.isEmpty
1682 }
1683
1684 private var uploadsInFlight: Bool {
1685 staged.contains { $0.attachment == nil }
1686 }
1687
1688 // Show (or clear, with nil) the default-command push-back. Every showing
1689 // bumps the generation, so an in-flight clear timer from an EARLIER
1690 // showing — even of the identical string — can never wipe this one early.
1691 private func setRouteNotice(_ text: String?) {
1692 routeNoticeGen += 1
1693 routeNotice = text
1694 guard text != nil else { return }
1695 let gen = routeNoticeGen
1696 DispatchQueue.main.asyncAfter(deadline: .now() + 8) {
1697 if routeNoticeGen == gen { routeNotice = nil }
1698 }
1699 }
1700
1701 // The thread's command host, created on first use (and pre-warmed the
1702 // moment a draft turns into a "/" — see onChange(of: draft)). Re-points
1703 // `sink` at THIS mount every call, like the model-cache re-point pattern,
1704 // so a revisited thread's cached host talks to the pane on screen.
1705 @discardableResult
1706 // The app-host projection queries (docs/native-apps.md phase 5): the
1707 // model asks the thread's command host to project app events it has no
1708 // built-in projection for. Lazy — the host webview only spins up when a
1709 // thread actually contains such an event (or a command is typed).
1710 private func installProjector() {
1711 guard let instance else { return }
1712 let thread = thread, myUserId = myUserId, ikB64 = instanceKeyB64
1713 model.projector = { eventJSON, group in
1714 let host = CommandHostCache.shared.host(for: instance, thread: thread,
1715 myUserId: myUserId, instanceKeyB64: ikB64)
1716 return await host.projectEvent(eventJSON, group: group)
1717 }
1718 }
1719
1720 private func ensureCommandHost() -> CommandHost? {
1721 guard let instance else { return nil }
1722 let host = commandHost ?? CommandHostCache.shared.host(
1723 for: instance, thread: thread, myUserId: myUserId, instanceKeyB64: instanceKeyB64)
1724 if commandHost == nil { commandHost = host }
1725 host.sink = { event in
1726 switch event {
1727 case .panel(_, let height, let animate): // the web panel draws its own header
1728 // The app's call (spec.animate): ease the reveal, or appear
1729 // instantly. The web panel skips its in-page ease in command-
1730 // host mode, so this is the ONE animation either way.
1731 panelAnimates = animate
1732 withAnimation(animate ? .easeOut(duration: 0.22) : nil) {
1733 panelHeight = min(height, 340)
1734 panelVisible = true
1735 }
1736 case .panelClosed:
1737 withAnimation(panelAnimates ? .easeIn(duration: 0.2) : nil) {
1738 panelVisible = false
1739 }
1740 case .panelHeight(let h):
1741 // Late re-measures (fonts, images) ease too — an abrupt snap
1742 // here read as a "jump" after the reveal.
1743 if panelVisible {
1744 withAnimation(panelAnimates ? .easeInOut(duration: 0.18) : nil) {
1745 panelHeight = min(h, 340)
1746 }
1747 }
1748 case .composerInsert(let text):
1749 // A panel acting on the composer (an /emoji tap) — inserted at
1750 // the caret, like the web's insertAtCaret.
1751 composerController.insert(text, replacingUTF16: nil)
1752 composerFocused = true
1753 case .notice(let from, let text):
1754 // A command's private "only visible to you" reply — the same
1755 // inline slot the default-command push-back uses.
1756 setRouteNotice(from.isEmpty ? text : "\(from) — \(text)")
1757 case .commands(let rows):
1758 // The thread's live command catalogue landed — the typeahead
1759 // and the forwarding decision follow it from here.
1760 hostRows = rows
1761 refreshTypeahead()
1762 }
1763 }
1764 // Cross-thread command routing (docs/native-apps.md phase 3): the page
1765 // asks for one narrow thread key ({needKey}) or an app's personal
1766 // thread ({needAppThread}); answer from what THIS member already
1767 // holds — never the private key.
1768 let origin = instance.origin
1769 host.keyProvider = { tid in
1770 guard let e = SharedThreadKeys.lookup(threadID: tid), e.origin == origin else { return nil }
1771 return e.key.base64urlEncodedString()
1772 }
1773 host.appThreadProvider = { [weak store] app, title in
1774 guard let store, let id = await store.openOrCreateAppThread(app: app, title: title)
1775 else { return nil }
1776 if let key = store.threads.first(where: { $0.id == id })?.threadKeyB64, !key.isEmpty {
1777 return (id, key)
1778 }
1779 return nil
1780 }
1781 if !host.commandRows.isEmpty, hostRows.isEmpty { hostRows = host.commandRows }
1782 return host
1783 }
1784
1785 // The wire form of the draft: serialized markdown when the field is
1786 // mounted (styled runs become markers — the vector-pinned twin), the raw
1787 // draft otherwise. Commands and routing always read `draft` (the display
1788 // text), mirroring the web's plain/markdown split.
1789 private var wireDraft: String {
1790 // Opted out of format-as-you-type = typed markers are literal and
1791 // must render literally: escape them; only the WYSIWYG formats.
1792 let wire = composerController.serializedMarkdown(escapeLiterals: !formatAsYouType)
1793 return wire.isEmpty ? draft : wire
1794 }
1795
1796 private func sendDraft() {
1797 // An edit replaces the message in place — no attachments/reply staging.
1798 // cancelEdit (not inline clears) so an edit inside an open thread view
1799 // hands the composer back to the thread's staged reply.
1800 if let id = editingID {
1801 if model.edit(id, text: wireDraft) { cancelEdit() }
1802 return
1803 }
1804 guard hasContent else { return }
1805 // Slash commands (only a bare text command — attachments/reply stage a
1806 // normal message). /me broadcasts an action line, /theme applies a
1807 // theme, everything else falls through to a normal send so a bot
1808 // answers it (matching the web).
1809 if staged.isEmpty, replyTo == nil, draft.first == "/" {
1810 switch SlashCommand.parse(draft, meName: model.myScreenName) {
1811 case .me(let line):
1812 if model.sendAction(line) { draft = "" }
1813 return
1814 case .setTheme(let id):
1815 themeName = id
1816 draft = ""
1817 return
1818 case .openThemes:
1819 draft = ""
1820 openThemeSettings()
1821 return
1822 case .integrity:
1823 draft = ""
1824 integrityReport = (model.integrity?.reportLines()).map { $0.joined(separator: "\n") }
1825 ?? "Integrity monitoring isn't active for this space."
1826 return
1827 case .help, .sendAsMessage:
1828 // Not the native floor. The current app-thread's own verbs
1829 // stay native (the default-command block below); everything
1830 // else consults the thread's commandItems() rows: a
1831 // bot-answered command rides to the conversation as a
1832 // message, and every framework-owned command — /emoji, /gif,
1833 // /todo, a custom app's verb, even a typo (the web engine
1834 // answers "Unknown command — try /help" as a private notice
1835 // instead of posting the literal) — forwards to the command
1836 // host. Cleared immediately, like the web composer —
1837 // runCommand consumes a command attempt synchronously (a
1838 // booting page queues it).
1839 let ownVerb = defaultCommand.map {
1840 AppDefaultCommand.routing(draft, spec: $0) != .passthrough
1841 } ?? false
1842 if !ownVerb, let host = ensureCommandHost(),
1843 CommandRouting.route(draft, rows: hostRows.isEmpty ? host.commandRows : hostRows) == .host {
1844 cmdLog.notice("forwarding to the command host: \(draft, privacy: .public)")
1845 host.run(draft)
1846 draft = "" // onChange(of: draft) ends the typing beat
1847 return
1848 }
1849 break // fall through — a bot answers /help and bot commands
1850 }
1851 }
1852 // Default-command thread (To-do, Reminders): plain text — or one of the
1853 // app's own verbs — becomes the app event instead of a chat message
1854 // nobody reads; a bare verb is consumed, never posted. Async because
1855 // Reminders schedules server-side first; the sync routing() pre-check
1856 // decides whether the draft is ours at all (passthrough → normal send).
1857 // The routing contract is shared with iOS via routeDefaultCommand.
1858 if staged.isEmpty, replyTo == nil, let dc = defaultCommand,
1859 AppDefaultCommand.routing(draft, spec: dc) != .passthrough {
1860 // One routing in flight at a time: the draft deliberately stays
1861 // visible during the schedule round-trip, so a second Enter must
1862 // not double-create the reminder.
1863 guard !dcRouting else { return }
1864 dcRouting = true
1865 let text = draft
1866 Task { @MainActor in
1867 defer { dcRouting = false }
1868 switch await model.routeDefaultCommand(text, spec: dc) {
1869 case .sent:
1870 // Clear only what we sent — typing during the schedule
1871 // round-trip must never be wiped by a stale completion.
1872 if draft == text { draft = "" }
1873 setRouteNotice(nil) // a success retires any stale push-back
1874 case .rejected(let prompt):
1875 // The app pushing back ("when should I remind you?") — the
1876 // draft stays put to fix; this line is the instruction.
1877 setRouteNotice(prompt)
1878 case .kept, .notHandled:
1879 break // draft stays put (down socket)
1880 }
1881 }
1882 return
1883 }
1884 // Mid-upload, the send queues instead of blocking (web PR #269): the
1885 // button spins and fireQueuedSend releases it when the batch resolves.
1886 guard !uploadsInFlight else {
1887 sendQueued = true
1888 return
1889 }
1890 // Only clear once the send reaches the socket — a down socket keeps the
1891 // draft to resend, never silently drops it (the web's rule).
1892 if model.send(wireDraft, attachments: staged.compactMap(\.attachment), replyTo: replyTo) {
1893 draft = ""
1894 staged = []
1895 // Sending from an open thread view keeps it open and re-stages the
1896 // reply to its root — the new reply folds in live and the next
1897 // send continues the same thread, like iMessage (web parity).
1898 if threadRootID != nil { stageThreadReply() } else { replyTo = nil }
1899 sendQueued = false
1900 }
1901 }
1902
1903 // /themes (or a bare/unknown /theme) opens the Settings window, where the
1904 // theme picker lives — the native equivalent of the web's picker sheet.
1905 private func openThemeSettings() {
1906 NSApp.activate(ignoringOtherApps: true)
1907 if NSApp.responds(to: Selector(("showSettingsWindow:"))) {
1908 NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
1909 } else {
1910 NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
1911 }
1912 }
1913
1914 // ---- reply-thread view (chat.js openThread/closeThread/stageThreadReply) --
1915
1916 // The lifted thread panel over the dimmed feed pane. Rows reuse the exact
1917 // MessageBubble wiring the feed uses (right-click menu, lightboxes,
1918 // drag-out), so everything keeps working inside the view; reply actions in
1919 // here resolve to the thread's ROOT via the same startReply path. Escape
1920 // closes it through the reply banner's ✕ (.cancelAction → cancelReply).
1921 private func threadOverlay(_ rootID: Int64) -> some View {
1922 ReplyThreadView(model: model, rootID: rootID, fallback: threadFallback,
1923 theme: theme, isGroup: isGroup,
1924 onClose: closeThread,
1925 onRootPaged: {
1926 // The root just paged in — refresh the banner to its
1927 // live snapshot, unless an edit borrowed the composer.
1928 if editingID == nil, replyTo?.id == rootID { stageThreadReply() }
1929 },
1930 row: { m, showSender in threadBubble(m, showSender: showSender) })
1931 }
1932
1933 // The feed hands the lightbox its downsampled thumbnail so the zoom opens
1934 // instantly; the full-fidelity decode (off-main, cached under its own size
1935 // class) swaps in underneath once it lands — unless the lightbox has
1936 // already moved on or closed. (Animated GIFs decode full in the feed, so
1937 // the swap is a cache hit and a no-op.)
1938 private func upgradeLightbox(_ att: Attachment, frame: CGRect) {
1939 guard let source = model.blobSource(att) else { return }
1940 Task {
1941 guard let full = await BlobImageLoader.shared.image(source),
1942 app.lightbox?.id == att.id else { return }
1943 app.lightbox = LightboxItem(id: att.id, name: att.name,
1944 image: full, sourceFrame: frame)
1945 }
1946 }
1947
1948 // One catalog for the feed and the thread view — reply/edit/delete/copy/
1949 // react (the feed adds Show Times), plus Mute/Unmute-sender (#578) on
1950 // others' uid-carrying messages (uid-less legacy ones can't be keyed).
1951 private func messageActions(_ m: ChatFeedModel.Message, times: Bool) -> MessageActions {
1952 var actions = MessageActions(
1953 canEdit: model.canEdit(m),
1954 onReply: { startReply(m) },
1955 onEdit: { startEdit(m) },
1956 onDelete: { model.deleteMessage(m.id) },
1957 onCopy: {
1958 NSPasteboard.general.clearContents()
1959 NSPasteboard.general.setString(m.text, forType: .string)
1960 },
1961 onReact: { emoji in model.toggleReaction(m.id, emoji: emoji) })
1962 if times {
1963 actions.timesShown = timesShown
1964 actions.onToggleTimes = {
1965 withAnimation(.easeOut(duration: 0.25)) {
1966 timesReveal = timesShown ? 0 : Self.timeGutter
1967 }
1968 }
1969 }
1970 if let onMutePerson, !m.mine, let uid = m.raw.uid, !uid.isEmpty {
1971 let muted = model.mutedPeople.contains(uid)
1972 actions.senderMuted = muted
1973 actions.senderLabel = model.senderName(m)
1974 actions.onMutePerson = { onMutePerson(uid, !muted) }
1975 }
1976 // Report to the space's admins (#578) — same gate as the mute: never
1977 // your own messages, and a uid to report (legacy uid-less messages
1978 // name no one).
1979 if let onReport, !m.mine, let uid = m.raw.uid, !uid.isEmpty {
1980 actions.onReport = { onReport(m) }
1981 }
1982 return actions
1983 }
1984
1985 @ViewBuilder
1986 private func threadBubble(_ m: ChatFeedModel.Message, showSender: Bool) -> some View {
1987 // The muted fold holds inside the thread view too (#578).
1988 if model.foldsAsMuted(m) {
1989 MutedMessageStub { model.revealMuted(m.id) }
1990 } else {
1991 threadMessageBubble(m, showSender: showSender)
1992 }
1993 }
1994
1995 private func threadMessageBubble(_ m: ChatFeedModel.Message, showSender: Bool) -> some View {
1996 MessageBubble(message: m, showSender: showSender,
1997 senderName: model.senderName(m),
1998 theme: theme,
1999 blobSource: { att in model.blobSource(att) },
2000 onImageTap: { att, image, frame in
2001 app.lightbox = LightboxItem(id: att.id, name: att.name,
2002 image: image, sourceFrame: frame)
2003 upgradeLightbox(att, frame: frame)
2004 },
2005 onFileTap: { att in download(att) },
2006 onVideoTap: { att, frame in playVideo(att, from: frame) },
2007 downloading: videoDownload,
2008 actions: messageActions(m, times: false),
2009 dragItem: { att in
2010 guard att.isImage || att.isVideo else { return nil }
2011 return AttachmentDragItem(filename: att.name ?? (att.isImage ? "photo.jpg" : "video.mp4"),
2012 mimeType: att.type, isImage: att.isImage,
2013 fetch: { await model.blobData(att) })
2014 },
2015 unfurl: { url in await model.unfurl(url) },
2016 onYouTube: { videoID, start, frame in
2017 app.youtubeLightbox = YouTubeLightboxItem(videoID: videoID, start: start,
2018 sourceFrame: frame)
2019 })
2020 }
2021
2022 private func openThread(_ rootID: Int64) {
2023 guard rootID > 0, threadRootID != rootID else { return }
2024 // Freeze the header stand-in NOW (web threadFallback) — the deepest
2025 // quoted snapshot in loaded state, in case the root never pages in.
2026 threadFallback = model.threadReplyRef(root: rootID)
2027 withAnimation(.easeOut(duration: 0.18)) { threadRootID = rootID }
2028 stageThreadReply()
2029 }
2030
2031 // Stage the composer's reply on the thread's root — the view's composer
2032 // contract ("Replying to <root author>"). A reply displaces an in-flight
2033 // edit; desktop focuses the composer, like the web.
2034 private func stageThreadReply() {
2035 guard let rootID = threadRootID else { return }
2036 editingID = nil
2037 replyTo = model.threadReplyRef(root: rootID)
2038 composerFocused = true
2039 }
2040
2041 private func closeThread() {
2042 guard let rootID = threadRootID else { return }
2043 withAnimation(.easeOut(duration: 0.15)) { threadRootID = nil }
2044 threadFallback = nil
2045 // Leaving the thread drops its staged reply — never an unrelated one
2046 // staged since (an edit clears replyTo itself, so that survives).
2047 if replyTo?.id == rootID { replyTo = nil }
2048 }
2049
2050 // ---- reply / edit staging (the composer banners) ----------------------
2051
2052 private func startReply(_ m: ChatFeedModel.Message) {
2053 editingID = nil
2054 // Replying to a reply continues its thread: target the TOP message.
2055 replyTo = model.replyTarget(for: m)
2056 composerFocused = true
2057 }
2058
2059 // While the thread view is open its staged reply IS the view's composer
2060 // state — cancelling it (the banner's ✕, or Escape via .cancelAction)
2061 // means leaving the thread, web parity.
2062 private func cancelReply() {
2063 if threadRootID != nil { closeThread() } else { replyTo = nil }
2064 }
2065
2066 private func startEdit(_ m: ChatFeedModel.Message) {
2067 replyTo = nil
2068 editingID = m.id
2069 draft = m.raw.text ?? m.text
2070 composerFocused = true
2071 }
2072
2073 private func cancelEdit() {
2074 editingID = nil
2075 draft = ""
2076 // An edit inside an open thread view borrowed the composer (startEdit
2077 // clears replyTo); hand it back so the next send still continues the
2078 // thread (web cancelEdit).
2079 if threadRootID != nil { stageThreadReply() }
2080 }
2081
2082 private func composerBanner(_ title: String, detail: String?,
2083 onCancel: @escaping () -> Void) -> some View {
2084 HStack(spacing: 8) {
2085 VStack(alignment: .leading, spacing: 1) {
2086 Text(title).font(.system(size: 12, weight: .semibold))
2087 if let detail, !detail.isEmpty {
2088 Text(detail).font(.system(size: 12)).foregroundStyle(.secondary).lineLimit(1)
2089 }
2090 }
2091 Spacer()
2092 Button(action: onCancel) {
2093 Image(systemName: "xmark.circle.fill")
2094 .font(.system(size: 13))
2095 .foregroundStyle(.tertiary)
2096 }
2097 .buttonStyle(.plain)
2098 .keyboardShortcut(.cancelAction)
2099 }
2100 .padding(.horizontal, 12)
2101 .padding(.vertical, 8)
2102 // Opaque panel card: the banner floats over the feed since the inset
2103 // lost its .bar slab, and messages/typing showed straight through it.
2104 // A stroked edge, NOT a shadow — a shadow inside safeAreaInset made
2105 // the hosted scroll content vanish entirely on macOS.
2106 .background(RoundedRectangle(cornerRadius: 10, style: .continuous)
2107 .fill(theme.panel.resolved(scheme)))
2108 .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous)
2109 .strokeBorder(.primary.opacity(0.10)))
2110 .padding(.horizontal, 10)
2111 .padding(.top, 6)
2112 }
2113
2114 // A queued send resolves whenever the staged set changes: nothing left to
2115 // send cancels it; the last upload finishing releases it.
2116 private func fireQueuedSend() {
2117 guard sendQueued else { return }
2118 guard hasContent else {
2119 sendQueued = false
2120 return
2121 }
2122 if !uploadsInFlight {
2123 sendQueued = false
2124 sendDraft()
2125 }
2126 }
2127
2128 // Stage photos / allowlisted documents: read, downscale+re-encode (images),
2129 // encrypt, upload — all native (ChatFeedModel.uploadImage/uploadFile); the
2130 // chip flips from spinner to icon when its blob id comes back.
2131 private func pickFiles() {
2132 let panel = NSOpenPanel()
2133 panel.canChooseFiles = true
2134 panel.allowsMultipleSelection = true
2135 panel.begin { response in
2136 guard response == .OK else { return }
2137 for url in panel.urls { stage(url) }
2138 composerFocused = true
2139 }
2140 }
2141
2142 // Pasted content (from MacComposerField's paste override): file URLs stage
2143 // directly; raw image data stages as a PNG (like the web composer's
2144 // paste-to-upload). Returns whether anything staged, so the field knows
2145 // the paste was (partly) consumed. Drops ride their own pipeline
2146 // (stageDrop) — a drag can also carry file PROMISES, which a paste never does.
2147 private func stagePasteboard(_ pb: NSPasteboard) -> Bool {
2148 if let urls = pb.readObjects(forClasses: [NSURL.self]) as? [URL] {
2149 let files = urls.filter(\.isFileURL)
2150 if !files.isEmpty {
2151 files.forEach { stage($0) }
2152 return true
2153 }
2154 }
2155 for type in [NSPasteboard.PasteboardType.png, .tiff] {
2156 guard let data = pb.data(forType: type) else { continue }
2157 stage(data: data, name: "pasted.png", type: type == .png ? .png : .tiff)
2158 return true
2159 }
2160 return false
2161 }
2162
2163 // ONE pipeline for every AppKit drag (the window drop zone, the composer
2164 // field's backstop): real file URLs stage directly; file promises — how
2165 // most apps vend drags (CleanShot, Photos, browsers) — are received into
2166 // a temp folder and staged from there; raw image data stages as a file.
2167 // URLs win over promises when a drag carries both, so one item never
2168 // stages twice.
2169 @discardableResult
2170 private func stageDrop(_ pb: NSPasteboard) -> Bool {
2171 // Verification tap (types, never content) — drags arrive in wildly
2172 // different shapes per source app; this is how the next "my drag
2173 // doesn't work" gets diagnosed without an instrumented build.
2174 Logger.identity.info("drop pasteboard types: \((pb.types ?? []).map(\.rawValue).joined(separator: ", "), privacy: .public)")
2175 if let urls = pb.readObjects(forClasses: [NSURL.self],
2176 options: [.urlReadingFileURLsOnly: true]) as? [URL],
2177 !urls.isEmpty {
2178 urls.forEach { stage($0) }
2179 return true
2180 }
2181 if let receivers = pb.readObjects(forClasses: [NSFilePromiseReceiver.self]) as? [NSFilePromiseReceiver],
2182 !receivers.isEmpty {
2183 let dir = FileManager.default.temporaryDirectory
2184 .appendingPathComponent("drop-" + UUID().uuidString, isDirectory: true)
2185 try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
2186 for receiver in receivers {
2187 receiver.receivePromisedFiles(atDestination: dir, options: [:],
2188 operationQueue: Self.promiseQueue) { url, error in
2189 if let error {
2190 Logger.identity.error("drop promise failed: \(String(describing: error), privacy: .public)")
2191 return
2192 }
2193 DispatchQueue.main.async { stage(url) }
2194 }
2195 }
2196 return true
2197 }
2198 for type in [NSPasteboard.PasteboardType.png, .tiff] {
2199 guard let data = pb.data(forType: type) else { continue }
2200 stage(data: data, name: type == .png ? "dropped.png" : "dropped.tiff",
2201 type: type == .png ? .png : .tiff)
2202 return true
2203 }
2204 return false
2205 }
2206
2207 // Promise fulfilment happens off-main (the source app writes the file);
2208 // staging hops back to main.
2209 private static let promiseQueue: OperationQueue = {
2210 let q = OperationQueue()
2211 q.qualityOfService = .userInitiated
2212 return q
2213 }()
2214
2215 private func stage(_ url: URL) {
2216 // A dropped/picked file can be tens of MB — the read happens off main
2217 // (Data(contentsOf:) is a synchronous disk read on the staging frame,
2218 // #465's Mac twin).
2219 Task {
2220 guard let data = await Task.detached(priority: .userInitiated,
2221 operation: { try? Data(contentsOf: url) }).value
2222 else { return }
2223 stage(data: data, name: url.lastPathComponent,
2224 type: UTType(filenameExtension: url.pathExtension))
2225 }
2226 }
2227
2228 private func stage(data: Data, name: String, type: UTType?) {
2229 let isImage = type?.conforms(to: .image) ?? false
2230 let isVideo = type?.conforms(to: .movie) ?? false
2231 // Any file type stages — size is the only gate (matching the web).
2232 if isVideo, data.count > maxVideoUploadBytes {
2233 busyText = "\(name): videos are capped at 50 MB"
2234 DispatchQueue.main.asyncAfter(deadline: .now() + 3) { busyText = nil }
2235 return
2236 }
2237 let entry = Staged(name: name, isImage: isImage)
2238 staged.append(entry)
2239 if isImage {
2240 // The chip's thumbnail is a full image decode + downscale — off
2241 // main like the upload's own processing; the chip upgrades from
2242 // ring-only the moment it lands (same shape as the video poster).
2243 let entryID = entry.id
2244 Task {
2245 if let thumb = await Task.detached(priority: .userInitiated,
2246 operation: { processImage(data, maxDim: 288) }).value,
2247 let i = staged.firstIndex(where: { $0.id == entryID }) {
2248 staged[i].preview = PlatformImage(data: thumb.bytes)
2249 }
2250 }
2251 }
2252 if isVideo {
2253 // Poster thumbnails arrive async — the chip upgrades from ring-only
2254 // to a real frame the moment extraction lands.
2255 let entryID = entry.id
2256 Task {
2257 if let poster = await videoPosterImage(data: data, mimeType: type?.preferredMIMEType),
2258 let i = staged.firstIndex(where: { $0.id == entryID }) {
2259 staged[i].preview = poster
2260 }
2261 }
2262 }
2263 let onProgress: @MainActor (Double) -> Void = { p in
2264 if let i = staged.firstIndex(where: { $0.id == entry.id }) {
2265 staged[i].progress = p
2266 }
2267 }
2268 Task {
2269 let att: Attachment?
2270 if isImage {
2271 att = await model.uploadImage(data, name: name, onProgress: onProgress)
2272 } else if isVideo {
2273 att = await model.uploadVideo(data, name: name, mimeType: type?.preferredMIMEType,
2274 onProgress: onProgress)
2275 } else {
2276 att = await model.uploadFile(data, name: name, mimeType: type?.preferredMIMEType,
2277 onProgress: onProgress)
2278 }
2279 if let att, let i = staged.firstIndex(where: { $0.id == entry.id }) {
2280 staged[i].attachment = att
2281 } else {
2282 staged.removeAll { $0.id == entry.id }
2283 if att == nil { busyText = "couldn't upload \(name)" }
2284 DispatchQueue.main.asyncAfter(deadline: .now() + 3) { busyText = nil }
2285 }
2286 fireQueuedSend() // the batch resolving may release a queued send
2287 }
2288 }
2289
2290 // A pending open-on-message request (search): once this feed is the
2291 // target and ready, page back until the message is loaded, then wave.
2292 private func consumeJump(_ proxy: ScrollViewProxy) {
2293 guard let j = app.nativeJump, j.thread == threadID, model.ready else { return }
2294 app.nativeJump = nil
2295 Task {
2296 var tries = 0
2297 while !model.messages.contains(where: { $0.id == j.message }),
2298 !model.exhausted, tries < 8 {
2299 tries += 1
2300 await model.loadOlder()
2301 try? await Task.sleep(nanoseconds: 550_000_000) // loadOlder's cooldown
2302 }
2303 guard model.messages.contains(where: { $0.id == j.message }) else { return }
2304 try? await Task.sleep(nanoseconds: 150_000_000) // let rows land
2305 jump(to: j.message, proxy: proxy)
2306 }
2307 }
2308
2309 private func jump(to id: Int64, proxy: ScrollViewProxy) {
2310 guard model.messages.contains(where: { $0.id == id }) else { return }
2311 // Already fully in view (with a little headroom for the wave)? Don't
2312 // yank the feed around — just wave where it stands.
2313 if let f = frames.rows[id], f.minY >= 12, f.maxY <= frames.viewport.height - 4 {
2314 danceID = id
2315 danceTick += 1
2316 return
2317 }
2318 // Not visible: scroll it near the top, leaving headroom above so the
2319 // wave's tiptoe stretch + wag never clip at the viewport edge.
2320 withAnimation { proxy.scrollTo(id, anchor: UnitPoint(x: 0, y: 0.06)) }
2321 // Let the scroll land first, then dance.
2322 DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
2323 danceID = id
2324 danceTick += 1
2325 }
2326 }
2327
2328 // Download an attachment: fetch + decrypt, save straight to ~/Downloads
2329 // (unique name, Finder reveal) — the same Safari-like convention the
2330 // webview's WKDownload path uses. Writing plaintext is fine here: it's the
2331 // member's own deliberate save, exactly like the web's file-chip click.
2332 private func download(_ att: Attachment) {
2333 Task {
2334 guard let data = await model.blobData(att) else { return }
2335 save(data, name: att.name ?? "file")
2336 }
2337 }
2338
2339 private func save(_ data: Data, name: String) {
2340 let dir = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
2341 ?? FileManager.default.temporaryDirectory
2342 let dest = uniqueDestination(in: dir, filename: name)
2343 guard (try? data.write(to: dest, options: .atomic)) != nil else { return }
2344 NSWorkspace.shared.activateFileViewerSelecting([dest])
2345 }
2346
2347 // Play a video in the lightbox: decrypt into memory, serve it to AVPlayer
2348 // through MemoryVideoAsset (never touching disk). The web's canPlayType
2349 // probe becomes AVAsset's isPlayable — an unplayable codec falls back to
2350 // a plain download, like the web's download-only card.
2351 private func playVideo(_ att: Attachment, from frame: CGRect) {
2352 Task {
2353 // No filename caption under the composer — the video card grows its
2354 // own progress ring (videoDownload below), which is feedback enough.
2355 // The web's 150ms grace: only a fetch that outlives it grows a ring.
2356 let started = Date()
2357 defer {
2358 busyText = nil
2359 videoDownload = nil
2360 }
2361 let data = await model.blobData(att) { progress in
2362 guard Date().timeIntervalSince(started) > 0.15 else { return }
2363 videoDownload = (att.id, progress)
2364 }
2365 guard let data else {
2366 busyText = "couldn't fetch that video"
2367 try? await Task.sleep(for: .seconds(2))
2368 return
2369 }
2370 let loader = MemoryVideoAsset(data: data, mimeType: att.type)
2371 let asset = loader.makeAsset()
2372 let playable = (try? await asset.load(.isPlayable)) ?? false
2373 guard playable else {
2374 save(data, name: att.name ?? "video")
2375 return
2376 }
2377 app.videoLightbox = VideoLightboxItem(
2378 id: att.id, name: att.name,
2379 player: AVPlayer(playerItem: AVPlayerItem(asset: asset)),
2380 loader: loader,
2381 videoSize: CGSize(width: att.w ?? 0, height: att.h ?? 0),
2382 sourceFrame: frame)
2383 }
2384 }
2385
2386 // "name.ext", then "name 2.ext", … — the Finder convention (the same helper
2387 // as ChatFeedWebView's; folds into one place when the webview retires).
2388 private func uniqueDestination(in dir: URL, filename: String) -> URL {
2389 let fm = FileManager.default
2390 let name = filename.isEmpty ? "download" : filename
2391 var candidate = dir.appendingPathComponent(name)
2392 guard fm.fileExists(atPath: candidate.path) else { return candidate }
2393 let ext = (name as NSString).pathExtension
2394 let base = (name as NSString).deletingPathExtension
2395 var n = 2
2396 repeat {
2397 let next = ext.isEmpty ? "\(base) \(n)" : "\(base) \(n).\(ext)"
2398 candidate = dir.appendingPathComponent(next)
2399 n += 1
2400 } while fm.fileExists(atPath: candidate.path)
2401 return candidate
2402 }
2403}
2404
2405