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). final class PaneDropRouter { static let shared = PaneDropRouter() private struct Entry { let token: UUID let handler: (NSPasteboard) -> 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) -> 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 } func deliver(_ pasteboard: NSPasteboard, 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)") entry.handler(pasteboard) return true } } // 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 } @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 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) // Plain @State: MacComposerField drives first responder from it directly. @State private var composerFocused = false // 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 // 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: picked → uploading (spinner) → ready. // Several can ride one message, like the web's pending list. private 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 } @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) and the header // stand-in while that root isn't loaded — frozen at open, like the web's // threadFallback (chat.js openThread's ref argument). @State private var threadRootID: Int64? @State private var threadFallback: ReplyRef? // 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() @State private var swipeMonitor: 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 { 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.messages.isEmpty { Text(appIntro) .font(.system(size: 13)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: 440) .padding(.vertical, 28) .padding(.horizontal, 16) .frame(maxWidth: .infinity) } ForEach(Array(model.messages.enumerated()), id: \.element.id) { i, m in let previous = i > 0 ? model.messages[i - 1] : nil 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. let mid = frames.viewport.height / 2 let ref = frames.rows .filter { $0.value.minY >= 0 && $0.value.maxY <= frames.viewport.height } .min { abs($0.value.midY - mid) < abs($1.value.midY - mid) } Task { await model.loadOlder() guard model.messages.first?.id != anchorID else { return } DispatchQueue.main.async { if let ref, frames.viewport.height > ref.value.height { let f = max(0, min(1, ref.value.minY / (frames.viewport.height - ref.value.height))) proxy.scrollTo(ref.key, anchor: UnitPoint(x: 0, y: f)) } 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. .onChange(of: model.messages.last?.id) { old, id in guard id != nil else { return } let mine = model.messages.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.messages.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 if !empty, let last = model.messages.last?.id, let f = frames.rows[last], f.minY < frames.viewport.height + 150 { proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) } } // Staged attachments live INSIDE the input pill, so staging and // unstaging resize the composer inset — keep the newest message // pinned through it when the reader was at the bottom. .onChange(of: staged.isEmpty) { _, _ in if let last = model.messages.last?.id, let f = frames.rows[last], f.minY < frames.viewport.height + 150 { pinUntilSettled(proxy) } } // 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) } // ⌘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 } startReply(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 let nearBottom = model.messages.last.flatMap { frames.rows[$0.id]?.minY } .map { $0 < frames.viewport.height + 150 } ?? false if nearBottom { pinUntilSettled(proxy) return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { if frames.rows.isEmpty && !model.messages.isEmpty { pinUntilSettled(proxy) } } } .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) } // 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. .overlay { if let rootID = threadRootID { 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 } } // 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") } } } .alert("Integrity check", isPresented: .init( get: { integrityReport != nil }, set: { if !$0 { integrityReport = nil } }) ) { Button("OK", role: .cancel) { integrityReport = nil } } message: { Text(integrityReport ?? "") } .task(id: instance?.id) { if let instance { await NativeFeedModelCache.shared.pollIntegrity(for: instance) } } // 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) .onAppear { model.start() // idempotent — a parked model is already live frames.isActive = isActive installSwipeMonitor() 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). composerFocused = active } .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 // 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 }) } 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 } PaneDropRouter.shared.register(key, token: dropToken) { stageDrop($0) } } 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 let last = model.messages.last?.id, let f = frames.rows[last], f.maxY <= frames.viewport.height + 150 { storeAnchor(nil) return } let mid = frames.viewport.height / 2 guard let ref = frames.rows .filter({ $0.value.minY >= 0 && $0.value.maxY <= frames.viewport.height }) .min(by: { abs($0.value.midY - mid) < abs($1.value.midY - mid) }) else { // No fully-visible row — one taller-than-the-viewport bubble (a // long code block) fills the screen. Anchor its top; scrollTo // can't express "row top minus 300pt", so this is the closest // honest restore. if let giant = frames.rows.first(where: { $0.value.minY < mid && $0.value.maxY > mid }) { storeAnchor(.init(rowID: giant.key, fraction: 0)) } return } let denom = frames.viewport.height - ref.value.height let fraction = denom > 0 ? max(0, min(1, ref.value.minY / denom)) : 0 storeAnchor(.init(rowID: ref.key, fraction: fraction)) } // 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) { guard model.ready else { return } guard model.messages.contains(where: { $0.id == anchor.rowID }) else { storeAnchor(nil) pinUntilSettled(proxy) 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) { 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) } } // 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.) private func lastRowFlush() -> Bool { guard let id = model.messages.last?.id, let f = frames.rows[id] else { return false } let vh = frames.viewport.height guard vh > 0 else { return false } // Short chat: the whole log fits (content height = last.maxY - // first.minY, independent of the offset), top-aligned — flush means the // first row starts at/after the top and the last row's bottom isn't // shoved past the viewport bottom. if let firstID = model.messages.first?.id, let ff = frames.rows[firstID], f.maxY - ff.minY <= vh { return ff.minY >= -2 && f.maxY <= vh + 2 } // Tall chat: the newest row's bottom must be in the lower half of the // viewport and not below its bottom edge — never the negative maxY of // the stranded-above wedge. return f.maxY > vh * 0.5 && f.maxY <= vh + 2 } // 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) { guard model.ready else { return } // the ready flip re-enters guard let id = model.messages.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.messages.isEmpty { if let first = model.messages.first?.id { proxy.scrollTo(first, anchor: .top) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { pinUntilSettled(proxy, attempts: 6, rescues: rescues - 1) } } else { settled = true // flush, or out of rescues — reveal rather than hide forever } return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { guard model.messages.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 model.messages.last?.id == id, !lastRowFlush() else { return } pinUntilSettled(proxy, attempts: max(attempts - 1, 2), rescues: rescues) } 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.messages.first?.id { proxy.scrollTo(first, anchor: .top) DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues) } } else { pinUntilSettled(proxy, attempts: attempts - 1, rescues: rescues) } } } // 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 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 } } // 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.messages.last?.id, let f = frames.rows[m.id], f.minY < frames.viewport.height + 150 { DispatchQueue.main.async { pinUntilSettled(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. private var hotkeyMessages: [ChatFeedModel.Message] { guard let rootID = threadRootID else { return model.messages } 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 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 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 // 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()) } private var composer: some View { VStack(alignment: .leading, spacing: 4) { if !taItems.isEmpty, let token = taToken { TypeaheadStrip(items: taItems, selected: taIndex, mentionAvatar: mentionAvatar) { pick($0, token: token) } .padding(.horizontal, 10) } // A command's results panel (the command-host webview — the page // draws its own header + ✕): in-flow above the banners, 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. 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) } if let replyTo { composerBanner("Replying to \(replyTo.name ?? "")", detail: replyTo.snippet) { cancelReply() } } if editingID != nil { composerBanner("Editing message", detail: nil) { cancelEdit() } } 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()) } .onChange(of: draft) { _, text in refreshTypeahead() // 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() } } // 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 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. 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) 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 { staged.removeAll { $0.id == s.id } fireQueuedSend() // cancels or releases a queued send } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 15)) .symbolRenderingMode(.palette) .foregroundStyle(.white, .gray) } .buttonStyle(.plain) } private var composerRow: some View { HStack(alignment: .bottom, spacing: 8) { // The web composer's "+": stage photos and allowlisted documents. // Gone in a default-command thread (To-do) — text-only surface, // matching the web and iOS composers. if defaultCommand == nil { Button(action: pickFiles) { 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) .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: defaultCommand?.placeholder ?? "Message", 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: composerController, onSend: sendDraft, onMoveUp: { if !taItems.isEmpty { taIndex = (taIndex + taItems.count - 1) % taItems.count return true } if draft.isEmpty, editingID == nil, let last = model.messages.last(where: { $0.mine && !$0.action }) { startEdit(last) return true } 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 } // An open command panel is the most recently summoned // layer — Escape peels it 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 } return false }, // Paste an image (or a copied file) straight onto the draft — // it stages as an upload, like the web composer's paste. // Absent in a default-command thread (text-only surface), so a // text paste there stays a plain text paste. onStagePaste: defaultCommand == nil ? { stagePasteboard($0) } : nil, // A file DRAG landing on the field stages too — the backstop // behind the window-wide drop zone, so NSTextView's default // (paste the file's PATH as text) is never the outcome. onStageDrop: defaultCommand == nil ? { stageDrop($0) } : nil ) // 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), and never over // an open ⌘K palette (its own handler re-asserts its field). .onReceive(NotificationCenter.default.publisher( for: NSApplication.didBecomeActiveNotification)) { _ in guard isActive, !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: sendDraft) { 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) .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)) } 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. @discardableResult // 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) } } 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 typeahead // and the forwarding decision follow it from here. hostRows = rows refreshTypeahead() } } // 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 = [] // Sending from an open thread view keeps it open and re-stages the // reply to its root — the new reply folds in live and the next // send continues the same thread, like iMessage (web parity). if threadRootID != nil { 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) } } // ---- 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, fallback: threadFallback, theme: theme, isGroup: isGroup, 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 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) }, 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) }) 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 } @ViewBuilder private func threadBubble(_ m: ChatFeedModel.Message, showSender: Bool) -> 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) } } private func threadMessageBubble(_ m: ChatFeedModel.Message, showSender: Bool) -> 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: 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) }) } private func openThread(_ rootID: Int64) { guard rootID > 0, threadRootID != rootID else { return } // Freeze the header stand-in NOW (web threadFallback) — the deepest // quoted snapshot in loaded state, in case the root never pages in. threadFallback = model.threadReplyRef(root: rootID) withAnimation(.easeOut(duration: 0.18)) { threadRootID = rootID } 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. private func stageThreadReply() { guard let rootID = threadRootID else { return } editingID = nil replyTo = model.threadReplyRef(root: rootID) composerFocused = true } private func closeThread() { guard let rootID = threadRootID else { return } withAnimation(.easeOut(duration: 0.15)) { threadRootID = nil } threadFallback = nil // Leaving the thread drops its staged reply — never an unrelated one // staged since (an edit clears replyTo itself, so that survives). if replyTo?.id == rootID { replyTo = nil } } // ---- reply / edit staging (the composer banners) ---------------------- private func startReply(_ m: ChatFeedModel.Message) { editingID = nil // Replying to a reply continues its thread: target the TOP message. replyTo = model.replyTarget(for: m) composerFocused = true } // While the 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. private func cancelReply() { if threadRootID != nil { 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 = "" // 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). if threadRootID != nil { stageThreadReply() } } private func composerBanner(_ title: String, detail: 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(.cancelAction) } .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) } // 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() } } // 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() { 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) } composerFocused = 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) -> Bool { if let urls = pb.readObjects(forClasses: [NSURL.self]) as? [URL] { let files = urls.filter(\.isFileURL) if !files.isEmpty { files.forEach { stage($0) } 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) 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) -> 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) } 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) } } } 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) 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) { // 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)) } } private func stage(data: Data, name: String, type: UTType?) { 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 { busyText = "\(name): videos are capped at 50 MB" DispatchQueue.main.asyncAfter(deadline: .now() + 3) { busyText = nil } return } let entry = Staged(name: name, isImage: isImage) staged.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.firstIndex(where: { $0.id == entryID }) { staged[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.firstIndex(where: { $0.id == entryID }) { staged[i].preview = poster } } } let onProgress: @MainActor (Double) -> Void = { p in if let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[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.firstIndex(where: { $0.id == entry.id }) { staged[i].attachment = att } else { staged.removeAll { $0.id == entry.id } if att == nil { busyText = "couldn't upload \(name)" } DispatchQueue.main.asyncAfter(deadline: .now() + 3) { busyText = nil } } fireQueuedSend() // the batch resolving may release a queued send } } // A pending open-on-message request (search): once this feed is the // target and ready, page back until the message is loaded, then wave. private func consumeJump(_ proxy: ScrollViewProxy) { guard let j = app.nativeJump, j.thread == threadID, model.ready else { return } app.nativeJump = nil Task { var tries = 0 while !model.messages.contains(where: { $0.id == j.message }), !model.exhausted, tries < 8 { tries += 1 await model.loadOlder() try? await Task.sleep(nanoseconds: 550_000_000) // loadOlder's cooldown } guard model.messages.contains(where: { $0.id == j.message }) else { return } try? await Task.sleep(nanoseconds: 150_000_000) // let rows land jump(to: j.message, proxy: proxy) } } private func jump(to id: Int64, proxy: ScrollViewProxy) { guard model.messages.contains(where: { $0.id == id }) else { 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 } } // 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 } }