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