| index 3e32b61..5bfccd9 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -3,7 +3,8 @@ import SwiftUI |
| import AVFoundation |
| import os |
| |
| -// Routes drop-to-attach from RootView's ONE drop region to the visible feed. |
| +// Routes drop-to-attach from RootView's ONE drop surface (the window-wide |
| +// AppKit catcher, with its SwiftUI .onDrop safety net) to the visible feed. |
| // Per-pane .onDrop regions can't coexist with the keep-alive ZStack: drop |
| // targeting resolves to the topmost region regardless of opacity or |
| // allowsHitTesting, so a hidden space's region either swallowed the drop |
| @@ -18,19 +19,23 @@ import os |
| // return to an earlier space). Mount lifecycle is structural and can't be |
| // coalesced away; selection is read live, so there is no event ordering to |
| // get wrong. Main-thread only (registration comes from view lifecycle, |
| -// delivery from the drop delegate). |
| +// delivery from the drop surface). |
| +// |
| +// The payload is the drag PASTEBOARD, not [NSItemProvider]: the AppKit |
| +// catcher only ever has the pasteboard, and it carries every drag shape the |
| +// feed stages (file URLs, file promises, raw image data). |
| final class PaneDropRouter { |
| static let shared = PaneDropRouter() |
| private struct Entry { |
| let token: UUID |
| - let handler: ([NSItemProvider]) -> Void |
| + let handler: (NSPasteboard) -> Void |
| } |
| // Keyed by the pane's instance origin — hidden panes stay registered |
| // (they stay mounted); selection picks between them at drop time. |
| private var entries: [String: Entry] = [:] |
| |
| func register(_ key: String, token: UUID, |
| - handler: @escaping ([NSItemProvider]) -> Void) { |
| + handler: @escaping (NSPasteboard) -> Void) { |
| Logger.identity.info("drop router: register \(key, privacy: .public)") |
| entries[key] = Entry(token: token, handler: handler) |
| } |
| @@ -41,19 +46,19 @@ final class PaneDropRouter { |
| Logger.identity.info("drop router: unregister \(key, privacy: .public)") |
| entries[key] = nil |
| } |
| - // Drives the drag's + badge via validateDrop: no registered feed for the |
| - // selected pane (locked pane, default-command thread, first-run) → the |
| - // detail area rejects the drag, matching the old empty-type-list gate. |
| + // Drives the drag's + badge: no registered feed for the selected pane |
| + // (locked pane, default-command thread, first-run) → the window rejects |
| + // the drag, matching the old empty-type-list gate. |
| func canDrop(over key: String?) -> Bool { |
| key.flatMap { entries[$0] } != nil |
| } |
| - func deliver(_ providers: [NSItemProvider], over key: String?) -> Bool { |
| + func deliver(_ pasteboard: NSPasteboard, over key: String?) -> Bool { |
| guard let key, let entry = entries[key] else { |
| Logger.identity.error("drop router: no feed for \(key ?? "<no selection>", privacy: .public) — drop bounced") |
| return false |
| } |
| - Logger.identity.info("drop router: deliver \(providers.count) provider(s) to \(key, privacy: .public)") |
| - entry.handler(providers) |
| + Logger.identity.info("drop router: deliver to \(key, privacy: .public)") |
| + entry.handler(pasteboard) |
| return true |
| } |
| } |
| @@ -436,6 +441,15 @@ struct NativeFeedView: View { |
| proxy.scrollTo(Self.bottomSentinel, anchor: .bottom) |
| } |
| } |
| + // Staged attachments live INSIDE the input pill, so staging and |
| + // unstaging resize the composer inset — keep the newest message |
| + // pinned through it when the reader was at the bottom. |
| + .onChange(of: staged.isEmpty) { _, _ in |
| + if let last = model.messages.last?.id, |
| + let f = frames.rows[last], f.minY < frames.viewport.height + 150 { |
| + pinUntilSettled(proxy) |
| + } |
| + } |
| // Opening a chat lands at the newest message — and STAYS there: |
| // one pin isn't enough, because rows materialise with their real |
| // (often taller) heights after the jump and shift the content a |
| @@ -515,18 +529,6 @@ struct NativeFeedView: View { |
| .transition(.opacity.combined(with: .offset(y: 12))) |
| } |
| } |
| - // Staged previews FLOAT over the feed, hugging the composer's top edge |
| - // (this overlay is applied BEFORE the inset, so its bottom IS the |
| - // composer's top) — in the inset they grew it and shoved the feed up. |
| - .overlay(alignment: .bottomLeading) { |
| - if !staged.isEmpty { |
| - // Forced to the leading edge (the chip row's own 10pt leading |
| - // then lines it up with the + button, as on the web). |
| - stagedChips |
| - .padding(.bottom, 8) |
| - .frame(maxWidth: .infinity, alignment: .leading) |
| - } |
| - } |
| .safeAreaInset(edge: .bottom) { |
| // An ended DM (#578): the server refuses new messages, so the |
| // composer gives way to the closed banner — the one honest action |
| @@ -601,27 +603,19 @@ struct NativeFeedView: View { |
| // topmost region regardless of opacity/allowsHitTesting — a hidden |
| // pane's region either stole the drop (staging into the wrong space's |
| // composer) or, gated, dead-ended the drag with no + badge at all. |
| - // Instead RootView holds the ONE drop region for the whole detail |
| - // area and PaneDropRouter (top of file) hands each drop to whichever |
| + // Instead RootView owns the ONE drop surface for the whole window |
| + // and PaneDropRouter (top of file) hands each drop to whichever |
| // registered feed belongs to the pane selected at drag time. |
| } |
| |
| - // What the feed accepts on a drop: real files, promised files (how most |
| - // apps vend drags — screenshots, Photos), and raw image/movie data. |
| - static let droppableTypes: [UTType] = { |
| - var types: [UTType] = [.fileURL, .image, .movie] |
| - types += NSFilePromiseReceiver.readableDraggedTypes.compactMap { UTType($0) } |
| - return types |
| - }() |
| - |
| // Register this feed as its pane's drop receiver for as long as it is |
| - // mounted — visibility is the delegate's problem, not ours. A |
| + // mounted — visibility is the drop surface's problem, not ours. A |
| // default-command thread (text-only surface) or an instance-less mount |
| // (the Debug diff pane) registers nothing, so the drag shows no + badge |
| // there — same behaviour the per-feed type-list gate used to give. |
| private func registerForDrops() { |
| guard defaultCommand == nil, let key = instance?.origin else { return } |
| - PaneDropRouter.shared.register(key, token: dropToken) { stageProviders($0) } |
| + PaneDropRouter.shared.register(key, token: dropToken) { stageDrop($0) } |
| } |
| |
| static let bottomSentinel = "bottom-sentinel" |
| @@ -1189,9 +1183,10 @@ struct NativeFeedView: View { |
| composerFocused = true |
| } |
| |
| - // The staged attachments above the field, web-style: images show a real |
| - // rounded thumbnail with the ✕ overlapping its corner and the upload ring |
| - // over the middle; files keep a pill. Left-aligned with the + button. |
| + // The staged attachments, Messages-style: INSIDE the input pill, above |
| + // the text, so the attachment and its caption visibly travel as one |
| + // message. Images show a real rounded thumbnail with the ✕ overlapping |
| + // its corner and the upload ring over the middle; files keep a pill. |
| private var stagedChips: some View { |
| HStack(alignment: .top, spacing: 8) { |
| ForEach(staged) { s in |
| @@ -1202,9 +1197,8 @@ struct NativeFeedView: View { |
| } |
| } |
| } |
| - .padding(.leading, 10) // flush with the + button's left edge, like the web |
| - .padding(.trailing, 12) |
| - .padding(.top, 8) |
| + .padding(.horizontal, 12) |
| + .padding(.top, 10) |
| } |
| |
| private func imageChip(_ s: Staged, preview: PlatformImage) -> some View { |
| @@ -1212,7 +1206,7 @@ struct NativeFeedView: View { |
| Image(platform: preview) |
| .resizable() |
| .scaledToFill() |
| - .frame(width: 64, height: 64) |
| + .frame(width: 96, height: 96) |
| .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) |
| if s.attachment == nil { |
| uploadRing(s.progress) |
| @@ -1295,112 +1289,123 @@ struct NativeFeedView: View { |
| .buttonStyle(.plain) |
| .padding(.bottom, 4) |
| } |
| - // The NSTextView-backed field (MacComposerField): a real caret — |
| - // mid-text typeahead, Shift/⌥+Return newlines AT the caret (#819), |
| - // caret-preserving splices — and the base the rich stages of #890 |
| - // build on. Plain text at this stage. Typeahead keys route through |
| - // the handlers: ↑↓ move, Tab/Return accept, Esc dismisses — Return |
| - // falls through to send when no menu is up; ↑ on an empty draft |
| - // edits the last message. |
| - MacComposerField( |
| - text: $draft, |
| - focused: $composerFocused, |
| - height: $composerHeight, |
| - caret: $composerCaret, |
| - placeholder: defaultCommand?.placeholder ?? "Message", |
| - fontSize: chatFontSize, // the feed's reading size, like the web composer |
| - // Queued behind an upload = the message is committed; editing |
| - // what's about to auto-send would race it. |
| - isDisabled: sendQueued, |
| - formatAsYouType: formatAsYouType, |
| - controller: composerController, |
| - onSend: sendDraft, |
| - onMoveUp: { |
| - if !taItems.isEmpty { |
| - taIndex = (taIndex + taItems.count - 1) % taItems.count |
| - return true |
| - } |
| - if draft.isEmpty, editingID == nil, |
| - let last = model.messages.last(where: { $0.mine && !$0.action }) { |
| - startEdit(last) |
| - return true |
| - } |
| - return false |
| - }, |
| - onMoveDown: { |
| - guard !taItems.isEmpty else { return false } |
| - taIndex = (taIndex + 1) % taItems.count |
| - return true |
| - }, |
| - onAccept: { |
| - guard !taItems.isEmpty, let token = taToken else { return false } |
| - pick(taItems[taIndex], token: token) |
| - return true |
| - }, |
| - onEscape: { |
| - if !taItems.isEmpty { |
| - taToken = nil |
| - taItems = [] |
| + // ONE pill around attachments and text, the Messages read: what's |
| + // staged and what's typed are visibly the same message. |
| + VStack(alignment: .leading, spacing: 0) { |
| + if !staged.isEmpty { |
| + stagedChips |
| + } |
| + // The NSTextView-backed field (MacComposerField): a real caret — |
| + // mid-text typeahead, Shift/⌥+Return newlines AT the caret (#819), |
| + // caret-preserving splices — and the base the rich stages of #890 |
| + // build on. Plain text at this stage. Typeahead keys route through |
| + // the handlers: ↑↓ move, Tab/Return accept, Esc dismisses — Return |
| + // falls through to send when no menu is up; ↑ on an empty draft |
| + // edits the last message. |
| + MacComposerField( |
| + text: $draft, |
| + focused: $composerFocused, |
| + height: $composerHeight, |
| + caret: $composerCaret, |
| + placeholder: defaultCommand?.placeholder ?? "Message", |
| + fontSize: chatFontSize, // the feed's reading size, like the web composer |
| + // Queued behind an upload = the message is committed; editing |
| + // what's about to auto-send would race it. |
| + isDisabled: sendQueued, |
| + formatAsYouType: formatAsYouType, |
| + controller: composerController, |
| + onSend: sendDraft, |
| + onMoveUp: { |
| + if !taItems.isEmpty { |
| + taIndex = (taIndex + taItems.count - 1) % taItems.count |
| + return true |
| + } |
| + if draft.isEmpty, editingID == nil, |
| + let last = model.messages.last(where: { $0.mine && !$0.action }) { |
| + startEdit(last) |
| + return true |
| + } |
| + return false |
| + }, |
| + onMoveDown: { |
| + guard !taItems.isEmpty else { return false } |
| + taIndex = (taIndex + 1) % taItems.count |
| return true |
| - } |
| - // An open command panel is the most recently summoned |
| - // layer — Escape peels it before reply/edit below (the |
| - // page posts {panel: null} back, which collapses the |
| - // frame, so the panel's onClose hook always runs). |
| - if panelVisible { |
| - commandHost?.dismissPanel() |
| + }, |
| + onAccept: { |
| + guard !taItems.isEmpty, let token = taToken else { return false } |
| + pick(taItems[taIndex], token: token) |
| return true |
| + }, |
| + onEscape: { |
| + if !taItems.isEmpty { |
| + taToken = nil |
| + taItems = [] |
| + return true |
| + } |
| + // An open command panel is the most recently summoned |
| + // layer — Escape peels it before reply/edit below (the |
| + // page posts {panel: null} back, which collapses the |
| + // frame, so the panel's onClose hook always runs). |
| + if panelVisible { |
| + commandHost?.dismissPanel() |
| + return true |
| + } |
| + return false |
| + }, |
| + // Paste an image (or a copied file) straight onto the draft — |
| + // it stages as an upload, like the web composer's paste. |
| + // Absent in a default-command thread (text-only surface), so a |
| + // text paste there stays a plain text paste. |
| + onStagePaste: defaultCommand == nil ? { stagePasteboard($0) } : nil, |
| + // A file DRAG landing on the field stages too — the backstop |
| + // behind the window-wide drop zone, so NSTextView's default |
| + // (paste the file's PATH as text) is never the outcome. |
| + onStageDrop: defaultCommand == nil ? { stageDrop($0) } : nil |
| + ) |
| + // The field's height is pushed by the field itself (measured |
| + // 1–9 lines) — SwiftUI never re-asks a representable when only |
| + // its content changes. |
| + .frame(height: composerHeight) |
| + // Claim the row's full width (the + and send buttons keep |
| + // their fixed sizes) — without this the HStack centres a |
| + // short pill around the representable's measured width. |
| + .frame(maxWidth: .infinity) |
| + .padding(.horizontal, 14) |
| + .padding(.vertical, 9) |
| + // Regaining the app drops the caret into the message box, the |
| + // same courtesy the webview shell extends (focusComposer) — |
| + // but ONLY from the pane actually on screen (every instance's |
| + // pane stays mounted at opacity 0, and their writes were |
| + // stealing focus from the visible composer), and never over |
| + // an open ⌘K palette (its own handler re-asserts its field). |
| + .onReceive(NotificationCenter.default.publisher( |
| + for: NSApplication.didBecomeActiveNotification)) { _ in |
| + guard isActive, !app.showPalette else { return } |
| + DispatchQueue.main.async { |
| + if !app.showPalette { composerFocused = true } |
| + } |
| } |
| - return false |
| - }, |
| - // Paste an image (or a copied file) straight onto the draft — |
| - // it stages as an upload, like the web composer's paste. |
| - // Absent in a default-command thread (text-only surface), so a |
| - // text paste there stays a plain text paste. |
| - onStagePaste: defaultCommand == nil ? { stagePasteboard($0) } : nil |
| - ) |
| - // The field's height is pushed by the field itself (measured |
| - // 1–9 lines) — SwiftUI never re-asks a representable when only |
| - // its content changes. |
| - .frame(height: composerHeight) |
| - // Claim the row's full width (the + and send buttons keep |
| - // their fixed sizes) — without this the HStack centres a |
| - // short pill around the representable's measured width. |
| - .frame(maxWidth: .infinity) |
| - .padding(.horizontal, 14) |
| - .padding(.vertical, 9) |
| - // The web composer: a bordered pill on the panel background, |
| - // not a filled grey field. |
| - .background(RoundedRectangle(cornerRadius: 19, style: .continuous) |
| - .fill(.background)) |
| - .overlay(RoundedRectangle(cornerRadius: 19, style: .continuous) |
| - .strokeBorder(.quaternary, lineWidth: 1)) |
| - // Regaining the app drops the caret into the message box, the |
| - // same courtesy the webview shell extends (focusComposer) — |
| - // but ONLY from the pane actually on screen (every instance's |
| - // pane stays mounted at opacity 0, and their writes were |
| - // stealing focus from the visible composer), and never over |
| - // an open ⌘K palette (its own handler re-asserts its field). |
| - .onReceive(NotificationCenter.default.publisher( |
| - for: NSApplication.didBecomeActiveNotification)) { _ in |
| - guard isActive, !app.showPalette else { return } |
| - DispatchQueue.main.async { |
| - if !app.showPalette { composerFocused = true } |
| + // Focus on open — asserted again a beat later AND when the |
| + // feed reveals: a FocusState write in onAppear races the |
| + // freshly-inserted (still invisible) field and loses. |
| + .onAppear { |
| + guard isActive else { return } |
| + composerFocused = true |
| + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { |
| + if isActive { composerFocused = true } |
| + } |
| } |
| - } |
| - // Focus on open — asserted again a beat later AND when the |
| - // feed reveals: a FocusState write in onAppear races the |
| - // freshly-inserted (still invisible) field and loses. |
| - .onAppear { |
| - guard isActive else { return } |
| - composerFocused = true |
| - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { |
| - if isActive { composerFocused = true } |
| + .onChange(of: settled) { _, isSettled in |
| + if isSettled, isActive { composerFocused = true } |
| } |
| - } |
| - .onChange(of: settled) { _, isSettled in |
| - if isSettled, isActive { composerFocused = true } |
| - } |
| + } |
| + // The web composer: a bordered pill on the panel background, |
| + // not a filled grey field. |
| + .background(RoundedRectangle(cornerRadius: 19, style: .continuous) |
| + .fill(.background)) |
| + .overlay(RoundedRectangle(cornerRadius: 19, style: .continuous) |
| + .strokeBorder(.quaternary, lineWidth: 1)) |
| Button(action: sendDraft) { |
| if sendQueued { |
| // Queued behind an in-flight upload — the web's .send-waiting. |
| @@ -1845,8 +1850,8 @@ struct NativeFeedView: View { |
| // Pasted content (from MacComposerField's paste override): file URLs stage |
| // directly; raw image data stages as a PNG (like the web composer's |
| // paste-to-upload). Returns whether anything staged, so the field knows |
| - // the paste was (partly) consumed. Drops keep their own provider pipeline |
| - // (stageProviders); the pasteboard hands us its contents directly. |
| + // the paste was (partly) consumed. Drops ride their own pipeline |
| + // (stageDrop) — a drag can also carry file PROMISES, which a paste never does. |
| private func stagePasteboard(_ pb: NSPasteboard) -> Bool { |
| if let urls = pb.readObjects(forClasses: [NSURL.self]) as? [URL] { |
| let files = urls.filter(\.isFileURL) |
| @@ -1863,45 +1868,58 @@ struct NativeFeedView: View { |
| return false |
| } |
| |
| - // ONE pipeline for every provider source (paste, drop): a real file URL |
| - // stages directly; anything else that reads as an image or movie is |
| - // materialised via loadFileRepresentation — which works whether the app |
| - // vended raw data or a file promise (CleanShot, Photos: the provider |
| - // fulfils the promise into the temp file it hands us). The temp URL dies |
| - // when the closure returns, so the bytes are read inside it. |
| - private func stageProviders(_ providers: [NSItemProvider]) { |
| - for provider in providers { |
| - // Verification tap (names, never content) — drags arrive in wildly |
| - // different shapes per source app; this is how the next "my drag |
| - // doesn't work" gets diagnosed without an instrumented build. |
| - Logger.identity.info("drop provider types: \(provider.registeredTypeIdentifiers.joined(separator: ", "), privacy: .public) suggested: \(provider.suggestedName ?? "-", privacy: .public)") |
| - if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { |
| - _ = provider.loadObject(ofClass: URL.self) { url, _ in |
| - if let url { DispatchQueue.main.async { stage(url) } } |
| - } |
| - continue |
| - } |
| - let types: [UTType] = [.png, .jpeg, .tiff, .heic, .image, .movie] |
| - guard let type = types.first(where: { |
| - provider.hasItemConformingToTypeIdentifier($0.identifier) |
| - }) else { continue } |
| - _ = provider.loadFileRepresentation(forTypeIdentifier: type.identifier) { url, error in |
| - if let error { |
| - Logger.identity.error("drop fileRep failed: \(String(describing: error), privacy: .public)") |
| + // ONE pipeline for every AppKit drag (the window drop zone, the composer |
| + // field's backstop): real file URLs stage directly; file promises — how |
| + // most apps vend drags (CleanShot, Photos, browsers) — are received into |
| + // a temp folder and staged from there; raw image data stages as a file. |
| + // URLs win over promises when a drag carries both, so one item never |
| + // stages twice. |
| + @discardableResult |
| + private func stageDrop(_ pb: NSPasteboard) -> Bool { |
| + // Verification tap (types, never content) — drags arrive in wildly |
| + // different shapes per source app; this is how the next "my drag |
| + // doesn't work" gets diagnosed without an instrumented build. |
| + Logger.identity.info("drop pasteboard types: \((pb.types ?? []).map(\.rawValue).joined(separator: ", "), privacy: .public)") |
| + if let urls = pb.readObjects(forClasses: [NSURL.self], |
| + options: [.urlReadingFileURLsOnly: true]) as? [URL], |
| + !urls.isEmpty { |
| + urls.forEach { stage($0) } |
| + return true |
| + } |
| + if let receivers = pb.readObjects(forClasses: [NSFilePromiseReceiver.self]) as? [NSFilePromiseReceiver], |
| + !receivers.isEmpty { |
| + let dir = FileManager.default.temporaryDirectory |
| + .appendingPathComponent("drop-" + UUID().uuidString, isDirectory: true) |
| + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) |
| + for receiver in receivers { |
| + receiver.receivePromisedFiles(atDestination: dir, options: [:], |
| + operationQueue: Self.promiseQueue) { url, error in |
| + if let error { |
| + Logger.identity.error("drop promise failed: \(String(describing: error), privacy: .public)") |
| + return |
| + } |
| + DispatchQueue.main.async { stage(url) } |
| } |
| - guard let url, let data = try? Data(contentsOf: url) else { return } |
| - // Prefer the drag's human name; the temp file supplies the |
| - // extension (its name is machine-generated but correctly typed). |
| - let ext = url.pathExtension |
| - let base = provider.suggestedName ?? url.deletingPathExtension().lastPathComponent |
| - let name = ext.isEmpty || base.lowercased().hasSuffix("." + ext.lowercased()) |
| - ? base : base + "." + ext |
| - let staged = UTType(filenameExtension: url.pathExtension) ?? type |
| - DispatchQueue.main.async { stage(data: data, name: name, type: staged) } |
| } |
| + return true |
| + } |
| + for type in [NSPasteboard.PasteboardType.png, .tiff] { |
| + guard let data = pb.data(forType: type) else { continue } |
| + stage(data: data, name: type == .png ? "dropped.png" : "dropped.tiff", |
| + type: type == .png ? .png : .tiff) |
| + return true |
| } |
| + return false |
| } |
| |
| + // Promise fulfilment happens off-main (the source app writes the file); |
| + // staging hops back to main. |
| + private static let promiseQueue: OperationQueue = { |
| + let q = OperationQueue() |
| + q.qualityOfService = .userInitiated |
| + return q |
| + }() |
| + |
| private func stage(_ url: URL) { |
| guard let data = try? Data(contentsOf: url) else { return } |
| let name = url.lastPathComponent |