import LChatCore import SwiftUI import AVFoundation import os // Routes drop-to-attach from RootView's ONE drop surface (the window-wide // AppKit catcher, with its SwiftUI .onDrop safety net) to the visible feed. // Per-pane .onDrop regions can't coexist with the keep-alive ZStack: drop // targeting resolves to the topmost region regardless of opacity or // allowsHitTesting, so a hidden space's region either swallowed the drop // (staging into its own composer, invisibly) or — type-gated — dead-ended // the drag with no + badge for the pane actually on screen. // // Routing is PULL-based, deliberately: feeds register by pane key on mount // and unregister on unmount, and the drop asks "which pane is selected NOW" // at drag time. The first cut had the active feed claim/release a single // slot on isActive flips — and a missed/coalesced onChange left the router // unclaimed while the visible feed believed it was the owner (bitten on a // return to an earlier space). Mount lifecycle is structural and can't be // coalesced away; selection is read live, so there is no event ordering to // get wrong. Main-thread only (registration comes from view lifecycle, // delivery from the drop surface). // // The payload is the drag PASTEBOARD, not [NSItemProvider]: the AppKit // catcher only ever has the pasteboard, and it carries every drag shape the // feed stages (file URLs, file promises, raw image data) — plus the drop's // LOCATION, since one window-wide region can't itself say where a drop // landed and the feed needs that to pick a composer (see dropTarget(at:)). final class PaneDropRouter { static let shared = PaneDropRouter() private struct Entry { let token: UUID let handler: (NSPasteboard, CGPoint) -> Void } // Keyed by the pane's instance origin — hidden panes stay registered // (they stay mounted); selection picks between them at drop time. private var entries: [String: Entry] = [:] func register(_ key: String, token: UUID, handler: @escaping (NSPasteboard, CGPoint) -> Void) { Logger.identity.info("drop router: register \(key, privacy: .public)") entries[key] = Entry(token: token, handler: handler) } // Token-checked so a within-pane thread switch can't misfire: the old // feed's onDisappear may land after the new feed registered the same key. func unregister(_ key: String, token: UUID) { guard entries[key]?.token == token else { return } Logger.identity.info("drop router: unregister \(key, privacy: .public)") entries[key] = nil } // Drives the drag's + badge: no registered feed for the selected pane // (locked pane, default-command thread, first-run) → the window rejects // the drag, matching the old empty-type-list gate. func canDrop(over key: String?) -> Bool { key.flatMap { entries[$0] } != nil } /// `point` is where the file was dropped, in SwiftUI `.global` space /// (top-left origin, measured from the window's content view) — the space /// `LiveFrame.rect` answers in, so the receiving feed can test it against /// its tracked frames with no further conversion. Both drop surfaces /// convert into it once, at their own edge (RootView). func deliver(_ pasteboard: NSPasteboard, at point: CGPoint, over key: String?) -> Bool { guard let key, let entry = entries[key] else { Logger.identity.error("drop router: no feed for \(key ?? "", privacy: .public) — drop bounced") return false } Logger.identity.info("drop router: deliver to \(key, privacy: .public) at \(Self.pt(point), privacy: .public)") entry.handler(pasteboard, point) return true } // Points/rects for the log lines — rounded, so a `log show` read of a // misrouted drop is legible at a glance. static func pt(_ p: CGPoint) -> String { String(format: "(%.0f, %.0f)", p.x, p.y) } static func rc(_ r: CGRect) -> String { String(format: "(%.0f, %.0f, %.0f×%.0f)", r.minX, r.minY, r.width, r.height) } } // One thread's natively-folded feed — the real native chat surface (mounted by // the takeover's NativeChatView and the Debug diff pane alike), rendered with // the shared MessageBubble leaf views. // leaf view (LChatCore). Own view so the ChatFeedModel's lifetime is tied // (via .id above) to the picked thread. struct NativeFeedView: View { @StateObject private var model: ChatFeedModel @Environment(\.chatFontSize) private var chatFontSize private let isGroup: Bool private let roster: [RosterEntry] private let myUserId: String // A bot in the thread is what makes /commands answerable (web threadHasBot). private let hasBot: Bool // The message a quote-tap jumped to does a little dance so the eye lands on // it. danceTick retriggers the animation even for the same target. @State private var danceID: Int64? @State private var danceTick = 0 // Live row frames in the scroll viewport, written by row geometry probes and // read imperatively by jump(). A plain reference (NOT @State): frames change // on every scroll tick, and none of the UI derives from them — state here // would re-render the whole list per tick. // ObservableObject (with no @Published — geometry writes must never // re-render the list) so @StateObject pins ONE instance across struct // re-inits: the swipe monitor closure and the geometry probes have to be // reading and writing the same store. private final class FrameStore: ObservableObject { var rows: [Int64: CGRect] = [:] var viewport: CGSize = .zero // RootView keeps every opened instance's pane MOUNTED at opacity 0, so // hidden panes share the visible one's frame; the swipe monitor must // only act for the pane actually on screen. A box (not view state) so // the monitor closure reads the live value. var isActive = true // Debounces the scroll-position capture (the per-thread anchor): the // wheel monitor fires per tick, and reading the row frames mid-fling // is churn for nothing — capture once the scroll goes quiet. var anchorDebounce: DispatchWorkItem? // Throttle stamp for the DURING-scroll captures. The trailing // debounce alone lost the race that mattered: scroll up, click // another chat within a beat — teardown cancelled the pending // capture and no anchor was ever saved (Paul's "state isn't being // saved at all"). Capturing every ~150ms mid-scroll keeps the anchor // never more than a few frames stale, whatever happens next. var lastCaptureAt: TimeInterval = 0 // The reader's own hands on the feed: when they last moved it (a // scroll, or a mouse drag — selecting text autoscrolls with no scroll // events at all), and whether a drag is still down. The self-correcting // scroll loops read these and hold off: moving the offset under a live // drag turns a text selection into a scroll, and a quiet read into a // jump. var lastInputAt: TimeInterval = 0 var dragging = false // The centre-row reading taken at that moment — the prepend // re-anchor's reference. Kept CURRENT as the reader moves the feed // rather than read once when a page fetch starts: the fetch is a real // network round-trip plus a decrypt, and a reading from before it // would put the reader back where they were when it began (a // scrolled-away-by-itself feed). Stale only while nothing is moving — // when it is stale, it is also still correct. var centreRef: NativeFeedModelCache.FeedScrollAnchor? // Coalesces the re-pin that follows a viewport resize: a live divider // drag fires per pixel, and one verify loop per tick would fight // itself. One pin, a beat after the size settles. var resizePinDebounce: DispatchWorkItem? // The live scroll loop's generation. All three of this file's // self-correcting loops — pinUntilSettled, settleBottomGently and // restoreUntilSettled — run across many runloop turns, and the resize // debounce above only cancels a pin that has not STARTED. So a second // trigger (another keystroke widening the composer, a message landing // mid-restore) used to start a second loop beside the first, each // issuing its own scrollTo from its own round of geometry — which is // the "jumping around" the reader sees. A fresh loop takes the next // generation; every continuation in all three re-checks that it still // holds the current one and stands down if it doesn't. var pinGeneration = 0 } @StateObject private var frames = FrameStore() private let mentionAvatar: (String) -> AvatarSource? private let isActive: Bool @AppStorage("nativeTheme") private var themeName = "classic" private var theme: ChatTheme { .named(themeName) } @Environment(\.colorScheme) private var scheme init(model: ChatFeedModel, thread: ChatThread, myUserId: String, isGroup: Bool, isActive: Bool = true, roster: [RosterEntry], instance: SavedInstance? = nil, instanceKeyB64: String? = nil, store: ThreadStore? = nil, mentionAvatar: @escaping (String) -> AvatarSource?, onReopenDM: (() async -> LChatAPI.ReopenDM)? = nil, onMutePerson: ((String, Bool) -> Void)? = nil, onReport: ((ChatFeedModel.Message) -> Void)? = nil, onSettled: (() -> Void)? = nil, onMemberProfile: ((String) -> Void)? = nil) { // The model arrives PARKED (NativeFeedModelCache) — folded messages and // a live socket survive leaving the chat, so re-opening never flashes. _model = StateObject(wrappedValue: model) // EVERY open starts hidden until its position verifiably lands — // bottom pin or anchor restore alike. The first cut of this revealed // parked models from the first frame ("instant"), but rows // materialise with real heights after that frame, and with tall // bubbles (code blocks) the correction was a visible jump on every // switch — Paul's report, twice. A parked model settles in a verify // round or two (~50–100ms of pane background), which reads as a fast // render; a visible jump reads as broken. No jumps > instant. _settled = State(initialValue: false) self.isGroup = isGroup self.isActive = isActive self.roster = roster self.myUserId = myUserId self.mentionAvatar = mentionAvatar let members = Set(thread.members) self.hasBot = roster.contains { members.contains($0.user_id) && $0.isAgent } self.appIntro = thread.appId.flatMap { AppChatProjection.intros[$0] } // The app-thread's default command (To-do: plain text adds a to-do) — // the web's defaultCommandFor, so every composer routes identically. self.defaultCommand = AppDefaultCommand.spec(appID: thread.appId, hasBot: self.hasBot) // The thread's app-local verbs for the "/" typeahead (/add in To-do), // then the command-host verbs (/emoji) — every native composer offers // what the per-thread command webview can answer (see sendDraft). self.appCommands = AppDefaultCommand.localCommands(appID: thread.appId) + WebPanelCommands.catalog self.threadID = thread.id // The command host needs the full thread (key) + instance to seed its // webview; nil (a mount without an instance) just disables forwarding. self.thread = thread self.instance = instance self.instanceKeyB64 = instanceKeyB64 self.store = store self.onReopenDM = onReopenDM self.onMutePerson = onMutePerson self.onReport = onReport self.onSettled = onSettled self.onMemberProfile = onMemberProfile } private let defaultCommand: AppDefaultCommand.Spec? private let appCommands: [(name: String, desc: String)] private let threadID: String private let thread: ChatThread private let instance: SavedInstance? private let instanceKeyB64: String? // For the command host's needAppThread answers (open-or-create a personal // app thread) — nil just leaves cross-thread commands unresolved. private let store: ThreadStore? // The closed banner's Reopen (#578) — routed through the mounting view's // ThreadStore so the sidebar row updates with the composer. nil (a mount // without a store, e.g. the Debug pane) just hides the button's action. private let onReopenDM: (() async -> LChatAPI.ReopenDM)? // Mute/unmute a message's sender (#578) — the container (NativeChatView) // routes it to ThreadStore.setPersonMuted; nil hides the menu item. private let onMutePerson: ((String, Bool) -> Void)? // Report a message's sender to the admins (#578) — the container owns the // sheet (it holds the ThreadStore with the roster + keys to seal to); // nil hides the menu item. private let onReport: ((ChatFeedModel.Message) -> Void)? // Fired every time the feed's position verifiably lands (settled flips // true) — the chat switcher uses it to drop the outgoing feed it holds // over the settle beat. private let onSettled: (() -> Void)? // A tapped @mention chip — the container opens the member's profile card. private let onMemberProfile: ((String) -> Void)? // The companion app's start-of-history intro (chatIntro on the web), // shown above the earliest message once all history is loaded. private let appIntro: String? // The lightbox lives on MacAppState (RootView overlays it window-wide). @EnvironmentObject private var app: MacAppState // Transient status-bar line while a video/file fetches ("nothing happened" // is worse than a quiet caption; the web shows a radial ring here). @State private var busyText: String? // A default command's push-back line ("when should I remind you?") — its // OWN slot, not busyText: the upload paths clear busyText on their own 3s // timers and would wipe an unrelated instruction. The generation counter // makes every clear timer cancel-safe (a re-shown identical prompt must // not be wiped by the previous showing's timer), and dcRouting keeps one // async routing in flight at a time (no double-created reminders). @State private var routeNotice: String? @State private var routeNoticeGen = 0 @State private var dcRouting = false // The feed's inline status line (FeedNoteBar, above the transcript): a // jump that has to page history back for its target says so, and says why // when it can't reach it. `revealing` is the target of the walk in flight // — one at a time, so a second tap can't stack walks. @State private var feedNote: String? @State private var feedNoteGen = 0 @State private var revealing: Int64? // The thread's COMMAND HOST — the hidden per-thread webview that runs // mini-app slash commands (/emoji) and shows their panels; the panel // reveals the webview above the composer (see CommandPanelWebView.swift). @State private var commandHost: CommandHost? // This thread's commandItems() rows (the host reports them once ready) — // they feed the "/" typeahead and the forward-or-send routing decision. @State private var hostRows: [CommandRow] = [] @State private var panelVisible = false @State private var panelHeight: CGFloat = 240 @State private var panelAnimates = true // the open post's animate flag, reused on close // The native composer's draft — cleared only when a send actually reaches // the server (model.send returns false on a down socket, keeping the text). @State private var draft = "" @State private var integrityReport: String? // /integrity read-out (tamper evidence) // A message's "Convert to thread" action: the chain to move, held while the // confirm is up. The ids are snapshotted at the click, so what moves is what // the person was looking at — not a set re-derived after the fact. @State private var convertTarget: ConvertTarget? struct ConvertTarget: Identifiable { let root: Int64 let ids: [Int64] var id: Int64 { root } } // Plain @State: MacComposerField drives first responder from it directly. @State private var composerFocused = false // Programmatic edits (typeahead splice, panel inserts) act on the view via // the controller, so the caret survives them. @State private var composerController = MacComposerController() @AppStorage("formatAsYouType") private var formatAsYouType = true // Attachments staged on the draft (Staged is file-scope, shared with // MessageComposerBar, which renders the chips; the upload pipeline that // fills them stays here — the window drop zone feeds it). @State private var staged: [Staged] = [] // Send pressed mid-upload (the web's PR #269): the button swaps its arrow // for a spinner and the message goes out the moment the uploads finish. @State private var sendQueued = false // Staged reply target / message being edited (the composer banners). @State private var replyTo: ReplyRef? @State private var editingID: Int64? // The open reply-thread view's root id (nil = closed). @State private var threadRootID: Int64? // Is the Threads list showing in the right dock? (It and the thread pane // share that one column — see the body.) It takes the caret while it's up, // so Escape closes it rather than reaching the composer underneath. @State private var showThreadList = false @FocusState private var threadListFocused: Bool // A jump whose target lives only in its thread (search hit, media-sheet // jump): the pane's row to land on + its wave — the feed's danceID/ // danceTick twins for the thread surface (jumpIntoThread). @State private var threadJump: Int64? @State private var threadJumpTick = 0 @State private var threadDanceID: Int64? @State private var threadDanceTick = 0 // Mac v1: the reply thread always DOCKS (thread-pane spec §6) — a flush // right-hand pane inside the chat surface, Slack-style, with its own // composer. The lifted-overlay path is kept intact below (it's iOS's // presentation, and the escape hatch if docking ever grows a width // fallback) but is unreachable while this stays constant true. private let threadDocked = true // The docked pane's OWN composer state — a second container state set, // exactly what the MessageComposerBar extraction (slice 4) was for. No // replyTo twin: the pane's reply target is fixed (the thread root, // derived fresh at send), so nothing user-armed lives here. @State private var paneDraft = "" @State private var paneFocused = false @State private var paneController = MacComposerController() @State private var paneStaged: [Staged] = [] @State private var paneSendQueued = false @State private var paneEditingID: Int64? @State private var paneBusyText: String? // This surface's message menus, keyed by message id, so ⌘T can pop the one // belonging to the right bubble (issue #392). A plain reference handed down // the Environment: the catchers register themselves as they mount, and // nothing here ever observes it. // One per surface, never shared: a message showing in BOTH the feed and an // open reply-thread view has a catcher in each, and a single scope would // hand ⌘T whichever mounted last. @State private var menuScope = MessageMenuScope() @State private var threadMenuScope = MessageMenuScope() // Messages' "Show Times": how far the time gutter is revealed (0…44). // Driven continuously by two-finger horizontal swipes over the feed and // snapped open/closed on release, or toggled whole from the context menu. // Deliberately NOT persisted — it's a peek, not a mode. @State private var timesReveal: CGFloat = 0 // The feed's window frame + the swipe monitor handle, for routing // horizontal scroll events (vertical ones pass through untouched). The // frame is a LiveFrame — read lazily at scroll-event time, never tracked // with a per-frame `onChange(of: CGRect)` (window-resize churn — #899). @State private var paneFrame = LiveFrame() // The DOCKED THREAD PANE's window frame — despite the name above, // paneFrame tracks the FEED column, so routing a drop needs the other // half of the split. Same lazy LiveFrame treatment: read only when a // drop lands (dropTarget(at:)), never observed. Its anchor dies with the // pane, so a closed thread reads .zero — and an empty rect contains // nothing, which is exactly "everything goes to the channel composer". @State private var threadPaneFrame = LiveFrame() @State private var swipeMonitor: Any? // The drag monitor's twin handle: mouse input over the feed is how a text // selection moves the offset (AppKit autoscrolls the drag), and it emits no // scroll events, so the swipe monitor alone can't see it. @State private var dragMonitor: Any? // Identifies this feed's registration on the shared drop router, so a // stale unregister (a thread switch's old feed disappearing after the new // one registered the same pane key) can't evict the rightful receiver. @State private var dropToken = UUID() // True while a horizontal swipe is actively dragging the gutter — the // zero-delta release event snaps only when a drag was in flight. @State private var draggingTimes = false // The bubble stage is sized from the VIEWPORT (minus row padding 20, run // spacer 48, and the gutter — reserved whether or not it's on screen, so // revealing times never resizes a bubble), capped at a readable measure. // State, not a frame-store read: window resizes must re-render the rows. static let timeGutter: CGFloat = 44 @State private var stageWidth: CGFloat = 368 @State private var viewportHeight: CGFloat = 0 private func updateStageWidth(_ viewport: CGSize) { viewportHeight = viewport.height // Quantised to 16pt steps: a live divider drag fires per-pixel size // changes, and re-wrapping every row each tick is the flashing/jumping // Paul saw — most ticks now change nothing. let raw = min(660, max(300, viewport.width - 20 - 48 - Self.timeGutter)) let stepped = (raw / 16).rounded(.down) * 16 if stepped != stageWidth { stageWidth = stepped } } private var timesShown: Bool { timesReveal > 0 } // The clip currently downloading + its 0…1 progress; drives the radial // ring on its card. Shown only once the fetch outlives the web's 150ms // grace, so a cached/fast load never flashes an empty ring. @State private var videoDownload: (id: String, progress: Double)? // Jump-to-latest: shown once the reader has scrolled a MODERATE distance // up — much wider than the 150pt auto-pin slack used elsewhere in this // file, so the button doesn't appear for merely reading the newest // couple of messages. Toggled by the sentinel's geometry probe below, // written only on the boolean's actual edges (not every scroll tick) so // it never becomes another per-frame @Published-style re-render source // (see the FrameStore comment above on why `frames` itself isn't state). @State private var jumpButtonVisible = false static let jumpButtonGap: CGFloat = 400 // Legacy (always-shown) scrollbars claim ~15pt of layout width on the // trailing edge, so the scroll CONTENT — and with it the bubbles' right // edge — ends short of the ScrollView frame the jump button's overlay // measures from. The content probe records the difference so the button // hugs the bubbles' actual edge in both scroller modes. Integer-guarded: // during a live divider drag viewport and content shrink in lockstep, so // the inset is constant and this never becomes a per-pixel re-render. @State private var scrollerInset: CGFloat = 0 private func updateScrollerInset(contentWidth: CGFloat) { let inset = max(0, (frames.viewport.width - contentWidth).rounded()) if inset != scrollerInset { scrollerInset = inset } } var body: some View { HStack(spacing: 0) { feedAndComposer // The docked thread pane (thread-pane spec §6): a flush right // column INSIDE the chat surface, beside the feed — never over // it. Insertion/removal animates with openThread/closeThread's // withAnimation; the lifted-overlay presentation is the kept // !threadDocked path inside feedAndComposer. // The Threads list takes the SAME dock: there is only one, so the // list and a thread are never both readable (the web made exactly // this trade). Opening a thread from a row therefore drops the // list as the thread arrives — which is what you'd want anyway: // you came to find a thread, you found it. // // The list only BORROWS the dock, though: it lays OVER the open // pane rather than replacing it, so a thread you were mid-reply in // keeps its half-typed text — bold runs, caret and undo included — // while you look something up, and closing the list hands it // straight back. (Replacing the pane looked equivalent because the // draft string lives on THIS view, but the rich content lives in // the composer's own text storage, which dies with the view: the // reply would come back plain, with the caret at the end and the // undo stack gone. The web's panel evicts the thread outright for // the same reason — its pane composer dies with the DOM node.) // ONE right column, whatever is standing in it. The list and the // pane used to be separate branches, so opening a thread from a // row removed one column and inserted another — and the feed // reflowed wide and back in the gap between them. Kept as a single // container, the column never leaves: only its contents cross-fade, // and the transition below is just for the column itself arriving // or leaving. if threadRootID != nil || showThreadList { Divider() ZStack { if let rootID = threadRootID, threadDocked { threadPane(rootID) .accessibilityHidden(showThreadList) // covered: not in the reading order // Explicitly a FADE, not the default. Inserted // inside a withAnimation, SwiftUI otherwise // animates the new subtree's own geometry — the // composer measuring its field, the scroll view // taking its size — and the pane visibly grows // into place. The column is already the right // size; only the contents should change. .transition(.opacity) } if showThreadList { threadListPane.transition(.opacity) } } .frame(width: 340) .transition(.move(edge: .trailing).combined(with: .opacity)) } } .alert("Integrity check", isPresented: .init( get: { integrityReport != nil }, set: { if !$0 { integrityReport = nil } }) ) { Button("OK", role: .cancel) { integrityReport = nil } } message: { Text(integrityReport ?? "") } // "Convert to thread" confirms, because it reorganises the chat for // EVERYONE and, in v1, can't be undone. One wording for all three // platforms (ChatFeedModel.convertConfirm). .alert("Convert to Thread", isPresented: .init( get: { convertTarget != nil }, set: { if !$0 { convertTarget = nil } }), presenting: convertTarget ) { target in Button("Cancel", role: .cancel) { convertTarget = nil } Button("Move") { // A conversion that can't reach the server has to SAY so: // nothing about a failed one is visible — the chain stays where // it was, exactly as if nobody had converted anything — so // after confirming a one-way act the reader would be left // believing it happened. The inline note bar, never a toast. if !model.convertToThread(root: target.root, ids: target.ids) { showFeedNote("Those replies didn't move — you're offline.") } convertTarget = nil } } message: { target in Text(ChatFeedModel.convertConfirm(target.ids.count)) } .task(id: instance?.id) { if let instance { await NativeFeedModelCache.shared.pollIntegrity(for: instance) } } .onAppear { model.start() // idempotent — a parked model is already live frames.isActive = isActive installSwipeMonitor() installDragMonitor() registerForDrops() installProjector() } .onChange(of: isActive) { _, active in frames.isActive = active // Release or claim the composer with the pane: a hidden pane's // stale focused=true would keep async-stealing first responder // from the visible pane's field (the old @FocusState dropped // itself automatically; the representable's @State cannot). // With a docked thread open, the claim goes to ITS bar — the // surface the reader was working in. let paneOpen = threadDocked && threadRootID != nil composerFocused = active && !paneOpen paneFocused = active && paneOpen } .onDisappear { // Deliberately NOT model.stop(): parked models keep their socket // (the FeedWebCache rule); the cache's eviction stops them. if let swipeMonitor { NSEvent.removeMonitor(swipeMonitor) } swipeMonitor = nil if let dragMonitor { NSEvent.removeMonitor(dragMonitor) } dragMonitor = nil frames.dragging = false // One last capture at teardown, belt to the throttle's braces — // captureAnchor no-ops if the row frames have already been // cleared by the children's own teardown (order isn't // guaranteed), in which case the throttled capture stands. frames.anchorDebounce?.cancel() frames.anchorDebounce = nil captureAnchor() if let key = instance?.origin { PaneDropRouter.shared.unregister(key, token: dropToken) } } // Drop-to-attach is NOT a per-feed .onDrop: every visited space stays // alive in RootView's ZStack, and drop-region resolution picks the // topmost region regardless of opacity/allowsHitTesting — a hidden // pane's region either stole the drop (staging into the wrong space's // composer) or, gated, dead-ended the drag with no + badge at all. // Instead RootView owns the ONE drop surface for the whole window // and PaneDropRouter (top of file) hands each drop to whichever // registered feed belongs to the pane selected at drag time. // @mentions: the roster index bubbles style "@Name" runs against // (feed rows and the reply-thread view alike), and the chip-tap // interception — a MentionLink URL opens the member's profile card; // every real link keeps the system route. .environment(\.mentionIndex, model.mentionIndex) .environment(\.openURL, OpenURLAction { url in guard let uid = MentionLink.uid(from: url), let onMemberProfile else { return .systemAction } onMemberProfile(uid) return .handled }) } // The feed column — the scrolling transcript, the channel composer inset // and the top integrity banner — split out of body so the docked thread // pane can sit BESIDE it (spec §6). The paneFrame tracking stays on this // column: the times-gutter swipe must never claim scrolls made over the // docked pane's own lists. private var feedAndComposer: some View { ScrollViewReader { proxy in // A ScrollView + LazyVStack, NOT a List: List's NSTableView-backed // rows clip their bounds, which beheaded the wave animation — stack // children can overdraw their row freely. ScrollView { LazyVStack(spacing: 0) { if let appIntro, model.ready, model.exhausted || model.feedMessages.isEmpty { Text(appIntro) .font(.system(size: 13)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: 440) .padding(.vertical, 28) .padding(.horizontal, 16) .frame(maxWidth: .infinity) } // The MAIN FEED list (model.feedMessages): the fold minus // thread-only replies — they live in the docked pane. // `previous` is read off this same list, so sender-run // grouping and day separators are computed over what's // visible: two feed bubbles with a hidden reply between // them in the fold still read as neighbours. ForEach(Array(model.feedMessages.enumerated()), id: \.element.id) { i, m in let previous = i > 0 ? model.feedMessages[i - 1] : nil FeedRow(key: rowKey(m, previous: previous), content: { AnyView(row(m, previous: previous, proxy: proxy)) }) .equatable() .id(m.id) // Deleted messages implode — a quick shrink-and- // fade where the bubble stood. .transition(.opacity) // plain fade — a scale-implosion removal blanked the whole feed on inset changes // Geometry probe for "is this row already in view?" // (jump-without-scroll). Writes to the frame store, // never to state. .background(GeometryReader { g in // Direct side-effect write on every layout pass — // NOT `.onChange(of: CGRect)`. Observing this // per-frame value fired SwiftUI's "onChange(of: // CGRect) action tried to update multiple times // per frame" churn (×every visible row) during a // window maximise/restore (#899). The store has // no @Published, so the write invalidates nothing // (see FrameStore) — this keeps `rows` just as // current, minus the observation. let _ = (frames.rows[m.id] = g.frame(in: .named("feed"))) Color.clear .onDisappear { frames.rows.removeValue(forKey: m.id) } }) // Scroll-back: the oldest row materialising means the // reader is nearing the top — fold the previous page, // then re-anchor it so history grows upward without // the viewport jumping (the web's prepend anchoring). .onAppear { // Fire while the reader is still ~8 rows from // the top: the page splices in off-screen and // the seam is never visible at reading pace. // model.loadOlder self-gates (ready flag + // cooldown), so a prepend can't cascade-load // the whole history. The `settled` gate stops // the opening pin-jump (which transiently // materialises top rows) from prefetching // pages nobody asked for. guard settled, i < 8, model.ready, !model.exhausted else { return } let anchorID = m.id // The trigger row often materialises while still // ABOVE the viewport (lazy lookahead), so its own // position is useless for restoring. Anchor on // whichever row is closest to the viewport's // centre instead — solidly on screen, valid // UnitPoint — and put IT back exactly where it // was, after the prepend has laid out. refreshCentreRef() Task { await model.loadOlder() guard model.feedMessages.first?.id != anchorID else { return } // The reading is taken at the LAST moment the // reader moved the feed (frames.centreRef), // not at the moment this fetch started: a page // is a network round-trip plus a decrypt, and // whatever they scrolled or dragged through it // would otherwise be undone — the feed // scrolling itself back a screenful for no // reason the reader can see. DispatchQueue.main.async { if let ref = frames.centreRef, model.feedMessages.contains(where: { $0.id == ref.rowID }) { proxy.scrollTo(ref.rowID, anchor: UnitPoint(x: 0, y: ref.fraction)) } else { proxy.scrollTo(anchorID, anchor: .top) } } } } // Optimistic send (web parity): the copy already shows // instantly and the confirm swaps it in place; once it's // gone slow (5s unconfirmed) the bubble dims, with a // VoiceOver cue so the delay isn't conveyed by opacity // alone. .opacity(m.pending && m.slow ? 0.55 : 1) .accessibilityHint(m.pending ? (m.slow ? "Still sending" : "Sending") : "") } // The web's typing bubble: three bouncing dots, them-side, // living IN the feed (the old composer-inset text line's // background clipped the bubble beneath it). if !model.typing.isEmpty { HStack(spacing: 0) { TypingBubble(theme: theme, label: isGroup ? typingSummary(model.typing) : nil) Spacer(minLength: 48) } .padding(EdgeInsets(top: 8, leading: 12, bottom: 2, trailing: 12)) .transition(.opacity) } // The pin target: a fixed-height sentinel BELOW the last // row. scrollTo(lastRow) computes against the row's // ESTIMATED height while it's unmaterialised — a tall // final message lands the feed one message short, and // every retry redoes the same wrong math. The sentinel's // height is exact, so "sentinel at the bottom" is always // truly the bottom. Color.clear.frame(height: 1).id(Self.bottomSentinel) } .padding(.vertical, 6) // Short chats read from the TOP: without this the bottom // anchor bottom-aligns content shorter than the viewport. .frame(minHeight: max(0, viewportHeight), alignment: .top) // Jump-button geometry probe: the CONTAINER's frame, not the // bottom sentinel's — a leaf row gets pruned by LazyVStack // once it's far enough off-screen (see the per-row probe // above, which does exactly that on .onDisappear), so reading // gap off the sentinel would freeze the wrong value the // moment it's culled, well before a real "moderate scroll" // threshold. The VStack's own extent is never culled — SwiftUI // needs it for the scroll math regardless of which rows are // realised — so its maxY always reflects the true content // bottom. State only flips at the threshold crossing, so // scrolling itself never re-renders the feed. .background(GeometryReader { g in let gap = g.frame(in: .named("feed")).maxY - frames.viewport.height Color.clear .onAppear { updateJumpButtonVisible(gap: gap) updateScrollerInset(contentWidth: g.size.width) } .onChange(of: gap) { _, gap in updateJumpButtonVisible(gap: gap) } .onChange(of: g.size.width) { _, w in updateScrollerInset(contentWidth: w) } }) } .coordinateSpace(name: "feed") // Every bubble's menu catcher registers itself here as it mounts, // so ⌘T can pop the right one (issue #392). .environment(\.messageMenuScope, menuScope) .background(GeometryReader { g in Color.clear .onAppear { frames.viewport = g.size updateStageWidth(g.size) } .onChange(of: g.size) { _, s in frames.viewport = s updateStageWidth(s) } }) // Follow the newest message like the web feed's pin-to-bottom — // but only when the reader is AT the bottom (or it's their own // send). The previous last row still being materialised near the // viewport is the "at bottom" signal; yanking someone out of // scroll-back for every incoming message made busy groups // unreadable. Keyed on the FEED's newest row, not the fold's: a // thread-only reply arriving (hidden here, shown in the pane) // must neither scroll nor pin the transcript. .onChange(of: model.feedMessages.last?.id) { old, id in guard id != nil else { return } let mine = model.feedMessages.last?.mine == true // Near-bottom only when the previous last row is MATERIALISED // near the viewport. Defaulting the unknown case to true // treated a reader deep in history (old row destroyed, no // frame) as at-the-bottom and yanked them down on arrivals. let nearBottom = old == nil || old.flatMap { frames.rows[$0]?.minY } .map { $0 < frames.viewport.height + 150 } ?? false // No materialised rows at all while messages exist = the feed // is showing blank space (the offset outlived a content // shrink) — always re-pin our way out of that. let wedged = frames.rows.isEmpty && !model.feedMessages.isEmpty // A VERIFIED pin, not a one-shot scrollTo: the send shrinks // the composer inset (draft clearing) mid-jump and rows // materialise with real heights, so the content can resize // under a one-shot target and leave the viewport clamped past // the real end — a fully blank feed. pinUntilSettled re-pins // against measured geometry until the last row is truly flush. if mine || nearBottom || wedged { pinUntilSettled(proxy) } } // The typing bubble appearing at the bottom should scroll into // view when the reader is already there. .onChange(of: model.typing.isEmpty) { _, empty in // Never while the reader's hands are on the feed: someone else // starting to type is the weakest reason there is to move the // offset, and it lands under live drags all day in a busy group. // The shared reading (the row's BOTTOM within slack) — the // row's TOP still counted a reader hundreds of points into // scroll-back as "at the bottom" whenever the newest message // was tall. Same correction as the viewport-resize gate above. if !empty, !readerIsDriving(), let last = model.feedMessages.last?.id, FeedGeometry.isNearBottom(lastRow: frames.rows[last], viewportHeight: frames.viewport.height) { proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) } } // The composer band is part of this pane's layout, and it moves for // more reasons than the old `staged.isEmpty` gate (one boolean edge) // could see: a staged photo grows it TWICE — the upload box, then // the decoded thumbnail — and a reply banner arming, a draft // wrapping to a second line or the window resizing all move it too. // Every one of those shrinks the scroll viewport, and SwiftUI keeps // the content's offset from the top, so the newest message slides // below the fold behind the band. The photo's SECOND growth had no // re-pin at all: the jump on uploading a photo. The viewport's own // measured height (it is already the inset-reduced height — // safeAreaInset shrinks this ScrollView's frame) catches all of // them. .onChange(of: viewportHeight) { old, new in guard old > 0, new != old, let last = model.feedMessages.last?.id, let f = frames.rows[last] else { return } // Was the reader at the bottom BEFORE the viewport changed? The // rows have not moved yet, so the OLD height is the honest // reading — against the new one, a reader the band has just // covered reads as "scrolled away" and gets left there. The // shared reading (the row's BOTTOM within slack), not the row's // top: for a tall newest message `minY < old + 150` still held // a couple of hundred points into scroll-back, so typing yanked // a reader who had scrolled up a little back down. guard FeedGeometry.isNearBottom(lastRow: f, viewportHeight: old) else { return } frames.resizePinDebounce?.cancel() // The GENTLE correction, never the cold-open loop: that loop's // rescue jolts to the top of loaded history to force // materialisation, which is right for a wedged pane and // catastrophic here — the composer band moves on almost every // keystroke (the typeahead strip opening, re-ranking and // closing; a draft wrapping a line), and each jolt is a // full-height jump under someone who is only typing. // And nothing at all while the feed is still unrevealed: the // open/restore machinery owns the offset until `settled`, and a // band that moves during a restore (a draft rehydrating, a // reply banner re-arming) used to start a bottom pin BESIDE the // restore — two loops scrolling to different targets, the pin // wiping the reader's saved anchor if it verified first. // settleBottomGently's own `settled` guard enforces this. let pin = DispatchWorkItem { settleBottomGently(proxy) } frames.resizePinDebounce = pin DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: pin) } // Opening a chat lands at the newest message — and STAYS there: // one pin isn't enough, because rows materialise with their real // (often taller) heights after the jump and shift the content a // message short. Re-pin until the frame store confirms the last // row is actually flush with the viewport bottom. .onChange(of: model.ready) { _, ready in guard ready else { return } if let anchor = savedAnchor() { restoreUntilSettled(proxy, anchor) } else { pinUntilSettled(proxy) } consumeJump(proxy) } // Search hits land on their message: page history until the // target is loaded, then jump-and-wave. .onChange(of: app.nativeJump) { _, _ in consumeJump(proxy) } // The header kebab's Threads row (a view up — see MacAppState). .onChange(of: app.nativeReplyThreadList) { _, _ in consumeThreadListRequest() } // ⌘R / ⌘T (Message ▸ Reply to / React to Latest Message, issue // #392) — the web feed's two keyboard message verbs, which the Mac // never had. Both act on the surface in FRONT of the reader: the // reply-thread view's messages while it's open, the feed's // otherwise. .onChange(of: app.replyLatestTick) { _, _ in guard isActive, !model.dmClosed, let m = ChatFeedModel.latestReplyTarget(in: hotkeyMessages) else { return } // With the docked pane open the thread's composer IS the // pane's bar — ⌘R just puts the caret there (its fixed reply // already targets the root); the overlay path stages on the // channel bar as before. if threadDocked, threadRootID != nil { paneFocused = true } else { startReply(m) } } // ⇧⌘R (Message ▸ Reply in Thread to Latest Message) — ⌘R's // thread-pane twin, same gate and same "latest" read: the pane's // messages while it's open (so it resolves to the open thread and // just refocuses), the feed's otherwise (opens that message's // thread, root-resolved like the menu verb). .onChange(of: app.replyInThreadLatestTick) { _, _ in guard isActive, !model.dmClosed, let m = ChatFeedModel.latestReplyTarget(in: hotkeyMessages) else { return } replyInThread(m) } .onChange(of: app.reactLatestTick) { _, _ in guard isActive, let m = ChatFeedModel.latestReactionTarget(in: hotkeyMessages) else { return } // The menu hangs off the bubble, so the bubble has to be on // screen — and materialised at all, or there's no catcher to // pop it from. A feed scrolled back into history scrolls first // and pops once the row has landed. (The thread view scrolls // itself and opens at the bottom, so it's exempt.) let scope = threadRootID == nil ? menuScope : threadMenuScope if threadRootID == nil, !rowOnScreen(m.id) { withAnimation { proxy.scrollTo(m.id, anchor: .bottom) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { scope.popUpMenu(for: m.id) } return } // Never straight from the onChange: popping runs a modal // tracking loop, and this one is still inside a view update. DispatchQueue.main.async { scope.popUpMenu(for: m.id) } } // A Text Size change (⌘+/⌘−, Settings slider) relayouts every row // at once; a shrink can leave the scroll offset clamped past the // new content end — a fully blank feed, the same wedge the // message-arrival handler guards against (and which un-wedged // itself only on the next arrival). Re-pin immediately when the // reader was at the bottom; from deep in history, give layout a // beat and rescue only if the viewport actually came up empty, so // a scrolled-back reader isn't yanked to the newest message. .onChange(of: chatFontSize) { _, _ in // The heavy loop is right here — every row re-lays out at a // new height, which is what its verify rounds exist for — but // the gate is the shared one, tall-newest-row included. let nearBottom = FeedGeometry.isNearBottom( lastRow: model.feedMessages.last.flatMap { frames.rows[$0.id] }, viewportHeight: frames.viewport.height) if nearBottom { pinUntilSettled(proxy) return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { if frames.rows.isEmpty && !model.feedMessages.isEmpty { pinUntilSettled(proxy) } } } .onAppear { // The mount clock starts at the opening appear only (the view // is rebuilt per thread switch, so one appear = one open). if mountSpan == nil, !settled { mountSpan = Perf.begin("feed.mount", slowMs: 500) } // Switching BACK to a thread the reader had scrolled away from // restores their spot instead of pinning to the bottom — the // anchor lives in the model cache (the view and its scroll // offset are rebuilt per thread; the cache survives). Hidden // while the restore lands (one or two verify rounds) so the // switch never shows the position being corrected — the jump // Paul reported. No anchor = the reader left at the bottom = // today's pin, still visible from the first frame. if let anchor = savedAnchor() { if settled { settled = false } restoreUntilSettled(proxy, anchor) } else { pinUntilSettled(proxy) } consumeJump(proxy) // A Threads click made while this feed wasn't mounted (the // chat was showing an app's Interface) waits for this. consumeThreadListRequest() } // Ends the mount span one turn after the reveal commits, so the // revealed frame's layout is measured — cold fetch, pin/restore // verify rounds and all. Also the switcher's signal to drop the // outgoing feed it held over this beat. .onChange(of: settled) { _, isSettled in guard isSettled else { return } onSettled?() guard let span = mountSpan else { return } mountSpan = nil let count = model.messages.count DispatchQueue.main.async { span.end("\(count) messages") } } // Anchor to the bottom BEFORE first layout — a parked/prewarmed // model arrives with messages already folded, and the onAppear pin // races layout and loses (the land-at-top bug, same as iOS). .defaultScrollAnchor(.bottom) // The feed reveals only once history has folded AND the pin has // verifiably landed — a parked chat settles in a frame or two, a // cold one shows the loader; either way the reader finds it // already at the bottom, never mid-snap. The reveal is a hard cut // (no animation): with messages in hand, switching chats must // read as instant, not a cross-fade. .opacity(model.ready && settled ? 1 : 0) .overlay { if !model.ready { ProgressView().controlSize(.small) } } // Jump-to-latest (web parity): bottom-right of the feed, shown // once jumpButtonVisible flips (the sentinel probe above). Sized to // this composer's 28pt +/send discs, not the web's 40px (its // composer circles ARE 40px, so 40 reads proportionate there and // oversized here). Insets: trailing matches bubbleRow's 10pt row // padding — plus the legacy scrollbar's width when one is shown // (scrollerInset) — so the disc's right edge sits exactly on the // bubbles' right edge in both scroller modes; the bottom gap // equals it. .overlay(alignment: .bottomTrailing) { if model.ready && settled && jumpButtonVisible { Button { withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) } } label: { Image(systemName: "chevron.down") .font(.system(size: 12, weight: .semibold)) .frame(width: 28, height: 28) .background(.regularMaterial, in: Circle()) .overlay(Circle().strokeBorder(.quaternary, lineWidth: 1)) } .buttonStyle(.plain) .accessibilityLabel("Jump to latest") .help("Jump to latest") // mouse-hover tooltip (the web button's title=) .padding(.trailing, 10 + scrollerInset) .padding(.bottom, 10) .transition(.opacity) } } } // The thread view lifts over the FEED pane only (the web's // .chatapp-feedwrap overlay) — applied before the composer inset, so // the real composer below stays live, staged to the thread's root. // Kept as the !threadDocked path only: Mac v1 always docks (§6). .overlay { if let rootID = threadRootID, !threadDocked { threadOverlay(rootID) .environment(\.messageMenuScope, threadMenuScope) .transition(.opacity.combined(with: .offset(y: 12))) } } // spacing 0: the default safe-area spacing left a visible dead strip // between the scrollbar's end (and the jump button's gap) and the // composer's top edge. .safeAreaInset(edge: .bottom, spacing: 0) { // An ended DM (#578): the server refuses new messages, so the // composer gives way to the closed banner — the one honest action // left is Reopen. if model.dmClosed { dmClosedBanner } else { // The material band lives on the input row INSIDE `composer`, // never on this whole inset: staged previews/banners float // transparent above it, so the band can't grow and curtain the // chat (the failure that got the original full-inset .bar slab // removed). composer } } // The feed's inline status line — in flow above the transcript, never // floating (see FeedNoteBar). Nothing to say renders nothing. .safeAreaInset(edge: .top, spacing: 0) { FeedNoteBar(feedNote) } // Tamper-evidence failure banner (docs/tamper-evidence.md) — only when // this space fails an integrity check; "What's this?" fills the report // alert below. .safeAreaInset(edge: .top, spacing: 0) { if let instance { IntegrityBanner(monitor: NativeFeedModelCache.shared.integrityMonitor(for: instance)) { integrityReport = NativeFeedModelCache.shared .integrityMonitor(for: instance).reportLines().joined(separator: "\n") } } } // Track the feed's window frame so the swipe monitor only claims // scroll events that happen over it (read lazily — see paneFrame). .trackingFrame(into: paneFrame) } private func updateJumpButtonVisible(gap: CGFloat) { let show = gap > Self.jumpButtonGap if show != jumpButtonVisible { jumpButtonVisible = show } } // Register this feed as its pane's drop receiver for as long as it is // mounted — visibility is the drop surface's problem, not ours. A // default-command thread (text-only surface) or an instance-less mount // (the Debug diff pane) registers nothing, so the drag shows no + badge // there — same behaviour the per-feed type-list gate used to give. private func registerForDrops() { guard defaultCommand == nil, let key = instance?.origin else { return } // Registered once per mount, so the target is decided AT DROP TIME // from live state (the closure's @State reads go through SwiftUI's // storage, not a snapshot): the window zone pre-empts the fields' // own onStageDrop backstops, so this closure is what decides which // composer a dropped file lands on. PaneDropRouter.shared.register(key, token: dropToken) { pb, point in let target = dropTarget(at: point) guard stageDrop(pb, into: target) else { return } // The caret follows the file into the bar it landed on — the same // move pickFiles makes for the "+" picker, and what keeps Return // meaning "send this" straight after a drop. Not while the // Threads list stands, though: it OWNS the caret while it's up // (openThreadList), and a column you can't Escape out of is a trap. guard !showThreadList else { return } switch target { case .main: composerFocused = true case .pane: paneFocused = true } } } // WHERE you dropped decides which composer stages the file — never which // composer holds the caret. Routing by focus (this closure's first cut) // meant that with a thread open EVERY drop in the window went to the // pane, because opening a thread claims the caret: dropping a photo on // the channel composer put it in the thread (James, Mac, 2026-08-30). // // COORDINATE SPACES: `point` and `threadPaneFrame.rect` are both SwiftUI // `.global` — top-left origin, measured from the window's content view. // The drop surface does the one AppKit flip (RootView's DropCatcherView / // PaneDropDelegate); LiveFrame answers in that space by construction. // Nothing is converted here, and nothing here may assume otherwise. private func dropTarget(at point: CGPoint) -> ComposerTarget { // No docked pane on screen → the channel composer, exactly as before. // The Threads list borrows the SAME column and covers the pane // opaquely, so while it stands the pane's composer can't be seen: a // drop over it belongs to the bar the reader can actually look at. let paneShowing = threadDocked && threadRootID != nil && !showThreadList let rect = paneShowing ? threadPaneFrame.rect : .zero let target: ComposerTarget = rect.contains(point) ? .pane : .main // The whole diagnosis of a future "it went to the wrong box", from // `log show` alone: the point, the pane it was tested against, and // where it ended up. Logger.identity.info(""" drop router: at \(PaneDropRouter.pt(point), privacy: .public) \ pane \(PaneDropRouter.rc(rect), privacy: .public) \ → \(target == .pane ? "pane" : "main", privacy: .public) """) return target } static let bottomSentinel = "bottom-sentinel" // ---- per-thread scroll memory ------------------------------------------- // The anchor is the row nearest the viewport's centre plus its fractional // travel — the same reference the prepend re-anchor uses — stored in the // model cache so it outlives this view's per-thread teardown. Captured on // a debounce after wheel scrolling goes quiet; cleared whenever a bottom // pin verifiably lands (being at the bottom IS the no-anchor state). private func savedAnchor() -> NativeFeedModelCache.FeedScrollAnchor? { guard let iid = instance?.id else { return nil } return NativeFeedModelCache.shared.scrollAnchor(instance: iid, thread: threadID) } private func storeAnchor(_ anchor: NativeFeedModelCache.FeedScrollAnchor?) { guard let iid = instance?.id else { return } NativeFeedModelCache.shared.setScrollAnchor(anchor, instance: iid, thread: threadID) } // Read the current position out of the frame store. Near the bottom // (the newest row materialised by the viewport) counts as AT the bottom — // clearing the anchor — so a reader hovering a few points up isn't // "restored" away from arriving messages next time. private func captureAnchor() { guard instance != nil, frames.viewport.height > 0, !frames.rows.isEmpty else { return } if FeedGeometry.isNearBottom(lastRow: model.feedMessages.last.flatMap { frames.rows[$0.id] }, viewportHeight: frames.viewport.height) { storeAnchor(nil) return } // A reading of nothing (every row off-screen mid-relayout) leaves the // last good anchor alone rather than clearing it. if let ref = FeedGeometry.centreRow(rows: frames.rows, viewportHeight: frames.viewport.height) { storeAnchor(ref) } } // Put the saved anchor's row back where it was and VERIFY it landed — // pinUntilSettled's shape, aimed at a row instead of the bottom. Rows // materialise with real heights after the first jump, so one round is // rarely enough. A vanished anchor row (evicted history, a deleted // message) falls back to the ordinary bottom pin, anchor cleared. private func restoreUntilSettled(_ proxy: ScrollViewProxy, _ anchor: NativeFeedModelCache.FeedScrollAnchor, attempts: Int = 12, generation: Int? = nil) { guard model.ready else { return } // The reader got there first (they scrolled, or grabbed a selection, // while the verify rounds were still landing) — where they are now IS // the position, so stop correcting toward the old one. Driving implies // settled, so the feed is already revealed at their spot. Checked with // the other preconditions, BEFORE the generation bump, so a call that // does nothing retires nothing. if readerIsDriving() { return } // The third loop on this feed, under the same generation discipline as // the other two — it aims at a ROW while they aim at the bottom, so two // of them live at once is the worst version of the fight: an arrival // pin landing mid-restore scrolls to the bottom and, if it verifies // first, clears the reader's saved anchor on the way past. let gen: Int if let generation { guard generation == frames.pinGeneration else { return } gen = generation } else { frames.pinGeneration &+= 1 gen = frames.pinGeneration } guard model.feedMessages.contains(where: { $0.id == anchor.rowID }) else { storeAnchor(nil) pinUntilSettled(proxy, generation: gen) return } proxy.scrollTo(anchor.rowID, anchor: UnitPoint(x: 0, y: anchor.fraction)) guard attempts > 0 else { settled = true // close enough — reveal rather than hide forever return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { guard gen == frames.pinGeneration else { return } if let f = frames.rows[anchor.rowID], frames.viewport.height > 0 { let denom = frames.viewport.height - f.height let want = denom > 0 ? anchor.fraction * denom : 0 if abs(f.minY - want) <= 2 { settled = true return } } restoreUntilSettled(proxy, anchor, attempts: attempts - 1, generation: gen) } } // A COLD feed stays invisible until the pin is verified flush — the reader // should find the chat already at the bottom, never watch it snap there. // A parked one starts settled (see init) so switching chats is instant. @State private var settled: Bool // The open's "click → usable feed" measurement, the iOS feed.mount twin: // begun at the opening appear, ended one turn after `settled` commits so // the reveal frame's layout is inside it. Same span name — the ledger // report digests both platforms through one seam. @State private var mountSpan: Perf.Span? // Whether the newest row is genuinely resting at the viewport bottom (a // tall chat pinned) or, for a chat too short to fill the viewport, fully // visible from the top. This replaces the bare `maxY <= viewport.height + 2` // verify, which ALSO matched a feed stranded ABOVE the viewport — bug #900: // on an open, a layout race can leave the scroll offset past the content's // end, every row with a NEGATIVE maxY (last row maxY ≈ -696), and // `-696 <= 855` "revealed" empty space beyond the last message (blank until // you scrolled). A genuinely-pinned last row has a POSITIVE maxY near the // bottom; a stranded one is negative. (The bottom safe-area inset — the // composer — means a pinned maxY lands SHORT of viewport.height, so we // can't require maxY ≈ height; "in the lower half and not below the bottom // edge" is the robust band for a tall chat.) // Reads the FEED's rows (feedMessages): a thread-only reply is the fold's // newest message but has no row here — keying the verify on it would // never settle, and the feed would stay hidden. private func lastRowFlush() -> Bool { guard let id = model.feedMessages.last?.id else { return false } return FeedGeometry.lastRowIsFlush( last: frames.rows[id], first: model.feedMessages.first.flatMap { frames.rows[$0.id] }, viewportHeight: frames.viewport.height) } // The GENTLE bottom correction: the feed is revealed, its rows are on // screen, and something moved the composer band underneath them (a staged // photo, a reply banner arming, the typeahead strip, a draft wrapping a // line). All that is owed is "put the newest message back against the // bottom edge" — a pin and, if the content settles a little differently, at // most a couple more. Deliberately NOT pinUntilSettled: that loop's rescue // scrolls to the TOP of loaded history to force a dematerialised stack to // lay out, which is the right medicine for a wedged pane and, on a band // that moved 18pt while someone types, a full-height jump for nothing. It // also never touches `settled` — this is a correction, not a reveal. // Six rounds — three fast, then three patient (~1.1s in all), which // outlasts the +0.2s held-recheck the full loop would have run. Running out // simply STOPS: the pane is revealed and the worst case is the newest // message sitting a little behind the band until the next trigger. It must // not escalate to pinUntilSettled, which jolts to the top on its FIRST // failed verify — a keystroke must never be able to reach that. Only a // WEDGE escalates, and a wedge has nothing on screen to disturb. private func settleBottomGently(_ proxy: ScrollViewProxy, attempts: Int = 6, generation: Int? = nil) { // `settled` is a HARD precondition, and it is checked BEFORE taking a // generation: taking one retires any pin loop in flight, and this // function never reveals — so entering it unrevealed would retire the // loop that was going to reveal and leave the pane hidden. Preconditions // first, side effect second. guard settled, model.ready, let id = model.feedMessages.last?.id else { return } // The newest correction wins and older ones stand down, so keystroke // after keystroke can't stack loops that fight each other. let gen: Int if let generation { guard generation == frames.pinGeneration else { return } gen = generation } else { frames.pinGeneration &+= 1 gen = frames.pinGeneration } // A WEDGE is not a gentle problem. With no rows materialised, // scrollTo(sentinel) computes against nothing every round (see // pinUntilSettled) — only the full loop's jolt to a real row gets the // offset back, and with a blank pane there is nothing on screen for // that jolt to disturb. Hand the generation over rather than spending // a round of no-op pins and leaving the feed blank until the next // arrival. guard !feedIsWedged() else { return pinUntilSettled(proxy, generation: gen) } // Is the reader still AT the bottom? Re-read every round against LIVE // geometry, not once at the trigger: between the debounce and a // deferred round this correction can be up to ~a second old, and a // reader who scrolled away in that window must be left where they are. // The 150pt slack is wider than any band move, so the row this exists // to rescue — pushed down behind a grown composer — still qualifies. guard FeedGeometry.isNearBottom(lastRow: frames.rows[id], viewportHeight: frames.viewport.height) else { return } // Never under the reader's hands — but hold the correction rather than // losing it: a trackpad fling keeps `driving` true through its whole // momentum tail, and dropping outright left the newest message behind // the band until something else happened to re-pin. guard !readerIsDriving() else { guard attempts > 1 else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { settleBottomGently(proxy, attempts: attempts - 1, generation: gen) } return } proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) guard attempts > 1 else { return } // Fast while the band is settling, then patient: content still // reflowing (a card finishing layout in the newest message) wants time, // not more pins. let delay = attempts > 4 ? 0.05 : 0.3 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { guard gen == frames.pinGeneration, model.feedMessages.last?.id == id, !lastRowFlush() else { return } settleBottomGently(proxy, attempts: attempts - 1, generation: gen) } } // Pin to the bottom sentinel and VERIFY it landed: the frame store knows // where the last row actually is, so re-pin (a few frames apart) until its // bottom sits at the viewport's bottom or the attempts run out. Bails if // the last message changes underneath (the chat moved on). // // The WEDGE needs more than retries: after a send, the composer-inset // shrink can clamp the scroll offset past the content's end with ZERO rows // materialised — and against a fully dematerialised lazy stack, the same // scrollTo(bottom) computes against nothing every round (the blank-feed // bug's stubborn form). When a verify round finds that state, jolt the // offset back into range by scrolling to the FIRST message (top) — which // forces materialisation — then resume pinning to the bottom. And never // give up while genuinely wedged: park and retry on a slower cadence // (bounded) instead of leaving a blank pane until the next arrival. private func pinUntilSettled(_ proxy: ScrollViewProxy, attempts: Int = 12, rescues: Int = 4, waits: Int = 12, generation: Int? = nil) { guard model.ready else { return } // the ready flip re-enters // A fresh pin supersedes whatever was still in flight: these loops span // many runloop turns, so without this a resize, an arrival and a // reveal each ran their own — three scrollTo streams, from three // different rounds of geometry, on one feed. Taken after the `ready` // guard, so a call that does nothing retires nothing. let gen: Int if let generation { guard generation == frames.pinGeneration else { return } gen = generation } else { frames.pinGeneration &+= 1 gen = frames.pinGeneration } // Never pull the offset out from under a live scroll or selection // drag: the pin lands mid-gesture, the content jumps, and a drag that // was selecting text ends up scrolling instead. Wait for the reader's // hands to come off (bounded — a stuck `dragging` must not wedge the // feed blank forever) and pin then. A WEDGED feed is exempt: there is // nothing on screen to be reading. if readerIsDriving(), !feedIsWedged(), waits > 0 { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { pinUntilSettled(proxy, attempts: attempts, rescues: rescues, waits: waits - 1, generation: gen) } return } guard let id = model.feedMessages.last?.id else { settled = true // nothing to pin (empty chat) return } proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) guard attempts > 0 else { // Out of fast retries. If the newest row is genuinely at the // bottom, reveal. Otherwise the feed is still stranded (rows above/ // below the viewport, or none materialised) — jolt to a valid // anchor and retry on a slower cadence (bounded by `rescues`) // rather than revealing a blank pane. if rescues > 0, !lastRowFlush(), !model.feedMessages.isEmpty { if let first = model.feedMessages.first?.id { proxy.scrollTo(first, anchor: .top) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { pinUntilSettled(proxy, attempts: 6, rescues: rescues - 1, waits: waits, generation: gen) } } else { settled = true // flush, or out of rescues — reveal rather than hide forever } return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { guard gen == frames.pinGeneration else { return } guard model.feedMessages.last?.id == id else { settled = true return } if lastRowFlush() { settled = true // genuinely pinned to the bottom — reveal // A verified bottom IS the no-anchor state: an own-send (or // arrival) pin that yanked a scrolled-back reader down makes // any saved anchor stale — the next open should land here, // at the newest, not back at the old spot. storeAnchor(nil) // Flush NOW isn't flush in a beat: a send clears the staged // strip a moment after this verify passes, the composer inset // shrinks, and the content shifts under the satisfied pin — // an image bubble lands below the fold (Paul's screenshot). // Confirm the pin HELD; if it slipped either way (pushed below // OR stranded above), keep pinning. DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { guard gen == frames.pinGeneration, model.feedMessages.last?.id == id, !lastRowFlush() else { return } pinUntilSettled(proxy, attempts: max(attempts - 1, 2), rescues: rescues, waits: waits, generation: gen) } return } // Not flush: the offset is stranded — rows above/below the viewport // (the #900 blank: rows materialised but all with negative maxY), or // none materialised. scrollTo(sentinel) can't pull a past-the-end // offset back on its own, so jolt to a materialised anchor (the // first message, at the top) to force a valid offset + real row // heights, THEN — a beat later, so the top scroll actually takes — // re-pin the bottom. Recursing with no gap lets the same // estimated-height overshoot recur every round, which is what made // the stranded open sit blank for ~a second before the slow rescue // path finally landed it. (Previously gated on frames.rows.isEmpty, // so a stranded-but-materialised feed never recovered at all.) if let first = model.feedMessages.first?.id { proxy.scrollTo(first, anchor: .top) DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues, waits: waits, generation: gen) } } else { pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues, waits: waits, generation: gen) } } } // Messages' two-finger reveal: horizontal-dominant scrolls over the feed // drag the time gutter open (swipe left) or closed (swipe right), // snapping at release; vertical scrolling passes through untouched. A // local event monitor rather than an overlay view — an NSView in the way // would have to fight the scroll view for every vertical event too. private func installSwipeMonitor() { let frames = frames swipeMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in // Hidden panes (opacity 0 in RootView's ZStack) share this frame — // only the on-screen pane may claim the swipe. guard frames.isActive, let window = event.window, let content = window.contentView else { return event } // Window coords are bottom-left; SwiftUI's .global is top-left. let p = event.locationInWindow let point = CGPoint(x: p.x, y: content.bounds.height - p.y) guard paneFrame.rect.contains(point) else { return event } // Any scroll over this pane captures the position: throttled // DURING the scroll (so a switch right after a fling still has a // near-current anchor — the trailing capture alone lost that // race), plus a trailing debounce for the exact resting spot. let now = ProcessInfo.processInfo.systemUptime noteReaderInput(at: now) if now - frames.lastCaptureAt > 0.15 { frames.lastCaptureAt = now captureAnchor() } frames.anchorDebounce?.cancel() let capture = DispatchWorkItem { captureAnchor() } frames.anchorDebounce = capture DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: capture) // The release event arrives with ZERO deltas, so it must be // handled before any horizontal-dominance check — that guard // silently ate it and the gutter froze wherever it was left. if event.phase == .ended || event.phase == .cancelled { guard draggingTimes else { return event } draggingTimes = false // Spring-loaded release: settle open or shut with a gentle bounce. withAnimation(.spring(response: 0.35, dampingFraction: 0.65)) { timesReveal = timesReveal > Self.timeGutter / 2 ? Self.timeGutter : 0 } return nil } // Momentum after a horizontal fling: swallow it so the feed // doesn't sideways-scroll, but don't keep dragging the gutter. if event.momentumPhase != [] { return abs(event.scrollingDeltaX) > abs(event.scrollingDeltaY) ? nil : event } guard abs(event.scrollingDeltaX) > abs(event.scrollingDeltaY) else { return event } // Natural scrolling: fingers left → negative deltaX → reveal grows. draggingTimes = true timesReveal = min(Self.timeGutter, max(0, timesReveal - event.scrollingDeltaX)) return nil // consumed } } // Selecting text is the other way the reader moves this feed: a drag near // the pane's edge autoscrolls it, and AppKit does that WITHOUT scroll // events, so the swipe monitor never sees it. This monitor only watches // (every event passes through untouched) — it exists so the scroll loops // know a drag is live and hold off, and so the centre reading stays // current through an autoscroll. private func installDragMonitor() { let frames = frames dragMonitor = NSEvent.addLocalMonitorForEvents( matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp] ) { event in // The mouse-up ends the drag wherever it happens — a selection // routinely finishes outside the pane it started in — so it is // cleared before the pane test, never after it. if event.type == .leftMouseUp { frames.dragging = false; return event } guard frames.isActive, let window = event.window, let content = window.contentView else { return event } let p = event.locationInWindow let point = CGPoint(x: p.x, y: content.bounds.height - p.y) guard paneFrame.rect.contains(point) else { return event } if event.type == .leftMouseDown { frames.dragging = true } noteReaderInput(at: ProcessInfo.processInfo.systemUptime) return event } } // One stamp + one fresh reading, from whichever monitor saw the input. private func noteReaderInput(at now: TimeInterval) { frames.lastInputAt = now refreshCentreRef() } // Re-read where the reader is looking. A reading of nothing (mid-relayout, // no row fully on screen) keeps the last good one rather than blanking it. private func refreshCentreRef() { if let ref = FeedGeometry.centreRow(rows: frames.rows, viewportHeight: frames.viewport.height) { frames.centreRef = ref } } // True while the reader is working the feed — a scroll or a drag in the // last beat. Every loop below that moves the offset defers to it. // Only once the feed is SETTLED (on screen, and the reader's): while it's // still hidden behind the opening pin, nothing they do is aimed at it. private func readerIsDriving() -> Bool { guard settled else { return false } if frames.dragging { // Trust the hardware over our own bookkeeping: a mouse-up can be // swallowed (a menu, a drag ending in another app), and a stuck // "dragging" would hold the feed's own corrections off forever. if NSEvent.pressedMouseButtons & 1 != 0 { return true } frames.dragging = false } return ProcessInfo.processInfo.systemUptime - frames.lastInputAt < 0.25 } // A blank pane — the offset outlived a content shrink, no row materialised // — always wins the offset back: there is nothing on screen for the reader // to be reading, so the rescue runs whatever their hands are doing. private func feedIsWedged() -> Bool { frames.rows.isEmpty && !model.feedMessages.isEmpty } // Bubble row with run-grouping, mirroring the web feed: the sender name // shows once per run (groups only), and bubbles inside a run sit tighter // than bubbles that start one. // One Equatable key per feed row. SwiftUI can't prove our rows unchanged // on its own — each body pass hands MessageBubble ~nine fresh closures — // so ANY model tick (someone typing, a sender name arriving, status // flips) re-ran every visible bubble's body: seconds of main-thread // render work around activation (issue #1071, the "hangs when I load // in" bug; ClickLab wedge samples fingerprinted bubbleRow/MessageBubble). // FeedRow compares THIS key instead and skips the body when nothing the // row draws has changed. // ⚠️ Every render input must be listed — a field missing from the key // means its changes stop repainting rows. private struct FeedRowKey: Equatable { let message: ChatFeedModel.Message let previous: ChatFeedModel.Message? // grouping: separator + same-sender run let mutedFold: Bool let senderName: String let canEdit: Bool // messageActions' one state read let isGroup: Bool let themeName: String let stageWidth: CGFloat let timesReveal: CGFloat let downloadingID: String? let downloadingProgress: Double? } private struct FeedRow: View, Equatable { let key: FeedRowKey // A CLOSURE, not an eager AnyView: building the row value (bubble + // nine closures + actions) per pass costs real time at -Onone even // when == then skips the body — lazy content means a skipped row // constructs nothing at all. let content: () -> AnyView static func == (a: Self, b: Self) -> Bool { a.key == b.key } var body: some View { content() } } private func rowKey(_ m: ChatFeedModel.Message, previous: ChatFeedModel.Message?) -> FeedRowKey { FeedRowKey(message: m, previous: previous, mutedFold: model.foldsAsMuted(m), senderName: model.senderName(m), canEdit: model.canEdit(m), isGroup: isGroup, themeName: themeName, stageWidth: stageWidth, timesReveal: timesReveal, downloadingID: videoDownload?.id, downloadingProgress: videoDownload?.progress) } @ViewBuilder private func row(_ m: ChatFeedModel.Message, previous: ChatFeedModel.Message?, proxy: ScrollViewProxy) -> some View { if m.action { // A /me-style app action line — centred, muted, no bubble // (style.css .chatapp-action). Text(m.text) .font(.system(size: 13)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .padding(EdgeInsets(top: 3, leading: 14, bottom: 3, trailing: 14)) } else if model.foldsAsMuted(m) { mutedRow(m, previous: previous) } else { bubbleRow(m, previous: previous, proxy: proxy) } } // A muted member's message (#578): the quiet stub, revealed per-message // for the session. Keeps the run/separator grouping so revealing it never // reshuffles the feed around it. Always a "them" row — own messages and // uid-less legacy ones never fold (foldsAsMuted). @ViewBuilder private func mutedRow(_ m: ChatFeedModel.Message, previous: ChatFeedModel.Message?) -> some View { let separator = timeSeparator(at: m.at, previous: previous?.at) let startsRun = separator != nil || !ChatFeedModel.sameSender(previous, m) if let separator { separatorRow(separator, at: m.at) } HStack(spacing: 0) { MutedMessageStub { model.revealMuted(m.id) } Spacer(minLength: 48) } .padding(EdgeInsets(top: startsRun ? 8 : 2, leading: 14, bottom: 2, trailing: 10)) } // The dated/time-only run separator — one builder for bubble and muted rows. private func separatorRow(_ separator: TimeSeparator, at: Int64) -> some View { HStack(spacing: 4) { if separator == .dated { Text(chatDayLabel(at)).fontWeight(.semibold) } Text(chatTime(at)) } .font(.system(size: 12)) .foregroundStyle(.secondary) .frame(maxWidth: .infinity) .padding(.top, 14) .padding(.bottom, 4) } @ViewBuilder private func bubbleRow(_ m: ChatFeedModel.Message, previous: ChatFeedModel.Message?, proxy: ScrollViewProxy) -> some View { // The web's exact grouping (chat.js viewItems): a dated separator when // the feed opens or the day changes, time-only past a 15-minute gap; // a same-sender run only continues when no separator intervenes. let separator = timeSeparator(at: m.at, previous: previous?.at) let startsRun = separator != nil || !ChatFeedModel.sameSender(previous, m) if let separator { separatorRow(separator, at: m.at) } HStack(spacing: 0) { if m.mine { Spacer(minLength: 48) } MessageBubble(message: m, showSender: isGroup && startsRun, senderName: model.senderName(m), theme: theme, onJump: { target in jump(to: target, proxy: proxy) }, onOpenThread: { root in openThread(root) }, blobSource: { att in model.blobSource(att) }, onImageTap: { att, image, frame in app.lightbox = LightboxItem(id: att.id, name: att.name, image: image, sourceFrame: frame) upgradeLightbox(att, frame: frame) }, onFileTap: { att in download(att) }, onVideoTap: { att, frame in playVideo(att, from: frame) }, downloading: videoDownload, actions: messageActions(m, times: true), dragItem: { att in guard att.isImage || att.isVideo else { return nil } return AttachmentDragItem(filename: att.name ?? (att.isImage ? "photo.jpg" : "video.mp4"), mimeType: att.type, isImage: att.isImage, fetch: { await model.blobData(att) }) }, unfurl: { url in let card = await model.unfurl(url) // The link card loads AFTER the send pin ran and // grows the bubble below the viewport — if this // is the last row and the reader is at the // bottom, re-pin so the posted link stays // visible (verified, like every other pin). if card != nil, m.id == model.feedMessages.last?.id, FeedGeometry.isNearBottom(lastRow: frames.rows[m.id], viewportHeight: frames.viewport.height) { // Gentle, not the cold-open loop: a card // growing one bubble is incremental content // on a feed that is already revealed, and the // heavy loop's jolt to the top of history is // far too big an instrument for it (#1502). DispatchQueue.main.async { settleBottomGently(proxy) } } return card }, onYouTube: { videoID, start, frame in app.youtubeLightbox = YouTubeLightboxItem(videoID: videoID, start: start, sourceFrame: frame) }) // A FIXED-width stage for the bubble (it aligns to its side // within), sized as if the gutter were always present. Only // YOUR bubbles slide (explicit offset) when times reveal — // their bubbles are already clear of the gutter and never move. .frame(width: stageWidth, alignment: m.mine ? .trailing : .leading) .offset(x: m.mine ? -timesReveal : 0) if !m.mine { Spacer(minLength: 48) } } // The time never participates in layout — it's a trailing overlay that // slides/fades in place, so row widths are constant and the LazyVStack // can't re-centre anything as the gutter reveals. .overlay(alignment: .trailing) { if timesReveal > 0 { Text(chatTime(m.at)) .font(.system(size: 11)) .foregroundStyle(.secondary) .fixedSize() .offset(x: (Self.timeGutter - timesReveal) * 0.6) .opacity(timesReveal / Self.timeGutter) } } // Leading 14 aligns the bubbles' left gutter with the chat header's // (its avatar sits at 14pt). .padding(EdgeInsets(top: startsRun ? 8 : 2, leading: 14, bottom: 2, trailing: 10)) .jumpWave(on: m.id == danceID, tick: danceTick) } // The list ⌘R / ⌘T read — see the tick handlers above. The feed's // VISIBLE list when no thread is open: a thread-only reply is never the // "latest message" in front of the reader. private var hotkeyMessages: [ChatFeedModel.Message] { guard let rootID = threadRootID else { return model.feedMessages } return ChatFeedModel.threadItems(in: model.messages, root: rootID) } // Materialised (an unrealised row has no catcher to pop a menu from) and // wholly inside the viewport, so the menu hangs off a bubble the reader // can see. private func rowOnScreen(_ id: Int64) -> Bool { guard let f = frames.rows[id] else { return false } return f.minY >= 0 && f.maxY <= frames.viewport.height } // The ended-DM banner that takes the composer's place (#578): inline in // the composer's layout slot — never floating over the feed. A refused // reopen swaps the line for its reason (their DM door / a blip), the // web's dm-closed-bar behaviour. @State private var reopenBusy = false @State private var reopenNote: String? private var dmClosedBanner: some View { HStack(spacing: 10) { Text(reopenNote ?? "This conversation is closed.") .font(.system(size: 13)) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) Button("Reopen") { guard let onReopenDM else { return } reopenNote = nil reopenBusy = true Task { switch await onReopenDM() { case .ok: break // the flag flips (store + model); the composer returns case .dmsClosed: reopenNote = "They're not accepting direct messages right now." case .failed: reopenNote = "Couldn't reopen — try again." } reopenBusy = false } } .disabled(reopenBusy || onReopenDM == nil) .accessibilityHint("Reopens this conversation for both of you") } .padding(.horizontal, 12) .padding(.vertical, 10) .frame(maxWidth: .infinity) .background(FrostedBand()) } // The native composer — the first native SEND path: the message is // encrypted in Swift (ChatFeedModel.send) and framed straight onto the // native WebSocket. Return sends (desktop shouldSend), ⌥Return newlines. // The bar itself is MessageComposerBar (file scope, below) — one // instantiable view, so the docked thread pane can mount a second one // with its own draft. This is the MAIN mount: user-armed reply/edit // banners (fixedReply nil), commands on, the command-host panel in the // slot, and the send/staging/host machinery below supplied as closures. private var composer: some View { MessageComposerBar( model: model, theme: theme, fixedReply: nil, allowCommands: true, placeholder: defaultCommand?.placeholder ?? "Message", // The bar's isActive is its may-claim-first-responder gate; with // the docked thread pane open, the caret belongs to the PANE's // bar, so the channel bar stands down (closeThread hands focus // back explicitly). Regaining the APP is the exception: the caret // comes back HERE even with a thread open, so this bar owns // focusOnActivate whenever its space is the one on screen. isActive: isActive && !(threadDocked && threadRootID != nil), focusOnActivate: isActive, settled: settled, formatAsYouType: formatAsYouType, roster: roster, myUserId: myUserId, hasBot: hasBot, appCommands: appCommands, hostRows: hostRows, mentionAvatar: mentionAvatar, draft: $draft, composerFocused: $composerFocused, controller: composerController, staged: staged, sendQueued: sendQueued, replyTo: replyTo, editingID: editingID, routeNotice: routeNotice, busyText: busyText, onSend: sendDraft, // The "+" and paste/drop staging are gone in a default-command // thread (To-do) — text-only surface, matching the web and iOS. onPickFiles: defaultCommand == nil ? { pickFiles(into: .main) } : nil, onUnstage: { s in staged.removeAll { $0.id == s.id } fireQueuedSend() // cancels or releases a queued send }, onCancelReply: cancelReply, onCancelEdit: cancelEdit, onDraftChanged: { text in // The typing beats: alive while there's a draft, an immediate // clear when it empties (edits don't announce themselves). model.typingBeat(active: !text.isEmpty && editingID == nil) // A "/" draft pre-warms the command host, so the page has booted // by the time Enter runs the command (a cold page queues it). if text.first == "/", commandHost == nil { ensureCommandHost() } }, onEscapeFallback: { // An open command panel is the most recently summoned layer — // Escape peels it (after the bar's own typeahead) before // reply/edit below (the page posts {panel: null} back, which // collapses the frame, so the panel's onClose hook always runs). if panelVisible { commandHost?.dismissPanel() return true } // This bar's OWN armed layer next — exactly what its banner // ✕ does. Handled here as well as by the ✕'s cancelAction so // the result doesn't hinge on AppKit's key-equivalent-vs- // keyDown order (whichever fires first consumes the key). if editingID != nil { cancelEdit() return true } if replyTo != nil { cancelReply() return true } // Nothing armed: a no-op, as before. The channel bar never // closes the docked pane — that's the pane bar's Escape, or // its ✕ — so a reflex Escape here can't take the thread away. return false }, ownsCancelKey: !paneFocused, onEditLatest: { // ↑ edits the latest own message IN THE FEED — a thread // reply of mine is edited from the pane (its own ↑). guard let last = model.feedMessages.last(where: { $0.mine && !$0.action }) else { return false } startEdit(last) return true }, // Paste an image (or a copied file) straight onto the draft — it // stages as an upload, like the web composer's paste; a file DRAG // landing on the field stages too. Absent in a default-command // thread (text-only surface), so a text paste stays a plain paste. onStagePaste: defaultCommand == nil ? { stagePasteboard($0) } : nil, onStageDrop: defaultCommand == nil ? { stageDrop($0) } : nil, panel: commandPanel ) } // A command's results panel (the command-host webview — the page draws its // own header + ✕): in-flow above the composer's banners (the bar's panel // slot), occupying layout like the web's .cmd-panel, sized by the page's // measure. Mounted PERMANENTLY at height 0 once the host exists, for three // reasons: revealing by height (not insertion) animates as a real ease-up // instead of SwiftUI's insertion fade; the page always has the pane's true // width, so its height measure is right the first time (no post-reveal // jump); and an attached webview keeps its rendering callbacks live. @ViewBuilder private var commandPanel: some View { if let host = commandHost { // The command-host page suppresses its own border/shadow (the // webview is edge-to-edge there) — this frame is the panel's // chrome: a hairline and a subtle lift, matching the web's // .cmd-panel. Both fade with visibility so the parked 0-height // frame leaves no ghost line above the composer. CommandPanelWebView(host: host) .frame(height: panelVisible ? panelHeight : 0) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder(.primary.opacity(panelVisible ? 0.08 : 0), lineWidth: 1) ) .shadow(color: .black.opacity(panelVisible ? 0.10 : 0), radius: 7, y: 2) .padding(.horizontal, 8) .allowsHitTesting(panelVisible) .accessibilityHidden(!panelVisible) } } private var hasContent: Bool { !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !staged.isEmpty } private var uploadsInFlight: Bool { staged.contains { $0.attachment == nil } } // Show (or clear, with nil) the default-command push-back. Every showing // bumps the generation, so an in-flight clear timer from an EARLIER // showing — even of the identical string — can never wipe this one early. private func setRouteNotice(_ text: String?) { routeNoticeGen += 1 routeNotice = text guard text != nil else { return } let gen = routeNoticeGen DispatchQueue.main.asyncAfter(deadline: .now() + 8) { if routeNoticeGen == gen { routeNotice = nil } } } // The thread's command host, created on first use (and pre-warmed the // moment a draft turns into a "/" — see onChange(of: draft)). Re-points // `sink` at THIS mount every call, like the model-cache re-point pattern, // so a revisited thread's cached host talks to the pane on screen. // The app-host projection queries (docs/native-apps.md phase 5): the // model asks the thread's command host to project app events it has no // built-in projection for. Lazy — the host webview only spins up when a // thread actually contains such an event (or a command is typed). private func installProjector() { guard let instance else { return } let thread = thread, myUserId = myUserId, ikB64 = instanceKeyB64 model.projector = { eventJSON, group in let host = CommandHostCache.shared.host(for: instance, thread: thread, myUserId: myUserId, instanceKeyB64: ikB64) return await host.projectEvent(eventJSON, group: group) } } @discardableResult // the "/" pre-warm wants the side effect, not the host private func ensureCommandHost() -> CommandHost? { guard let instance else { return nil } let host = commandHost ?? CommandHostCache.shared.host( for: instance, thread: thread, myUserId: myUserId, instanceKeyB64: instanceKeyB64) if commandHost == nil { commandHost = host } host.sink = { event in switch event { case .panel(_, let height, let animate): // the web panel draws its own header // The app's call (spec.animate): ease the reveal, or appear // instantly. The web panel skips its in-page ease in command- // host mode, so this is the ONE animation either way. panelAnimates = animate withAnimation(animate ? .easeOut(duration: 0.22) : nil) { panelHeight = min(height, 340) panelVisible = true } case .panelClosed: withAnimation(panelAnimates ? .easeIn(duration: 0.2) : nil) { panelVisible = false } case .panelHeight(let h): // Late re-measures (fonts, images) ease too — an abrupt snap // here read as a "jump" after the reveal. if panelVisible { withAnimation(panelAnimates ? .easeInOut(duration: 0.18) : nil) { panelHeight = min(h, 340) } } case .composerInsert(let text): // A panel acting on the composer (an /emoji tap) — inserted at // the caret, like the web's insertAtCaret. composerController.insert(text, replacingUTF16: nil) composerFocused = true case .notice(let from, let text): // A command's private "only visible to you" reply — the same // inline slot the default-command push-back uses. setRouteNotice(from.isEmpty ? text : "\(from) — \(text)") case .commands(let rows): // The thread's live command catalogue landed — the forwarding // decision follows it from here, and the composer bar re-ranks // its typeahead on the rows change (onChange(of: hostRows)). hostRows = rows } } // Cross-thread command routing (docs/native-apps.md phase 3): the page // asks for one narrow thread key ({needKey}) or an app's personal // thread ({needAppThread}); answer from what THIS member already // holds — never the private key. let origin = instance.origin host.keyProvider = { tid in guard let e = SharedThreadKeys.lookup(threadID: tid), e.origin == origin else { return nil } return e.key.base64urlEncodedString() } host.appThreadProvider = { [weak store] app, title in guard let store, let id = await store.openOrCreateAppThread(app: app, title: title) else { return nil } if let key = store.threads.first(where: { $0.id == id })?.threadKeyB64, !key.isEmpty { return (id, key) } return nil } if !host.commandRows.isEmpty, hostRows.isEmpty { hostRows = host.commandRows } return host } // The wire form of the draft: serialized markdown when the field is // mounted (styled runs become markers — the vector-pinned twin), the raw // draft otherwise. Commands and routing always read `draft` (the display // text), mirroring the web's plain/markdown split. private var wireDraft: String { // Opted out of format-as-you-type = typed markers are literal and // must render literally: escape them; only the WYSIWYG formats. let wire = composerController.serializedMarkdown(escapeLiterals: !formatAsYouType) return wire.isEmpty ? draft : wire } private func sendDraft() { // An edit replaces the message in place — no attachments/reply staging. // cancelEdit (not inline clears) so an edit inside an open thread view // hands the composer back to the thread's staged reply. if let id = editingID { if model.edit(id, text: wireDraft) { cancelEdit() } return } guard hasContent else { return } // Slash commands (only a bare text command — attachments/reply stage a // normal message). /me broadcasts an action line, /theme applies a // theme, everything else falls through to a normal send so a bot // answers it (matching the web). if staged.isEmpty, replyTo == nil, draft.first == "/" { switch SlashCommand.parse(draft, meName: model.myScreenName) { case .me(let line): if model.sendAction(line) { draft = "" } return case .setTheme(let id): themeName = id draft = "" return case .openThemes: draft = "" openThemeSettings() return case .integrity: draft = "" integrityReport = (model.integrity?.reportLines()).map { $0.joined(separator: "\n") } ?? "Integrity monitoring isn't active for this space." return case .help, .sendAsMessage: // Not the native floor. The current app-thread's own verbs // stay native (the default-command block below); everything // else consults the thread's commandItems() rows: a // bot-answered command rides to the conversation as a // message, and every framework-owned command — /emoji, /gif, // /todo, a custom app's verb, even a typo (the web engine // answers "Unknown command — try /help" as a private notice // instead of posting the literal) — forwards to the command // host. Cleared immediately, like the web composer — // runCommand consumes a command attempt synchronously (a // booting page queues it). let ownVerb = defaultCommand.map { AppDefaultCommand.routing(draft, spec: $0) != .passthrough } ?? false if !ownVerb, let host = ensureCommandHost(), CommandRouting.route(draft, rows: hostRows.isEmpty ? host.commandRows : hostRows) == .host { cmdLog.notice("forwarding to the command host: \(draft, privacy: .public)") host.run(draft) draft = "" // onChange(of: draft) ends the typing beat return } break // fall through — a bot answers /help and bot commands } } // Default-command thread (To-do, Reminders): plain text — or one of the // app's own verbs — becomes the app event instead of a chat message // nobody reads; a bare verb is consumed, never posted. Async because // Reminders schedules server-side first; the sync routing() pre-check // decides whether the draft is ours at all (passthrough → normal send). // The routing contract is shared with iOS via routeDefaultCommand. if staged.isEmpty, replyTo == nil, let dc = defaultCommand, AppDefaultCommand.routing(draft, spec: dc) != .passthrough { // One routing in flight at a time: the draft deliberately stays // visible during the schedule round-trip, so a second Enter must // not double-create the reminder. guard !dcRouting else { return } dcRouting = true let text = draft Task { @MainActor in defer { dcRouting = false } switch await model.routeDefaultCommand(text, spec: dc) { case .sent: // Clear only what we sent — typing during the schedule // round-trip must never be wiped by a stale completion. if draft == text { draft = "" } setRouteNotice(nil) // a success retires any stale push-back case .rejected(let prompt): // The app pushing back ("when should I remind you?") — the // draft stays put to fix; this line is the instruction. setRouteNotice(prompt) case .kept, .notHandled: break // draft stays put (down socket) } } return } // Mid-upload, the send queues instead of blocking (web PR #269): the // button spins and fireQueuedSend releases it when the batch resolves. guard !uploadsInFlight else { sendQueued = true return } // Only clear once the send reaches the socket — a down socket keeps the // draft to resend, never silently drops it (the web's rule). if model.send(wireDraft, attachments: staged.compactMap(\.attachment), replyTo: replyTo) { draft = "" staged = [] // Overlay model only: sending from an open thread view keeps it // open and re-stages the reply to its root, like iMessage (web // parity). Docked, the channel bar stays on the channel — the // pane's own composer carries the thread (sendPaneDraft). if threadRootID != nil, !threadDocked { stageThreadReply() } else { replyTo = nil } sendQueued = false } } // /themes (or a bare/unknown /theme) opens the Settings window, where the // theme picker lives — the native equivalent of the web's picker sheet. private func openThemeSettings() { NSApp.activate(ignoringOtherApps: true) if NSApp.responds(to: Selector(("showSettingsWindow:"))) { NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) } else { NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) } } // ---- the Threads list (chat.js paintThreadList / the ▤ Threads row) ----- // This chat's threads as a right-hand column — the SAME 340pt dock the // thread pane uses, with the same chrome, because it is the same slot. // Rows open a thread, which swaps this column for that thread's pane. private var threadListPane: some View { // openThread drops the list itself (one dock), so the column swaps in // one update rather than sliding out and back in. ReplyThreadListView(model: model, theme: theme, onOpen: { root in openThread(root) }, onClose: closeThreadList) .frame(width: 340) .background(theme.panel.resolved(scheme)) // The list OWNS the focus while it's up (openThreadList claims it), // so Escape lands here rather than on the composer it covered — // whose own Escape means "cancel the reply", not "close this". // Never destructive: unlike a composer, an index holds nothing you // could lose, so it needs no draft guard. .focusable() .focused($threadListFocused) // …but it is not a tab stop, so it draws no ring: an index is not // a control, and macOS would outline the whole 340pt column. The // rows inside are real buttons and keep their own focus rings, so // keyboard focus stays visible where it means something. .focusEffectDisabled() .onExitCommand { closeThreadList() } // A complementary landmark beside the feed, never a modal — the // same standing the thread pane has. .accessibilityElement(children: .contain) .accessibilityLabel("Threads") } // The list borrows the dock from whatever is in it (see the body) — an // open thread is left alone, composer and all, and comes back when the // list closes. It does take the CARET, though: a column you can't dismiss // from the keyboard is a trap, and the covered composer must not keep // first responder under it. private func openThreadList() { withAnimation(.easeOut(duration: 0.18)) { showThreadList = true } guard isActive else { return } paneFocused = false composerFocused = false threadListFocused = true } private func closeThreadList() { guard showThreadList else { return } withAnimation(.easeOut(duration: 0.15)) { showThreadList = false } // Closing a column must never leave the caret nowhere. Handing the // dock back to a thread that was already open puts it in that pane's // composer, where the reader left it; otherwise it goes to the channel // bar. Both gated on the pane actually being on screen — every visited // space stays mounted, and a parked one must never steal first // responder from the visible space. guard isActive else { return } if threadRootID != nil, threadDocked { paneFocused = true } else { composerFocused = true } } // The header kebab's Threads row, arriving over MacAppState (the header is // painted a view up). Consumed once, like a search hit's jump — and only // by the feed the request names, since every visited space stays mounted. // // Also drained on appear, for the same reason consumeJump is: the header // is drawn in states where this feed isn't mounted at all (a group in // Interface mode, an app thread's pane), so a click there waits here and // opens the list when the conversation comes back — rather than being // silently dropped. private func consumeThreadListRequest() { guard isActive, app.nativeReplyThreadList?.thread == threadID else { return } app.nativeReplyThreadList = nil openThreadList() } // ---- reply-thread view (chat.js openThread/closeThread/stageThreadReply) -- // The lifted thread panel over the dimmed feed pane. Rows reuse the exact // MessageBubble wiring the feed uses (right-click menu, lightboxes, // drag-out), so everything keeps working inside the view; reply actions in // here resolve to the thread's ROOT via the same startReply path. Escape // closes it through the reply banner's ✕ (.cancelAction → cancelReply). private func threadOverlay(_ rootID: Int64) -> some View { ReplyThreadView(model: model, rootID: rootID, isGroup: isGroup, jumpTarget: threadJump, jumpTick: threadJumpTick, onClose: closeThread, onRootPaged: { // The root just paged in — refresh the banner to its // live snapshot, unless an edit borrowed the composer. if editingID == nil, replyTo?.id == rootID { stageThreadReply() } }, row: { m, showSender in threadBubble(m, showSender: showSender) }) } // The DOCKED thread pane (spec §6): ReplyThreadView's panel content — // header/✕, paging note, the root and its replies as one list — as a // flush right-hand column with its OWN composer at the bottom, the web's // .chatapp-thread-panel--docked. Root-paging and the empty-thread // auto-close live in ReplyThreadView and ride along unchanged. No // onRootPaged: there's no staged banner to refresh — the pane's reply // target derives fresh at send. Its own menu scope, like the overlay, so // ⌘T pops the catcher on the surface in front of the reader. private func threadPane(_ rootID: Int64) -> some View { VStack(spacing: 0) { ReplyThreadView(model: model, rootID: rootID, isGroup: isGroup, docked: true, jumpTarget: threadJump, jumpTick: threadJumpTick, onClose: closeThread, row: { m, showSender in threadBubble(m, showSender: showSender, pane: true) }) // An ended DM refuses new messages — the pane goes composer-less, // like the channel bar giving way to the closed banner. if !model.dmClosed { Divider() paneComposer } } .frame(width: 340) // The web dock's background: var(--panel), not the overlay's frosted // material — a flush column sits on the window, not over the feed. .background(theme.panel.resolved(scheme)) .environment(\.messageMenuScope, threadMenuScope) // The web dock is a complementary landmark ("Reply thread"); the // panel content inside carries the header/controls. .accessibilityElement(children: .contain) .accessibilityLabel("Reply thread") // Track this column's window frame so a drop can be routed by where // it landed (dropTarget(at:)) — read lazily, like paneFrame. .trackingFrame(into: threadPaneFrame) } // The pane's composer — a SECOND MessageComposerBar over its own state // set. fixedReply marks it a pane bar (no reply banner, no ✕ — the // target is the thread's root, derived fresh at send in sendPaneDraft); // commands stay off, so "/" text sends verbatim (spec risk 3). The edit // banner DOES render here (§10.5): editing a thread reply from the pane // is legitimate, with its ✕ cancelling back to the plain reply state. private var paneComposer: some View { MessageComposerBar( model: model, theme: theme, fixedReply: { threadRootID.map { model.threadReplyRef(root: $0) } }, allowCommands: false, placeholder: "Reply…", isActive: isActive, // The pane never takes the caret on app activation — the channel // bar does (see its focusOnActivate). Opening the thread, replying // to a pane bubble and a notification landing all still focus here // explicitly; this is only about coming back to the app. focusOnActivate: false, settled: settled, formatAsYouType: formatAsYouType, roster: roster, myUserId: myUserId, hasBot: hasBot, appCommands: [], hostRows: [], mentionAvatar: mentionAvatar, draft: $paneDraft, composerFocused: $paneFocused, controller: paneController, staged: paneStaged, sendQueued: paneSendQueued, replyTo: nil, editingID: paneEditingID, routeNotice: nil, busyText: paneBusyText, onSend: sendPaneDraft, onPickFiles: defaultCommand == nil ? { pickFiles(into: .pane) } : nil, onUnstage: { s in paneStaged.removeAll { $0.id == s.id } firePaneQueuedSend() // cancels or releases a queued send }, onCancelReply: {}, // never rendered — fixedReply is non-nil onCancelEdit: cancelPaneEdit, onDraftChanged: { text in // Same thread, same typing beats; no "/" host pre-warm — the // pane runs no commands. model.typingBeat(active: !text.isEmpty && paneEditingID == nil) }, onEscapeFallback: { // The pane's ladder, innermost first (after the bar's own // typeahead): an armed edit cancels — the same peel as its // banner ✕, handled here too so it doesn't hinge on AppKit's // key-equivalent-vs-keyDown order — then typed content makes // Escape a no-op (keyboard Escape is never destructive of a // draft or staged files, the channel bar's rule; a deliberate // close with content is the ✕), and only an EMPTY pane // composer closes the thread — the overlay's contract. if paneEditingID != nil { cancelPaneEdit() return true } if paneHasContent { return false } closeThread() return true }, ownsCancelKey: paneFocused, onEditLatest: { // ↑ edits the latest own message IN THIS THREAD (root // included — it's pinned right here), not the channel's. guard let rootID = threadRootID, let last = ChatFeedModel.threadItems(in: model.messages, root: rootID) .last(where: { $0.mine && !$0.action }) else { return false } startPaneEdit(last) return true }, onStagePaste: defaultCommand == nil ? { stagePasteboard($0, into: .pane) } : nil, onStageDrop: defaultCommand == nil ? { stageDrop($0, into: .pane) } : nil, panel: EmptyView() ) } private var paneHasContent: Bool { !paneDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !paneStaged.isEmpty } private var paneUploadsInFlight: Bool { paneStaged.contains { $0.attachment == nil } } private var paneWireDraft: String { let wire = paneController.serializedMarkdown(escapeLiterals: !formatAsYouType) return wire.isEmpty ? paneDraft : wire } // The pane's send: a replyTo reply flagged thread-only (ReplyRef.thread // — it lives in the thread, never as a main-feed bubble; Slack's model) // — no command routing, no default-command detour (matching the web pane // composer). The reply target derives FRESH here (ChatFeedModel // .threadReplyRef): the root may have paged in since the pane opened, and // a loaded root snapshots better than the frozen quote. private func sendPaneDraft() { // An edit replaces the message in place — same rule as the channel bar. if let id = paneEditingID { if model.edit(id, text: paneWireDraft) { cancelPaneEdit() } return } guard paneHasContent, let rootID = threadRootID else { return } // Mid-upload, the send queues instead of blocking (web PR #269). guard !paneUploadsInFlight else { paneSendQueued = true return } // Only clear once the send reaches the socket — a down socket keeps // the draft to resend, never silently drops it (the web's rule). if model.send(paneWireDraft, attachments: paneStaged.compactMap(\.attachment), replyTo: model.threadReplyRef(root: rootID).inThread()) { paneDraft = "" paneStaged = [] paneSendQueued = false paneFocused = true // the next reply continues the thread (web parity) } } private func firePaneQueuedSend() { guard paneSendQueued else { return } guard paneHasContent else { paneSendQueued = false return } if !paneUploadsInFlight { paneSendQueued = false sendPaneDraft() } } private func startPaneEdit(_ m: ChatFeedModel.Message) { paneEditingID = m.id paneDraft = m.raw.text ?? m.text paneFocused = true } private func cancelPaneEdit() { paneEditingID = nil paneDraft = "" } // The feed hands the lightbox its downsampled thumbnail so the zoom opens // instantly; the full-fidelity decode (off-main, cached under its own size // class) swaps in underneath once it lands — unless the lightbox has // already moved on or closed. (Animated GIFs decode full in the feed, so // the swap is a cache hit and a no-op.) private func upgradeLightbox(_ att: Attachment, frame: CGRect) { guard let source = model.blobSource(att) else { return } Task { guard let full = await BlobImageLoader.shared.image(source), app.lightbox?.id == att.id else { return } app.lightbox = LightboxItem(id: att.id, name: att.name, image: full, sourceFrame: frame) } } // One catalog for the feed and the thread view — reply/edit/delete/copy/ // react (the feed adds Show Times), plus Mute/Unmute-sender (#578) on // others' uid-carrying messages (uid-less legacy ones can't be keyed). private func messageActions(_ m: ChatFeedModel.Message, times: Bool) -> MessageActions { var actions = MessageActions( canEdit: model.canEdit(m), onReply: { startReply(m) }, onReplyInThread: { replyInThread(m) }, onEdit: { startEdit(m) }, onDelete: { model.deleteMessage(m.id) }, onCopy: { NSPasteboard.general.clearContents() NSPasteboard.general.setString(m.text, forType: .string) }, onReact: { emoji in model.toggleReaction(m.id, emoji: emoji) }) // "Convert to thread" — only where there IS an inline chain under this // message to move, which is also what keeps it off a message nobody // answered and off the pane's bubbles (nothing inline hangs off one). // Anyone in the chat may convert: it reorganises where messages render, // it doesn't touch what anyone wrote. The gate is the folded flag (this // runs for every row on screen); the chain itself is walked at the // click, so what moves is what's on screen at that moment. if m.hasInlineChain { actions.onConvertToThread = { let ids = ChatFeedModel.inlineChainIDs(in: model.messages, root: m.id) if !ids.isEmpty { convertTarget = ConvertTarget(root: m.id, ids: ids) } } } if times { actions.timesShown = timesShown actions.onToggleTimes = { withAnimation(.easeOut(duration: 0.25)) { timesReveal = timesShown ? 0 : Self.timeGutter } } } if let onMutePerson, !m.mine, let uid = m.raw.uid, !uid.isEmpty { let muted = model.mutedPeople.contains(uid) actions.senderMuted = muted actions.senderLabel = model.senderName(m) actions.onMutePerson = { onMutePerson(uid, !muted) } } // Report to the space's admins (#578) — same gate as the mute: never // your own messages, and a uid to report (legacy uid-less messages // name no one). if let onReport, !m.mine, let uid = m.raw.uid, !uid.isEmpty { actions.onReport = { onReport(m) } } return actions } // Routing (spec §10.5): Reply/Edit on a bubble rendered INSIDE the docked // pane target the PANE's bar — Reply just puts the caret there (its fixed // reply already targets this thread's root), Edit stages on it. Main-feed // bubbles keep the channel bar (messageActions above), unchanged. private func paneMessageActions(_ m: ChatFeedModel.Message) -> MessageActions { var actions = messageActions(m, times: false) actions.onReply = { paneFocused = true } // "Reply in Thread" from inside the pane would resolve to the thread // already open and do exactly what Reply does here (put the caret in // the pane's bar) — two rows for one act is noise, so the pane's // menus omit it. (The web keeps it as a harmless refocus; the Mac // NSMenu is denser, so it drops.) actions.onReplyInThread = nil // Nor "Convert to thread": a bubble in the pane is already in a thread, // so inlineChainIDs finds nothing under it anyway — except on the // thread's own ROOT, which can still have inline replies out in the // feed. Tidying those is a thing you do while looking at the feed. actions.onConvertToThread = nil actions.onEdit = { startPaneEdit(m) } return actions } @ViewBuilder private func threadBubble(_ m: ChatFeedModel.Message, showSender: Bool, pane: Bool = false) -> some View { // The muted fold holds inside the thread view too (#578). if model.foldsAsMuted(m) { MutedMessageStub { model.revealMuted(m.id) } } else { threadMessageBubble(m, showSender: showSender, pane: pane) } } private func threadMessageBubble(_ m: ChatFeedModel.Message, showSender: Bool, pane: Bool = false) -> some View { MessageBubble(message: m, showSender: showSender, senderName: model.senderName(m), theme: theme, blobSource: { att in model.blobSource(att) }, onImageTap: { att, image, frame in app.lightbox = LightboxItem(id: att.id, name: att.name, image: image, sourceFrame: frame) upgradeLightbox(att, frame: frame) }, onFileTap: { att in download(att) }, onVideoTap: { att, frame in playVideo(att, from: frame) }, downloading: videoDownload, actions: pane ? paneMessageActions(m) : messageActions(m, times: false), dragItem: { att in guard att.isImage || att.isVideo else { return nil } return AttachmentDragItem(filename: att.name ?? (att.isImage ? "photo.jpg" : "video.mp4"), mimeType: att.type, isImage: att.isImage, fetch: { await model.blobData(att) }) }, unfurl: { url in await model.unfurl(url) }, onYouTube: { videoID, start, frame in app.youtubeLightbox = YouTubeLightboxItem(videoID: videoID, start: start, sourceFrame: frame) }) // The jump wave, on the thread surface (jumpIntoThread's target). .jumpWave(on: m.id == threadDanceID, tick: threadDanceTick) } // A jump whose target lives only in its thread — loaded, but absent from // the transcript (mainFeedItems: a thread reply with its root on screen): // open the docked pane on the root (a no-op when already open there), // then land on THAT reply inside it and wave it — the feed jump's two // beats, on the pane. The web lands inside .chatapp-thread-msgs the same // way, so a search hit or media-sheet jump never dead-ends on "the // newest reply" instead of the one asked for. private func jumpIntoThread(_ target: ChatFeedModel.Message) { guard let root = ChatFeedModel.threadRootID(of: target) else { return } openThread(root) threadJump = target.id threadJumpTick += 1 DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { threadDanceID = target.id threadDanceTick += 1 } } // "Reply in Thread" (thread-pane spec §3.5; web openReplyThread): open the // message's thread — threadTarget semantics, so a message already IN a // thread continues it while anything else roots a NEW thread on itself — // with the caret in the pane's bar (openThread claims that focus). The // thread already open on that root: just the caret, the same as Reply on // one of the pane's own bubbles. This verb is the ONLY way a thread ever // begins; nothing is absorbed into one by replying. private func replyInThread(_ m: ChatFeedModel.Message) { guard let rootID = model.threadTarget(for: m).id, rootID > 0 else { return } if threadRootID == rootID { if threadDocked { paneFocused = true } else { stageThreadReply() } } else { openThread(rootID) } } private func openThread(_ rootID: Int64) { // Whether the right column is ALREADY standing, read before // closeThreadList() below clears the flag it depends on. let columnStanding = showThreadList || threadRootID != nil // One right dock: a thread takes it back from the Threads list, // however the thread was opened (a row, a tally, a jump) — the list is // never left over the pane. Ahead of the guard, so a row for the // thread ALREADY open still returns you to it, caret and all: the // guard below leaves early, and nothing after it would run. if showThreadList { // Same transaction as the open below where one follows the other, // so the column's contents cross-fade once instead of racing two // animations against each other. closeThreadList() if threadRootID == rootID { return } } guard rootID > 0, threadRootID != rootID else { return } // A fresh open lands at the newest reply — never on a stale jump // target from an earlier one (jumpIntoThread re-aims right after). threadJump = nil // Switching roots with the pane open: the old thread's half-typed // draft or in-flight edit must not leak into the new one. if threadDocked, threadRootID != nil { resetPaneComposer() } // Animate the ARRIVAL of the column, not a swap inside one that is // already standing: coming from the list, the cross-fade above is the // whole transition, and animating the mount as well is what made a // first-time pane grow into place. if columnStanding { threadRootID = rootID } else { withAnimation(.easeOut(duration: 0.18)) { threadRootID = rootID } } if threadDocked { // The channel bar stays on the channel — no staging; the pane's // composer derives its reply fresh at send. Opening puts the // caret in the pane's bar (its onAppear claims focus on first // mount; this write covers a root SWITCH, when the bar is // already mounted — the channel field resigns via onFocusChange). paneFocused = true } else { stageThreadReply() } } // Stage the composer's reply on the thread's root — the view's composer // contract ("Replying to "). A reply displaces an in-flight // edit; desktop focuses the composer, like the web. Flagged thread-only: // a send from an open thread view lives in the thread (the same flag the // docked pane's bar sets), never as a main-feed bubble. private func stageThreadReply() { guard let rootID = threadRootID else { return } editingID = nil replyTo = model.threadReplyRef(root: rootID).inThread() composerFocused = true } private func closeThread() { guard let rootID = threadRootID else { return } withAnimation(.easeOut(duration: 0.15)) { threadRootID = nil } threadJump = nil if threadDocked { // The pane's composer dies with the pane (the web's docked pane // block is destroyed on close); focus returns to the channel bar // — but only on the pane actually on screen: every visited space // stays mounted hidden, and the empty-thread auto-close fires on // parked views too, which must never steal first responder from // the visible space (the same isActive gate every focus claim // in this file wears). resetPaneComposer() paneFocused = false if isActive { composerFocused = true } } else { // Leaving the overlay drops its staged reply — never an unrelated // one staged since (an edit clears replyTo itself, so that // survives). Docked, a reply armed on the root from the MAIN feed // is the reader's own staging — leave it alone. if replyTo?.id == rootID { replyTo = nil } } } // ---- the docked pane's composer state (thread-pane spec §6) ------------ private func resetPaneComposer() { paneDraft = "" paneStaged = [] paneEditingID = nil paneSendQueued = false paneBusyText = nil } // ---- reply / edit staging (the composer banners) ---------------------- private func startReply(_ m: ChatFeedModel.Message) { editingID = nil // Belt for the parked overlay path. Mac v1 always docks // (`threadDocked` is a constant true), and docked pane bubbles route // to the pane's own bar (paneMessageActions), so this branch is // unreachable today — it exists so the overlay layout, if it's ever // switched back on here, can't regress the way it would without it: // under the overlay the channel bar IS the open thread's reply box, so // a Reply on one of the view's bubbles must stage the root WITH the // thread flag rather than quote that bubble into the transcript. if let rootID = threadRootID, !threadDocked, model.threadTarget(for: m).id == rootID { stageThreadReply() return } // Plain Reply quotes the EXACT message tapped — never a root walk. // Replying to a reply stacks another quote in the feed; joining a // thread is the other verb's job ("Reply in Thread"), and only that // verb's. replyTo = model.quoteTarget(for: m) composerFocused = true } // While the OVERLAY thread view is open its staged reply IS the view's // composer state — cancelling it (the banner's ✕, or Escape via // .cancelAction) means leaving the thread, web parity. Docked, the // channel bar's banner is always the reader's own staging: cancel just // cancels, and the pane closes through its own Escape/✕. private func cancelReply() { if threadRootID != nil, !threadDocked { closeThread() } else { replyTo = nil } } private func startEdit(_ m: ChatFeedModel.Message) { replyTo = nil editingID = m.id draft = m.raw.text ?? m.text composerFocused = true } private func cancelEdit() { editingID = nil draft = "" // Overlay model: an edit inside an open thread view borrowed the // composer (startEdit clears replyTo); hand it back so the next send // still continues the thread (web cancelEdit). Docked edits ride the // pane's own bar (cancelPaneEdit), so nothing to hand back. if threadRootID != nil, !threadDocked { stageThreadReply() } } // A queued send resolves whenever the staged set changes: nothing left to // send cancels it; the last upload finishing releases it. private func fireQueuedSend() { guard sendQueued else { return } guard hasContent else { sendQueued = false return } if !uploadsInFlight { sendQueued = false sendDraft() } } // Which composer a staged file lands on: the channel bar or the docked // thread pane's. Threaded through the one staging/upload pipeline below // so both bars share it — each keeps its own chip list and busy line. private enum ComposerTarget { case main, pane } private func stagedBinding(_ target: ComposerTarget) -> Binding<[Staged]> { target == .main ? $staged : $paneStaged } private func setStageBusy(_ text: String?, _ target: ComposerTarget) { switch target { case .main: busyText = text case .pane: paneBusyText = text } } // Stage photos / allowlisted documents: read, downscale+re-encode (images), // encrypt, upload — all native (ChatFeedModel.uploadImage/uploadFile); the // chip flips from spinner to icon when its blob id comes back. private func pickFiles(into target: ComposerTarget = .main) { let panel = NSOpenPanel() panel.canChooseFiles = true panel.allowsMultipleSelection = true panel.begin { response in guard response == .OK else { return } for url in panel.urls { stage(url, into: target) } switch target { case .main: composerFocused = true case .pane: paneFocused = true } } } // Pasted content (from MacComposerField's paste override): file URLs stage // directly; raw image data stages as a PNG (like the web composer's // paste-to-upload). Returns whether anything staged, so the field knows // the paste was (partly) consumed. Drops ride their own pipeline // (stageDrop) — a drag can also carry file PROMISES, which a paste never does. private func stagePasteboard(_ pb: NSPasteboard, into target: ComposerTarget = .main) -> Bool { if let urls = pb.readObjects(forClasses: [NSURL.self]) as? [URL] { let files = urls.filter(\.isFileURL) if !files.isEmpty { files.forEach { stage($0, into: target) } return true } } for type in [NSPasteboard.PasteboardType.png, .tiff] { guard let data = pb.data(forType: type) else { continue } stage(data: data, name: "pasted.png", type: type == .png ? .png : .tiff, into: target) return true } return false } // ONE pipeline for every AppKit drag (the window drop zone, the composer // field's backstop): real file URLs stage directly; file promises — how // most apps vend drags (CleanShot, Photos, browsers) — are received into // a temp folder and staged from there; raw image data stages as a file. // URLs win over promises when a drag carries both, so one item never // stages twice. @discardableResult private func stageDrop(_ pb: NSPasteboard, into target: ComposerTarget = .main) -> Bool { // Verification tap (types, never content) — drags arrive in wildly // different shapes per source app; this is how the next "my drag // doesn't work" gets diagnosed without an instrumented build. Logger.identity.info("drop pasteboard types: \((pb.types ?? []).map(\.rawValue).joined(separator: ", "), privacy: .public)") if let urls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL], !urls.isEmpty { urls.forEach { stage($0, into: target) } return true } if let receivers = pb.readObjects(forClasses: [NSFilePromiseReceiver.self]) as? [NSFilePromiseReceiver], !receivers.isEmpty { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("drop-" + UUID().uuidString, isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) for receiver in receivers { receiver.receivePromisedFiles(atDestination: dir, options: [:], operationQueue: Self.promiseQueue) { url, error in if let error { Logger.identity.error("drop promise failed: \(String(describing: error), privacy: .public)") return } DispatchQueue.main.async { stage(url, into: target) } } } return true } for type in [NSPasteboard.PasteboardType.png, .tiff] { guard let data = pb.data(forType: type) else { continue } stage(data: data, name: type == .png ? "dropped.png" : "dropped.tiff", type: type == .png ? .png : .tiff, into: target) return true } return false } // Promise fulfilment happens off-main (the source app writes the file); // staging hops back to main. private static let promiseQueue: OperationQueue = { let q = OperationQueue() q.qualityOfService = .userInitiated return q }() private func stage(_ url: URL, into target: ComposerTarget = .main) { // A dropped/picked file can be tens of MB — the read happens off main // (Data(contentsOf:) is a synchronous disk read on the staging frame, // #465's Mac twin). Task { guard let data = await Task.detached(priority: .userInitiated, operation: { try? Data(contentsOf: url) }).value else { return } stage(data: data, name: url.lastPathComponent, type: UTType(filenameExtension: url.pathExtension), into: target) } } private func stage(data: Data, name: String, type: UTType?, into target: ComposerTarget = .main) { // The target bar's chip list (main or the docked pane's) — the whole // pipeline below reads/writes through this one binding. let staged = stagedBinding(target) let isImage = type?.conforms(to: .image) ?? false let isVideo = type?.conforms(to: .movie) ?? false // Any file type stages — size is the only gate (matching the web). if isVideo, data.count > maxVideoUploadBytes { setStageBusy("\(name): videos are capped at 50 MB", target) DispatchQueue.main.asyncAfter(deadline: .now() + 3) { setStageBusy(nil, target) } return } let entry = Staged(name: name, isImage: isImage) staged.wrappedValue.append(entry) if isImage { // The chip's thumbnail is a full image decode + downscale — off // main like the upload's own processing; the chip upgrades from // ring-only the moment it lands (same shape as the video poster). let entryID = entry.id Task { if let thumb = await Task.detached(priority: .userInitiated, operation: { processImage(data, maxDim: 288) }).value, let i = staged.wrappedValue.firstIndex(where: { $0.id == entryID }) { staged.wrappedValue[i].preview = PlatformImage(data: thumb.bytes) } } } if isVideo { // Poster thumbnails arrive async — the chip upgrades from ring-only // to a real frame the moment extraction lands. let entryID = entry.id Task { if let poster = await videoPosterImage(data: data, mimeType: type?.preferredMIMEType), let i = staged.wrappedValue.firstIndex(where: { $0.id == entryID }) { staged.wrappedValue[i].preview = poster } } } let onProgress: @MainActor (Double) -> Void = { p in if let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) { staged.wrappedValue[i].progress = p } } Task { let att: Attachment? if isImage { att = await model.uploadImage(data, name: name, onProgress: onProgress) } else if isVideo { att = await model.uploadVideo(data, name: name, mimeType: type?.preferredMIMEType, onProgress: onProgress) } else { att = await model.uploadFile(data, name: name, mimeType: type?.preferredMIMEType, onProgress: onProgress) } if let att, let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) { staged.wrappedValue[i].attachment = att } else { staged.wrappedValue.removeAll { $0.id == entry.id } if att == nil { setStageBusy("couldn't upload \(name)", target) } DispatchQueue.main.asyncAfter(deadline: .now() + 3) { setStageBusy(nil, target) } } // The batch resolving may release a queued send — on ITS bar. switch target { case .main: fireQueuedSend() case .pane: firePaneQueuedSend() } } } // A pending open-on-message request (search, or a notification tap's // stamped message): once this feed is the target and ready, land on the // message — jump() pages back for it through reveal() when it isn't // loaded, so a target that can't be reached is explained inline like every // other jump instead of doing nothing. (This used to carry its own // hand-rolled walk ending in a bare `return`.) private func consumeJump(_ proxy: ScrollViewProxy) { guard let j = app.nativeJump, j.thread == threadID, model.ready else { return } app.nativeJump = nil jump(to: j.message, proxy: proxy) } private func jump(to id: Int64, proxy: ScrollViewProxy) { guard let target = model.messages.first(where: { $0.id == id }) else { // Not loaded at all — older than the page we hold. Page back for // it rather than doing nothing: this is the ordinary case for the // quote card above a plain reply on a cold-opened chat. reveal(id, proxy: proxy) return } // Loaded but not in the transcript = a thread-only reply whose root // is loaded (mainFeedItems): its bubble is in the pane, so land // there. (With the root unloaded it degrades to an inline bubble and // the ordinary scroll below finds it.) if !model.feedMessages.contains(where: { $0.id == id }) { jumpIntoThread(target) return } // Already fully in view (with a little headroom for the wave)? Don't // yank the feed around — just wave where it stands. if let f = frames.rows[id], f.minY >= 12, f.maxY <= frames.viewport.height - 4 { danceID = id danceTick += 1 return } // Not visible: scroll it near the top, leaving headroom above so the // wave's tiptoe stretch + wag never clip at the viewport edge. withAnimation { proxy.scrollTo(id, anchor: UnitPoint(x: 0, y: 0.06)) } // Let the scroll land first, then dance. DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { danceID = id danceTick += 1 } } // Page history back for a jump target that isn't loaded, then land on it — // the web's revealMessage. Never a silent no-op: the wait says so inline, // and a walk that can't reach the message says which kind of can't // (deleted vs. further back than one jump goes). One walk at a time. // // Cannot recurse: model.pageBack returning .found means the message IS in // model.messages, so the jump below takes its loaded path and can't come // back here. private func reveal(_ id: Int64, proxy: ScrollViewProxy) { guard revealing == nil, id > 0 else { return } revealing = id showFeedNote("Looking for that message…", sticky: true) // the outcome replaces it Task { let reach = await model.pageBack(to: id) revealing = nil showFeedNote(ChatFeedModel.jumpReachNote(reach)) guard reach == .found, model.messages.contains(where: { $0.id == id }) else { return } try? await Task.sleep(nanoseconds: 150_000_000) // let the prepended rows land jump(to: id, proxy: proxy) } } // The note answers something the reader just did, so it clears itself // rather than going stale behind a thread switch — the transient-status // convention busyText/routeNotice already use here. A note that's still // true while work is in flight ("Looking for that message…") is `sticky`: // it's replaced by the outcome, never timed out from under a walk that // runs long. The generation counter keeps every clear cancel-safe, like // routeNoticeGen. private static let feedNoteSeconds: TimeInterval = 6 private func showFeedNote(_ text: String?, sticky: Bool = false) { feedNote = text feedNoteGen += 1 guard text != nil, !sticky else { return } let gen = feedNoteGen DispatchQueue.main.asyncAfter(deadline: .now() + Self.feedNoteSeconds) { if feedNoteGen == gen { feedNote = nil } } } // Download an attachment: fetch + decrypt, save straight to ~/Downloads // (unique name, Finder reveal) — the same Safari-like convention the // webview's WKDownload path uses. Writing plaintext is fine here: it's the // member's own deliberate save, exactly like the web's file-chip click. private func download(_ att: Attachment) { Task { guard let data = await model.blobData(att) else { return } save(data, name: att.name ?? "file") } } private func save(_ data: Data, name: String) { let dir = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory let dest = uniqueDestination(in: dir, filename: name) guard (try? data.write(to: dest, options: .atomic)) != nil else { return } NSWorkspace.shared.activateFileViewerSelecting([dest]) } // Play a video in the lightbox: decrypt into memory, serve it to AVPlayer // through MemoryVideoAsset (never touching disk). The web's canPlayType // probe becomes AVAsset's isPlayable — an unplayable codec falls back to // a plain download, like the web's download-only card. private func playVideo(_ att: Attachment, from frame: CGRect) { Task { // No filename caption under the composer — the video card grows its // own progress ring (videoDownload below), which is feedback enough. // The web's 150ms grace: only a fetch that outlives it grows a ring. let started = Date() defer { busyText = nil videoDownload = nil } let data = await model.blobData(att) { progress in guard Date().timeIntervalSince(started) > 0.15 else { return } videoDownload = (att.id, progress) } guard let data else { busyText = "couldn't fetch that video" try? await Task.sleep(for: .seconds(2)) return } let loader = MemoryVideoAsset(data: data, mimeType: att.type) let asset = loader.makeAsset() let playable = (try? await asset.load(.isPlayable)) ?? false guard playable else { save(data, name: att.name ?? "video") return } app.videoLightbox = VideoLightboxItem( id: att.id, name: att.name, player: AVPlayer(playerItem: AVPlayerItem(asset: asset)), loader: loader, videoSize: CGSize(width: att.w ?? 0, height: att.h ?? 0), sourceFrame: frame) } } // "name.ext", then "name 2.ext", … — the Finder convention (the same helper // as ChatFeedWebView's; folds into one place when the webview retires). private func uniqueDestination(in dir: URL, filename: String) -> URL { let fm = FileManager.default let name = filename.isEmpty ? "download" : filename var candidate = dir.appendingPathComponent(name) guard fm.fileExists(atPath: candidate.path) else { return candidate } let ext = (name as NSString).pathExtension let base = (name as NSString).deletingPathExtension var n = 2 repeat { let next = ext.isEmpty ? "\(base) \(n)" : "\(base) \(n).\(ext)" candidate = dir.appendingPathComponent(next) n += 1 } while fm.fileExists(atPath: candidate.path) return candidate } } // Attachments staged on the draft: picked → uploading (spinner) → ready. // Several can ride one message, like the web's pending list. File scope: // NativeFeedView owns the upload pipeline that fills these; MessageComposerBar // renders them as chips. struct Staged: Identifiable { let id = UUID() let name: String let isImage: Bool var preview: PlatformImage? // image picks show a real thumbnail (web-style) var progress = 0.0 // request-body bytes sent, 0…1 var attachment: Attachment? // nil while the encrypt+upload is in flight } // The chat composer as ONE instantiable view — extracted from NativeFeedView // so the docked reply-thread pane can mount a SECOND composer, with its own // draft and typeahead, beside the main bar. The bar owns only what is // genuinely composer-internal (caret, measured field height, the typeahead); // everything code outside the composer reaches — the draft (startEdit writes // it), focus (pane switches claim/release it), reply/edit staging (thread // open/close, ⌘R), the staged uploads (the window drop zone feeds them), the // command host — stays with the container and arrives as bindings/values/ // closures, so a second instance is just a second container state set. // // The two presentation knobs (the thread-pane spec §6): // - fixedReply: nil = the MAIN bar — user-armed reply/edit banners. Non-nil = // a pane bar: the reply target derives fresh at send time (inside the // container's onSend), so no reply banner and no cancel-✕ render here. // - allowCommands: false = no slash-command typeahead — "/" text sends // verbatim (the container's onSend skips command routing and passes no // panel). @mention and :emoji: completion still work. struct MessageComposerBar: View { let model: ChatFeedModel let theme: ChatTheme let fixedReply: (() -> ReplyRef?)? let allowCommands: Bool let placeholder: String // The container's pane-visibility + reveal gates the focus handlers key // off — every opened instance's pane stays mounted hidden (RootView's // ZStack), so only the on-screen bar may claim first responder. let isActive: Bool // …and which bar takes the caret when the APP comes forward, which is not // the same question: with a thread pane open the channel bar stands down // from claiming on open/reveal (isActive false — the pane owns the caret // there), yet regaining the app must still land in the CHANNEL's box, not // the thread's (Paul, 2026-08-29). Exactly one bar per screen says true. let focusOnActivate: Bool let settled: Bool let formatAsYouType: Bool // Typeahead inputs (the web shell.js typeaheadItems' arguments). let roster: [RosterEntry] let myUserId: String let hasBot: Bool let appCommands: [(name: String, desc: String)] let hostRows: [CommandRow] let mentionAvatar: (String) -> AvatarSource? // Container-owned composer state (written from outside the bar — see the // header comment). @Binding var draft: String @Binding var composerFocused: Bool // Programmatic edits (typeahead splice, panel inserts) act on the view via // the controller, so the caret survives them. Container-held: the command // host's sink inserts through it. let controller: MacComposerController let staged: [Staged] let sendQueued: Bool let replyTo: ReplyRef? let editingID: Int64? let routeNotice: String? let busyText: String? // Actions the container supplies — the send path, the staging pipeline // and the command host stay with it (they reach thread/instance state). let onSend: () -> Void let onPickFiles: (() -> Void)? // nil hides the "+" (default-command threads) let onUnstage: (Staged) -> Void let onCancelReply: () -> Void let onCancelEdit: () -> Void let onDraftChanged: (String) -> Void // typing beat + "/" host pre-warm let onEscapeFallback: () -> Bool // the container's next Escape layer (command panel) // Whether THIS bar's banner ✕ carries the Escape key equivalent // (.cancelAction). Two bars can show banners at once (channel + docked // pane), and two cancelAction buttons would race on AppKit's // key-equivalent walk — so the container hands the key to exactly one: // the bar whose field has focus, the channel bar when focus is in // neither (today's single-bar behaviour). let ownsCancelKey: Bool // ↑ on an empty draft edits the latest editable message in THIS bar's // SCOPE — the channel feed, or the docked pane's thread — so the // container finds it and stages the edit; returns whether it did. let onEditLatest: () -> Bool let onStagePaste: ((NSPasteboard) -> Bool)? let onStageDrop: ((NSPasteboard) -> Bool)? // The container's command-panel slot, between the typeahead and the // banners (the main bar mounts the command-host webview here; a // commands-off bar passes an EmptyView). let panel: Panel // ---- bar-owned state ---------------------------------------------------- // The caret as a UTF-16 offset into `draft` — real caret tracking for the // typeahead (the old TextField could only assume caret-at-end). @State private var composerCaret = 0 // The field's measured height (1–9 lines), pushed by the field on every // content/width/font change — SwiftUI never re-asks a representable. @State private var composerHeight: CGFloat = 22 // The active typeahead: the token under the caret + its ranked candidates. @State private var taToken: ComposerToken? @State private var taItems: [TypeaheadItem] = [] @State private var taIndex = 0 @EnvironmentObject private var app: MacAppState @Environment(\.chatFontSize) private var chatFontSize @Environment(\.colorScheme) private var scheme var body: some View { VStack(alignment: .leading, spacing: 4) { panel if fixedReply == nil, let replyTo { composerBanner("Replying to \(replyTo.name ?? "")", detail: replyTo.snippet, cancelLabel: "Cancel reply") { onCancelReply() } } if editingID != nil { composerBanner("Editing message", detail: nil, cancelLabel: "Cancel edit") { onCancelEdit() } } VStack(spacing: 0) { // A default command's push-back ("when should I remind you?") // — inline above the composer, occupying layout, never floating. if let routeNotice { Text(routeNotice) .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) .padding(.top, 6) } composerRow // Transient feedback only (downloads, upload errors) — no // permanent status line. if let busyText { Text(busyText) .font(.caption2) .foregroundStyle(.secondary) .frame(maxWidth: .infinity) .padding(4) } } .frame(maxWidth: .infinity) // iMessage-style: a within-window frost (FrostedBand) — matches the // chat stream's surface exactly at rest (blurring the same window // background it sits on) while messages scrolling underneath ghost // through the glass. Only the input row wears it — the typeahead/ // banners above stay transparent floaters. .background(FrostedBand()) } // The typeahead is a drop-UP that OVERLAYS the feed, never a row in // this stack. This whole VStack is the bottom safeAreaInset's content, // which SHRINKS the feed's scroll view — and the strip is one row per // candidate, re-ranked on every keystroke, so `/t` → `/th` → `/thr` // moved the entire transcript a row at a time as the list narrowed. // Transparent was not enough: transparent still occupies layout. // Web parity — style.css `.typeahead` is absolutely positioned against // `.composer-float`, a zero-height anchor at the top of the composer // stack, for exactly this reason. (The staged-attachment strip stays in // flow on purpose, on both platforms: floating THAT covered tappable // things at the feed's bottom edge.) // // The alignment-guide flip is the whole trick: aligned `.top`, the // overlay reports its own BOTTOM as its top edge, so it sits directly // above the band and rises with it — a reply banner arming or a photo // staging pushes the strip up, with no height measured anywhere. // .topLEADING, and the width claimed explicitly: measured off-screen, a // plain `.top` overlay centres its child on the band and lets it take // its ideal width, where the in-flow VStack (alignment: .leading) gave // it the full width, left-aligned. Without both of these the card // floats centred and narrow instead of tracking the composer's edges. .overlay(alignment: .topLeading) { // The guide flip MUST sit outside the conditional. Measured // off-screen: a guide set on a view INSIDE an `if` never reaches // the overlay — the overlay aligns the _ConditionalContent // container and reads its default `.top`, so the card was laid out // with its top on the band's top and ran down over the composer // and off the bottom of the window. Outside the `if` it shifts by // the card's full height (measured: 360…458 vs 262…360 for a 98pt // card over a band at 360…400). typeaheadDropUp .alignmentGuide(.top) { $0[.bottom] } } .onChange(of: draft) { _, text in refreshTypeahead() onDraftChanged(text) } // Caret moves (clicks / arrows) can enter or leave a token with the // text unchanged — re-check, like the web composer's keyup handler. .onChange(of: composerCaret) { _, _ in refreshTypeahead() } // The thread's live command rows landing re-ranks an open "/" menu — // this stands in for the direct refreshTypeahead() the container's // command-host sink used to make when the state all lived together. .onChange(of: hostRows) { _, _ in refreshTypeahead() } } // The token under the REAL caret (MacComposerField tracks it). The field // reports UTF-16, the tokenizer speaks Characters — convert at the // boundary, exactly as the iOS screens do. // The typeahead card, or nothing. Split out so the drop-up's alignment // guide can be applied OUTSIDE this conditional (see the overlay above). @ViewBuilder private var typeaheadDropUp: some View { if !taItems.isEmpty, let token = taToken { TypeaheadStrip(items: taItems, selected: taIndex, mentionAvatar: mentionAvatar) { pick($0, token: token) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 10) .padding(.bottom, 4) } } private func refreshTypeahead() { let utf16 = min(max(composerCaret, 0), draft.utf16.count) let idx = String.Index(utf16Offset: utf16, in: draft) let charCaret = draft.distance(from: draft.startIndex, to: idx) taToken = composerToken(draft, caret: charCaret) // A commands-off bar never menus a "/" draft — the text sends // verbatim, matching the web pane composer (cfg.commands:false). if !allowCommands, taToken?.kind == .command { taToken = nil } taItems = taToken.map { typeaheadItems($0, roster: roster, myUserId: myUserId, hasBot: hasBot, appCommands: appCommands, displayNames: model.senderNames, rows: hostRows) } ?? [] taIndex = 0 } // Accept a candidate: splice it over the token (trigger → caret), exactly // like the web's acceptTypeahead. private func pick(_ item: TypeaheadItem, token: ComposerToken) { // Splice trigger → caret, PRESERVING the tail after the caret (the old // TextField assumed caret-at-end and dropped it). Characters → UTF-16 // at the boundary; the field edits itself via the controller, so the // caret lands right after the insert. let chars = Array(draft) let utf16 = min(max(composerCaret, 0), draft.utf16.count) let idx = String.Index(utf16Offset: utf16, in: draft) let charCaret = draft.distance(from: draft.startIndex, to: idx) let start = min(token.start, chars.count) let end = min(max(charCaret, start), chars.count) let head = String(chars[0.. some View { ZStack { Image(platform: preview) .resizable() .scaledToFill() .frame(width: 96, height: 96) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) if s.attachment == nil { uploadRing(s.progress) .frame(width: 26, height: 26) } } // Video thumbs wear a small play badge so they read as clips. .overlay(alignment: .bottomLeading) { if !s.isImage { Image(systemName: "play.fill") .font(.system(size: 9)) .foregroundStyle(.white) .padding(4) .background(Circle().fill(.black.opacity(0.55))) .padding(4) } } .overlay(alignment: .topTrailing) { unstageButton(s).offset(x: 7, y: -7) } .padding(.top, 7) // room for the overlapping ✕ .shadow(color: .black.opacity(0.18), radius: 5, y: 2) } private func pillChip(_ s: Staged) -> some View { HStack(spacing: 6) { if s.attachment == nil { uploadRing(s.progress).frame(width: 15, height: 15) } else { Text(fileIcon(s.name)).font(.system(size: 15)) } Text(s.name).font(.system(size: 13)).lineLimit(1).truncationMode(.middle) unstageButton(s) } .padding(.horizontal, 9) .padding(.vertical, 6) .background(Capsule().fill(.primary.opacity(0.06))) .frame(maxWidth: 200) } private func uploadRing(_ progress: Double) -> some View { ZStack { Circle().stroke(.white.opacity(0.45), lineWidth: 3) Circle() .trim(from: 0, to: max(0.03, progress)) .stroke(.white, style: StrokeStyle(lineWidth: 3, lineCap: .round)) .rotationEffect(.degrees(-90)) .animation(.linear(duration: 0.1), value: progress) } .shadow(color: .black.opacity(0.4), radius: 2) } private func unstageButton(_ s: Staged) -> some View { Button { onUnstage(s) } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 15)) .symbolRenderingMode(.palette) .foregroundStyle(.white, .gray) } .buttonStyle(.plain) .accessibilityLabel("Remove \(s.name)") } private var composerRow: some View { HStack(alignment: .bottom, spacing: 8) { // The web composer's "+": stage photos and allowlisted documents. // The container passes nil in a default-command thread (To-do) — // text-only surface, matching the web and iOS composers. if let onPickFiles { Button(action: onPickFiles) { Image(systemName: "plus.circle.fill") .font(.system(size: 28)) // same weight as the send button // Messages.app: a real glyph on the disc — white in // dark mode, dark grey in light — not the knocked-out // transparent plus the monochrome fill gave. .symbolRenderingMode(.palette) .foregroundStyle(scheme == .dark ? Color.white : Color(white: 0.3), .tertiary) } .buttonStyle(.plain) .accessibilityLabel("Attach files") .padding(.bottom, 4) } // ONE pill around attachments and text, the Messages read: what's // staged and what's typed are visibly the same message. VStack(alignment: .leading, spacing: 0) { if !staged.isEmpty { stagedChips } // The NSTextView-backed field (MacComposerField): a real caret — // mid-text typeahead, Shift/⌥+Return newlines AT the caret (#819), // caret-preserving splices — and the base the rich stages of #890 // build on. Plain text at this stage. Typeahead keys route through // the handlers: ↑↓ move, Tab/Return accept, Esc dismisses — Return // falls through to send when no menu is up; ↑ on an empty draft // edits the last message. MacComposerField( text: $draft, focused: $composerFocused, height: $composerHeight, caret: $composerCaret, placeholder: placeholder, fontSize: chatFontSize, // the feed's reading size, like the web composer // Queued behind an upload = the message is committed; editing // what's about to auto-send would race it. isDisabled: sendQueued, formatAsYouType: formatAsYouType, controller: controller, onSend: onSend, onMoveUp: { if !taItems.isEmpty { taIndex = (taIndex + taItems.count - 1) % taItems.count return true } if draft.isEmpty, editingID == nil { return onEditLatest() } return false }, onMoveDown: { guard !taItems.isEmpty else { return false } taIndex = (taIndex + 1) % taItems.count return true }, onAccept: { guard !taItems.isEmpty, let token = taToken else { return false } pick(taItems[taIndex], token: token) return true }, onEscape: { if !taItems.isEmpty { taToken = nil taItems = [] return true } // Deeper Escape layers belong to the container (the // main bar peels an open command panel here, before // its reply/edit banners). return onEscapeFallback() }, onStagePaste: onStagePaste, onStageDrop: onStageDrop ) // The field's height is pushed by the field itself (measured // 1–9 lines) — SwiftUI never re-asks a representable when only // its content changes. .frame(height: composerHeight) // Claim the row's full width (the + and send buttons keep // their fixed sizes) — without this the HStack centres a // short pill around the representable's measured width. .frame(maxWidth: .infinity) .padding(.horizontal, 14) .padding(.vertical, 9) // Regaining the app drops the caret into the message box, the // same courtesy the webview shell extends (focusComposer) — // but ONLY from the pane actually on screen (every instance's // pane stays mounted at opacity 0, and their writes were // stealing focus from the visible composer), never over an // open ⌘K palette (its own handler re-asserts its field), and // from the CHANNEL's bar rather than an open thread's // (focusOnActivate — you come back to the conversation, not // to the reply you were parked in). .onReceive(NotificationCenter.default.publisher( for: NSApplication.didBecomeActiveNotification)) { _ in guard focusOnActivate, !app.showPalette else { return } DispatchQueue.main.async { if !app.showPalette { composerFocused = true } } } // Focus on open — asserted again a beat later AND when the // feed reveals: a FocusState write in onAppear races the // freshly-inserted (still invisible) field and loses. .onAppear { guard isActive else { return } composerFocused = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { if isActive { composerFocused = true } } } .onChange(of: settled) { _, isSettled in if isSettled, isActive { composerFocused = true } } } // The web composer: a bordered pill on the panel background, // not a filled grey field. .background(RoundedRectangle(cornerRadius: 19, style: .continuous) .fill(.background)) .overlay(RoundedRectangle(cornerRadius: 19, style: .continuous) .strokeBorder(.quaternary, lineWidth: 1)) Button(action: onSend) { if sendQueued { // Queued behind an in-flight upload — the web's .send-waiting. ProgressView() .controlSize(.small) .frame(width: 28, height: 28) } else { Image(systemName: "arrow.up.circle.fill") .font(.system(size: 28)) .foregroundStyle(hasContent ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.tertiary)) } } .buttonStyle(.plain) .disabled(!hasContent) .accessibilityLabel("Send") .padding(.bottom, 4) } // Messages.app gives the input pill real breathing room below — the // old 6pt read as cramped against the window edge. .padding(EdgeInsets(top: 8, leading: 10, bottom: 12, trailing: 10)) } // The container's send path keeps its own twin (fireQueuedSend) — both // read the same draft + staged, so they can't disagree. private var hasContent: Bool { !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !staged.isEmpty } private func composerBanner(_ title: String, detail: String?, cancelLabel: String, onCancel: @escaping () -> Void) -> some View { HStack(spacing: 8) { VStack(alignment: .leading, spacing: 1) { Text(title).font(.system(size: 12, weight: .semibold)) if let detail, !detail.isEmpty { Text(detail).font(.system(size: 12)).foregroundStyle(.secondary).lineLimit(1) } } Spacer() Button(action: onCancel) { Image(systemName: "xmark.circle.fill") .font(.system(size: 13)) .foregroundStyle(.tertiary) } .buttonStyle(.plain) .keyboardShortcut(ownsCancelKey ? .cancelAction : nil) .accessibilityLabel(cancelLabel) } .padding(.horizontal, 12) .padding(.vertical, 8) // Opaque panel card: the banner floats over the feed since the inset // lost its .bar slab, and messages/typing showed straight through it. // A stroked edge, NOT a shadow — a shadow inside safeAreaInset made // the hosted scroll content vanish entirely on macOS. .background(RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(theme.panel.resolved(scheme))) .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder(.primary.opacity(0.10))) .padding(.horizontal, 10) .padding(.top, 6) } }