| index 7663bb7..eaa3f3b 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -1,6 +1,7 @@ |
| import LChatCore |
| import SwiftUI |
| import AVKit |
| +import GameController |
| import PhotosUI |
| import UniformTypeIdentifiers |
| |
| @@ -87,7 +88,6 @@ struct NativeChatScreen: View { |
| static var pendingShare: (thread: String, files: [URL])? |
| |
| @State private var draft = "" |
| - @State private var caret = 0 |
| // The per-thread command host (LChatCore CommandHost — the web command |
| // engine, headless): slash commands the native floor doesn't own forward |
| // here; replies come back as inline notices, panels as the webview above |
| @@ -100,7 +100,6 @@ struct NativeChatScreen: View { |
| @State private var panelHeight: CGFloat = 240 |
| @State private var panelAnimates = true // the open post's animate flag, reused on close |
| @State private var composerFocused = false // ComposerField's own focus binding (UIKit-backed) |
| - @State private var nativeTypeahead: (token: ComposerToken, items: [TypeaheadItem])? |
| // A default command's push-back line, generation-tokened so a re-shown |
| // identical prompt is never wiped by an earlier showing's clear timer; |
| // dcRouting keeps one async routing in flight (no double-sent reminders). |
| @@ -129,10 +128,28 @@ struct NativeChatScreen: View { |
| // The notification-tap thread-reply check ran for this open (one-shot, |
| // like the unread anchor it complements — landOnUnreadThreadReply). |
| @State private var unreadThreadChecked = false |
| - @State private var pickerItem: PhotosPickerItem? |
| - @State private var showFilePicker = false |
| + // Where the open thread presents (thread-pane spec §6, the iPad slice): |
| + // in a REGULAR horizontal size class it docks — a flush right-hand pane |
| + // beside the feed with its own composer, Slack-style; compact keeps the |
| + // lifted overlay byte-for-byte. Read live, so an iPad multitasking |
| + // resize mid-thread re-presents (the .onChange(of: sizeClass) handoff |
| + // carries the draft across). An iPhone can't reach regular: the app is |
| + // portrait-locked there. |
| + @Environment(\.horizontalSizeClass) private var sizeClass |
| + private var threadDocked: Bool { sizeClass == .regular } |
| + // The docked pane's OWN composer state — a second container state set, |
| + // exactly what the MessageComposerBar extraction was for. No replyTo |
| + // twin: the pane's reply target is fixed (the thread root, derived fresh |
| + // at send), so nothing user-armed lives here. |
| + @State private var paneDraft = "" |
| + @State private var paneFocused = false |
| + @State private var paneController = ComposerFieldController() |
| + @State private var paneStaged: [StagedUpload] = [] |
| + @State private var paneEditingID: Int64? |
| + // Attachments staged on the draft (StagedUpload is file-scope, shared |
| + // with MessageComposerBar, which renders the chips; the encrypt+upload |
| + // pipeline that fills them stays here — the share-sheet handoff feeds it). |
| @State private var staged: [StagedUpload] = [] |
| - @State private var sendQueued = false |
| @State private var quickLookItem: QuickLookItem? |
| @State private var playingVideo: PlayingVideo? |
| // A downloaded file staged on disk for Quick Look, plus the temp directory |
| @@ -145,14 +162,6 @@ struct NativeChatScreen: View { |
| let url: URL |
| } |
| |
| - struct StagedUpload: Identifiable { |
| - let id = UUID() |
| - let name: String |
| - var preview: UIImage? // image picks/pastes show a real thumbnail |
| - var progress = 0.0 |
| - var attachment: Attachment? |
| - } |
| - |
| struct PlayingVideo: Identifiable { |
| let id: String |
| let player: AVPlayer |
| @@ -198,6 +207,7 @@ struct NativeChatScreen: View { |
| |
| @Environment(\.dismiss) private var dismiss |
| @Environment(\.scenePhase) private var scenePhase |
| + @Environment(\.colorScheme) private var scheme |
| @AppStorage("nativeTheme") private var themeName = "classic" |
| // Dynamic Type: read at the system body size (17 at the default setting — |
| // also the web's touch size), so Settings → Text Size scales the feed. |
| @@ -218,6 +228,19 @@ struct NativeChatScreen: View { |
| // under both and rests content between them via content insets. |
| @State private var headerHeight: CGFloat = 0 |
| @State private var composerHeight: CGFloat = 0 |
| + // The chat surface's width (probe on the feed/pane HStack) — the docked |
| + // pane clamps to it: the web's min(340px, 40%), so a Split View 2/3 or a |
| + // Stage Manager window (~640pt) still leaves the feed the larger share. |
| + @State private var chatWidth: CGFloat = 0 |
| + private var paneWidth: CGFloat { |
| + chatWidth > 0 ? min(340, chatWidth * 0.4) : 340 |
| + } |
| + // Whether a hardware keyboard is attached (GameController's coalesced |
| + // keyboard is the standard signal). Gates the focus-on-open courtesy: a |
| + // keyboard-driven reader wants the caret to move into the pane; a |
| + // touch-only iPad would get the software keyboard raised over the |
| + // replies just opened — this screen's own rule against exactly that. |
| + private var hardwareKeyboardAttached: Bool { GCKeyboard.coalesced != nil } |
| |
| @State private var showMembers = false |
| @State private var showRename = false |
| @@ -374,19 +397,29 @@ struct NativeChatScreen: View { |
| if let appID = activeGroupAppID { |
| groupAppSurface(appID) |
| } else { |
| - feed |
| - } |
| - } |
| - // The thread view lifts over the FEED only (the web's |
| - // .chatapp-feedwrap overlay) — applied before the safe-area |
| - // insets, so the header pill stays above it and the real composer |
| - // below stays live, staged to the thread's root. |
| - .overlay { |
| - if let rootID = threadRootID { |
| - threadOverlay(rootID) |
| - .transition(.opacity.combined(with: .offset(y: 12))) |
| + HStack(spacing: 0) { |
| + feedAndComposer |
| + // The docked thread pane (thread-pane spec §6): a flush |
| + // right column INSIDE the chat surface, beside the feed — |
| + // never over it. Regular width only; the lifted overlay |
| + // is the kept !threadDocked path inside feedAndComposer. |
| + if let rootID = threadRootID, threadDocked { |
| + Divider() |
| + threadPane(rootID) |
| + .transition(.move(edge: .trailing).combined(with: .opacity)) |
| + } |
| } |
| + // Measured for the pane's width clamp (the web's min(340px, |
| + // 40%)) — the feed column flexes, so this is the chat |
| + // surface's full width whether or not the pane is up; the |
| + // probe never shapes layout (the header/composer pattern). |
| + .background(GeometryReader { g in |
| + Color.clear |
| + .onAppear { chatWidth = g.size.width } |
| + .onChange(of: g.size.width) { _, w in chatWidth = w } |
| + }) |
| } |
| + } |
| .navigationTitle("") |
| .navigationBarTitleDisplayMode(.inline) |
| .toolbarBackground(.hidden, for: .navigationBar) |
| @@ -425,28 +458,57 @@ struct NativeChatScreen: View { |
| threadName: thread.title, evidence: target.evidence) |
| .presentationDetents([.medium, .large]) |
| } |
| - .safeAreaInset(edge: .bottom) { |
| - // An app surface owns the whole pane, inputs included — the |
| - // chat composer steps aside; a read-only app chat never has |
| - // one. |
| - if activeGroupAppID == nil, !readOnlyChat { |
| - // An ended DM (#578): the server refuses new messages, so |
| - // the composer gives way to the closed banner — the one |
| - // honest action left is Reopen. |
| - Group { |
| - if model.dmClosed { |
| - dmClosedBanner |
| - } else { |
| - composerCluster |
| + // An iPad multitasking resize can flip the size class with a |
| + // thread open — the presentation follows live (threadDocked is |
| + // derived), and this hands the thread's composer state across so |
| + // nothing typed is lost: overlay → dock moves the root-staged |
| + // main reply into the pane; dock → overlay folds the pane's draft |
| + // (or in-flight edit) back onto the main bar and re-stages the |
| + // root reply, the overlay's contract. A draft is only ever moved |
| + // into an EMPTY bar, and never while one of its uploads is still |
| + // in flight — the upload task writes its progress/blob id back |
| + // through the binding it was started with, so a chip moved |
| + // mid-upload would freeze on the other bar and wedge its send; |
| + // a mid-upload bar keeps its WHOLE draft (text included, so one |
| + // message never splits across two bars). Either refusal leaves |
| + // the pane's state parked, back on screen when the width |
| + // returns — the park survives a RESIZE only; a close or a root |
| + // switch resets it (openThread/closeThread). |
| + .onChange(of: sizeClass) { _, _ in |
| + guard let rootID = threadRootID else { return } |
| + if threadDocked { |
| + if replyTo?.id == rootID { |
| + replyTo = nil |
| + if editingID == nil, !paneHasContent, hasContent, !uploading(staged) { |
| + paneDraft = draft |
| + paneStaged = staged |
| + draft = "" |
| + staged = [] |
| } |
| } |
| - // Measured for the feed's rest inset — the feed extends |
| - // under the glass; content must rest above it. |
| - .background(GeometryReader { g in |
| - Color.clear |
| - .onAppear { composerHeight = g.size.height } |
| - .onChange(of: g.size.height) { _, h in composerHeight = h } |
| - }) |
| + if composerFocused { |
| + composerFocused = false |
| + paneFocused = true |
| + } |
| + } else { |
| + if let id = paneEditingID { |
| + if editingID == nil, !hasContent, !uploading(paneStaged) { |
| + replyTo = nil |
| + editingID = id |
| + draft = paneDraft |
| + staged = paneStaged |
| + resetPaneComposer() |
| + } |
| + } else if paneHasContent, !hasContent, !uploading(paneStaged) { |
| + draft = paneDraft |
| + staged = paneStaged |
| + resetPaneComposer() |
| + } |
| + if editingID == nil { stageThreadReply() } |
| + if paneFocused { |
| + paneFocused = false |
| + composerFocused = true |
| + } |
| } |
| } |
| .toolbar { |
| @@ -719,6 +781,49 @@ struct NativeChatScreen: View { |
| } |
| } |
| |
| + // The feed column — the scrolling transcript, the lifted thread overlay |
| + // (compact widths) and the composer inset — split out of body so the |
| + // docked thread pane can sit BESIDE it (thread-pane spec §6). The |
| + // composer inset rides this column, not the screen: docked, the pane |
| + // carries its own bar and the channel bar must stop at the divider. |
| + private var feedAndComposer: some View { |
| + feed |
| + // The thread view lifts over the FEED only (the web's |
| + // .chatapp-feedwrap overlay) — applied before the safe-area |
| + // insets, so the header pill stays above it and the real composer |
| + // below stays live, staged to the thread's root. Kept as the |
| + // !threadDocked path: regular widths dock instead. |
| + .overlay { |
| + if let rootID = threadRootID, !threadDocked { |
| + threadOverlay(rootID) |
| + .transition(.opacity.combined(with: .offset(y: 12))) |
| + } |
| + } |
| + .safeAreaInset(edge: .bottom) { |
| + // A read-only app chat never has a composer (an app surface |
| + // owning the whole pane never mounts this column at all). |
| + if !readOnlyChat { |
| + // An ended DM (#578): the server refuses new messages, so |
| + // the composer gives way to the closed banner — the one |
| + // honest action left is Reopen. |
| + Group { |
| + if model.dmClosed { |
| + dmClosedBanner |
| + } else { |
| + composer |
| + } |
| + } |
| + // Measured for the feed's rest inset — the feed extends |
| + // under the glass; content must rest above it. |
| + .background(GeometryReader { g in |
| + Color.clear |
| + .onAppear { composerHeight = g.size.height } |
| + .onChange(of: g.size.height) { _, h in composerHeight = h } |
| + }) |
| + } |
| + } |
| + } |
| + |
| // The feed is a STOCK, unflipped UICollectionView (StockChatFeed — |
| // promoted from the /freshpane experiment, which scrolled silky on |
| // device where the flipped container's dressing didn't). Standard safe |
| @@ -861,16 +966,18 @@ struct NativeChatScreen: View { |
| } |
| |
| @ViewBuilder |
| - private func threadBubble(_ m: ChatFeedModel.Message, showSender: Bool) -> some View { |
| + private func threadBubble(_ m: ChatFeedModel.Message, showSender: Bool, |
| + pane: Bool = false) -> some View { |
| // The muted fold holds inside the thread view too (#578). |
| if model.foldsAsMuted(m) { |
| MutedMessageStub { model.revealMuted(m.id) } |
| } else { |
| - threadMessageBubble(m, showSender: showSender) |
| + threadMessageBubble(m, showSender: showSender, pane: pane) |
| } |
| } |
| |
| - private func threadMessageBubble(_ m: ChatFeedModel.Message, showSender: Bool) -> some View { |
| + private func threadMessageBubble(_ m: ChatFeedModel.Message, showSender: Bool, |
| + pane: Bool = false) -> some View { |
| // Wrapped in the interaction reader: the screen deliberately doesn't |
| // observe FeedInteraction, but the overlay's download rings still need |
| // live progress. |
| @@ -885,7 +992,7 @@ struct NativeChatScreen: View { |
| onFileTap: { openFile($0) }, |
| onVideoTap: { att, frame in playVideo(att, from: frame) }, |
| downloading: inter.blobDownload, |
| - actions: threadMessageActions(m), |
| + actions: pane ? paneMessageActions(m) : threadMessageActions(m), |
| unfurl: { await model.unfurl($0) }) |
| .environment(\.chatFontSize, chatFontSize) |
| .environment(\.mentionIndex, model.mentionIndex) |
| @@ -944,7 +1051,7 @@ struct NativeChatScreen: View { |
| return actions |
| } |
| |
| - // Reply/Edit on a bubble INSIDE the open thread view (the Mac pane's |
| + // Reply/Edit on a bubble INSIDE the open thread OVERLAY (the Mac pane's |
| // routing, paneMessageActions): Reply re-arms the thread's root reply on |
| // the composer — flagged thread-only, like every send from the view — and |
| // pops the keyboard (a reply intent, unlike a tally tap); "Reply in |
| @@ -961,6 +1068,18 @@ struct NativeChatScreen: View { |
| return actions |
| } |
| |
| + // The same routing for a bubble rendered inside the DOCKED pane (spec |
| + // §10.5), aimed at the pane's own bar instead of the channel one: Reply |
| + // just puts the caret there (its fixed reply already targets this |
| + // thread's root, flagged thread-only at send), Edit stages on it, and |
| + // "Reply in thread" stays omitted — inherited from the thread catalogue. |
| + private func paneMessageActions(_ m: ChatFeedModel.Message) -> MessageActions { |
| + var actions = threadMessageActions(m) |
| + actions.onReply = { paneFocused = true } |
| + actions.onEdit = { startPaneEdit(m) } |
| + return actions |
| + } |
| + |
| private func openThread(_ rootID: Int64) { |
| guard rootID > 0, threadRootID != rootID else { return } |
| // Freeze the header stand-in NOW (web threadFallback) — the deepest |
| @@ -969,35 +1088,59 @@ struct NativeChatScreen: View { |
| // A fresh open lands at the newest reply — never on a stale jump |
| // target from an earlier one (jumpIntoThread re-aims right after). |
| threadJump = nil |
| + // Switching roots: the old thread's half-typed pane draft or |
| + // in-flight edit must not leak into the new one — in EITHER |
| + // presentation, since pane state parked by a compact-width resize |
| + // would otherwise resurface on the next dock with paneEditingID |
| + // still naming a message from the old thread. |
| + if threadRootID != nil { resetPaneComposer() } |
| withAnimation(.easeOut(duration: 0.18)) { threadRootID = rootID } |
| - stageThreadReply() |
| + if threadDocked { |
| + // The channel bar stays on the channel — no staging; the pane's |
| + // composer derives its reply fresh at send. Focus moves into |
| + // the pane (spec §10) only for a hardware-keyboard reader: a |
| + // tally/quote tap is a reading intent, and on a touch-only iPad |
| + // the software keyboard would rise over the replies just opened |
| + // — the rule stageThreadReply keeps for the overlay. The reply |
| + // verbs (replyInThread, the pane bubbles' Reply) focus regardless. |
| + if hardwareKeyboardAttached { paneFocused = true } |
| + } else { |
| + stageThreadReply() |
| + } |
| } |
| |
| // "Reply in thread" (thread-pane spec §3.5): resolve the tapped message |
| // to its thread's ROOT (replyTarget — replying to a reply continues its |
| // thread), open the thread view pinned to it (a re-tap inside the open |
| // thread just re-arms the root reply), then put the caret in the reply |
| - // box. Unlike a tally/quote tap this IS a reply intent, so the keyboard |
| - // pops — the one thread-open path that focuses on touch. |
| + // box — the pane's bar when docked, the channel bar staged to the root |
| + // under the overlay. Unlike a tally/quote tap this IS a reply intent, so |
| + // the keyboard pops even on the overlay path, which otherwise never |
| + // focuses on touch. |
| private func replyInThread(_ m: ChatFeedModel.Message) { |
| guard let rootID = model.replyTarget(for: m).id, rootID > 0 else { return } |
| - if threadRootID == rootID { |
| + if threadRootID != rootID { |
| + openThread(rootID) |
| + } else if !threadDocked { |
| stageThreadReply() |
| + } |
| + if threadDocked { |
| + paneFocused = true |
| } else { |
| - openThread(rootID) |
| + composerFocused = true |
| } |
| - composerFocused = true |
| } |
| |
| - // Stage the composer's reply on the thread's root — the view's composer |
| - // contract ("Replying to <root author>"). A reply displaces an in-flight |
| - // edit, but the keyboard is deliberately NOT popped: it would cover the |
| - // thread the reader just opened (the web skips focus on touch too). |
| + // Stage the composer's reply on the thread's root — the OVERLAY view's |
| + // composer contract ("Replying to <root author>"). A reply displaces an |
| + // in-flight edit, but the keyboard is deliberately NOT popped: it would |
| + // cover the thread the reader just opened (the web skips focus on touch |
| + // too). Docked, the pane's own bar carries the thread — never called. |
| // Flagged thread-only (ReplyRef.thread): a send from the open thread |
| // view lives in the thread, Slack-style — the main feed shows only the |
| // root's "N replies" tally. The compact analogue of the Mac/web pane |
| - // composer's send; a plain Reply/swipe/quote reply from the feed itself |
| - // never carries the flag. |
| + // composer's send (sendPaneDraft flags its own); a plain Reply/swipe/ |
| + // quote reply from the feed itself never carries the flag. |
| private func stageThreadReply() { |
| guard let rootID = threadRootID else { return } |
| editingID = nil |
| @@ -1009,9 +1152,180 @@ struct NativeChatScreen: View { |
| withAnimation(.easeOut(duration: 0.15)) { threadRootID = nil } |
| threadFallback = nil |
| threadJump = nil |
| - // Leaving the thread drops its staged reply — never an unrelated one |
| - // staged since (an edit clears replyTo itself, so that survives). |
| - if replyTo?.id == rootID { replyTo = nil } |
| + // The pane's composer dies with the thread (the web's docked pane |
| + // block is destroyed on close) — in EITHER presentation: pane state |
| + // a compact-width resize parked must not outlive the thread it was |
| + // typed for (a later dock would revive a draft, or an edit id, from |
| + // a thread no longer on screen). |
| + resetPaneComposer() |
| + if threadDocked { |
| + // Focus returns to the channel bar only if the pane's field HAD |
| + // it — a keyboard that's up moves over, a keyboard that's down |
| + // stays down (touch never pops one as a side effect of closing). |
| + let hadFocus = paneFocused |
| + paneFocused = false |
| + if hadFocus { composerFocused = true } |
| + } else { |
| + // Leaving the overlay drops its staged reply — never an unrelated |
| + // one staged since (an edit clears replyTo itself, so that |
| + // survives). |
| + if replyTo?.id == rootID { replyTo = nil } |
| + } |
| + } |
| + |
| + // ---- the docked pane (thread-pane spec §6, regular widths) ----------- |
| + |
| + // ReplyThreadView's panel content — header/✕, pinned root, paging note, |
| + // reply rows — as a flush right-hand column with its OWN composer at the |
| + // bottom, the web's .chatapp-thread-panel--docked. Root-paging and the |
| + // empty-thread auto-close live in ReplyThreadView and ride along |
| + // unchanged. No onRootPaged: there's no staged banner to refresh — the |
| + // pane's reply target derives fresh at send. |
| + private func threadPane(_ rootID: Int64) -> some View { |
| + VStack(spacing: 0) { |
| + ReplyThreadView(model: model, rootID: rootID, fallback: threadFallback, |
| + theme: .named(themeName), isGroup: isGroup, docked: true, |
| + // A jump into a thread lands on THAT reply here |
| + // too (#1475): jumpIntoThread opens this pane and |
| + // re-aims with the tick, the rows wave via |
| + // threadDanceID below. |
| + jumpTarget: threadJump, jumpTick: threadJumpTick, |
| + onClose: closeThread, |
| + row: { m, showSender in |
| + threadBubble(m, showSender: showSender, pane: true) |
| + }) |
| + // An ended DM refuses new messages — the pane goes composer-less, |
| + // like the channel bar giving way to the closed banner; a |
| + // read-only app chat never had one. |
| + if !model.dmClosed, !readOnlyChat { |
| + Divider() |
| + paneComposer |
| + } |
| + } |
| + // The web's min(340px, 40%): fixed at 340 on a full-width iPad, a |
| + // 40% share when the chat surface is narrower (Split View 2/3, |
| + // Stage Manager) so the feed keeps the larger column. |
| + .frame(width: paneWidth) |
| + // The web dock's background: var(--panel), not the overlay's frosted |
| + // material — a flush column sits on the window, not over the feed. |
| + .background(ChatTheme.named(themeName).panel.resolved(scheme)) |
| + // The web dock is a complementary landmark ("Reply thread"); the |
| + // panel content inside carries the header/controls. |
| + .accessibilityElement(children: .contain) |
| + .accessibilityLabel("Reply thread") |
| + } |
| + |
| + // The pane's composer — a SECOND MessageComposerBar over its own state |
| + // set. fixedReply marks it a pane bar (no reply banner, no ✕ — the |
| + // target is the thread's root, derived fresh at send in sendPaneDraft); |
| + // commands stay off, so "/" text sends verbatim (spec risk 3). The edit |
| + // banner DOES render here (§10.5): editing a thread reply from the pane |
| + // is legitimate, with its ✕ cancelling back to the plain reply state. |
| + private var paneComposer: some View { |
| + MessageComposerBar( |
| + fixedReply: { threadRootID.map { model.threadReplyRef(root: $0).inThread() } }, |
| + allowCommands: false, |
| + placeholder: "Reply…", |
| + formatAsYouType: formatAsYouType, |
| + roster: store.roster, |
| + myUserId: store.myUserId, |
| + hasBot: hasBot, |
| + appCommands: [], |
| + displayNames: store.displayLabelMap(), |
| + hostRows: [], |
| + draft: $paneDraft, |
| + composerFocused: $paneFocused, |
| + controller: paneController, |
| + staged: paneStaged, |
| + replyTo: nil, |
| + editingID: paneEditingID, |
| + routeNotice: nil, |
| + onSend: sendPaneDraft, |
| + attach: defaultCommand == nil ? attachHooks(into: .pane) : nil, |
| + onUnstage: { s in paneStaged.removeAll { $0.id == s.id } }, |
| + onCancelReply: {}, // never rendered — fixedReply is non-nil |
| + onCancelEdit: cancelPaneEdit, |
| + onDraftChanged: { text in |
| + // Same thread, same typing beats; no "/" host pre-warm — the |
| + // pane runs no commands. |
| + model.typingBeat(active: !text.isEmpty && paneEditingID == nil) |
| + }, |
| + onEscapeFallback: { |
| + // The pane's ladder, innermost first (after the bar's own |
| + // typeahead): an armed edit cancels — the same peel as its |
| + // banner ✕ — then typed content makes Escape a no-op |
| + // (keyboard Escape is never destructive of a draft or staged |
| + // files; a deliberate close with content is the ✕), and only |
| + // an EMPTY pane composer closes the thread. |
| + if paneEditingID != nil { |
| + cancelPaneEdit() |
| + return true |
| + } |
| + if paneHasContent { return false } |
| + closeThread() |
| + return true |
| + }, |
| + onEditLatest: { |
| + // ↑ edits the latest own message IN THIS THREAD (root |
| + // included — it's pinned right here), not the channel's. |
| + guard let rootID = threadRootID, |
| + let last = ChatFeedModel.threadItems(in: model.messages, root: rootID) |
| + .last(where: { $0.mine && !$0.action }) |
| + else { return } |
| + startPaneEdit(last) |
| + }, |
| + panel: EmptyView()) |
| + } |
| + |
| + private var paneHasContent: Bool { |
| + !paneDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !paneStaged.isEmpty |
| + } |
| + |
| + private var paneWireDraft: String { |
| + let wire = paneController.serializedMarkdown(escapeLiterals: !formatAsYouType) |
| + return wire.isEmpty ? paneDraft : wire |
| + } |
| + |
| + // The pane's send: an ORDINARY replyTo reply — no command routing, no |
| + // default-command detour (matching the web pane composer). The reply |
| + // target derives FRESH here (ChatFeedModel.replyTarget semantics): the |
| + // root may have paged in, or the chain's deepest snapshot improved, |
| + // since the pane opened. |
| + private func sendPaneDraft() { |
| + // An edit replaces the message in place — same rule as the channel bar. |
| + if let id = paneEditingID { |
| + if model.edit(id, text: paneWireDraft) { cancelPaneEdit() } |
| + return |
| + } |
| + guard Self.canSend(draft: paneDraft, staged: paneStaged), |
| + let rootID = threadRootID else { return } |
| + // Only clear once the send reaches the socket — a down socket keeps |
| + // the draft to resend, never silently drops it (the web's rule). |
| + // Flagged thread-only (ReplyRef.thread, #1475): a send from the pane |
| + // lives in the thread — the transcript shows the root's tally only. |
| + if model.send(paneWireDraft, attachments: paneStaged.compactMap(\.attachment), |
| + replyTo: model.threadReplyRef(root: rootID).inThread()) { |
| + paneDraft = "" |
| + paneStaged = [] |
| + paneFocused = true // the next reply continues the thread (web parity) |
| + } |
| + } |
| + |
| + private func startPaneEdit(_ m: ChatFeedModel.Message) { |
| + paneEditingID = m.id |
| + paneDraft = m.raw.text ?? m.text |
| + paneFocused = true |
| + } |
| + |
| + private func cancelPaneEdit() { |
| + paneEditingID = nil |
| + paneDraft = "" |
| + } |
| + |
| + private func resetPaneComposer() { |
| + paneDraft = "" |
| + paneStaged = [] |
| + paneEditingID = nil |
| } |
| |
| // Scroll to a message and wave it — quote-taps and search hits share this. |
| @@ -1138,167 +1452,139 @@ struct NativeChatScreen: View { |
| .background(.bar) |
| } |
| |
| - private var composerCluster: some View { |
| - VStack(spacing: 6) { |
| - if let ta = nativeTypeahead { |
| - typeaheadStrip(ta.items) |
| - } |
| - // The command panel — a command's transient interactive surface |
| - // (/emoji, /gif), the webview revealed above the composer while a |
| - // panel is showing. Mounted permanently once a host exists (height |
| - // 0 when closed) so the page loads and keeps its state — the Mac |
| - // composer's exact shape. |
| - if let commandHost { |
| - CommandPanelWebView(host: commandHost) |
| - .frame(height: panelVisible ? panelHeight : 0) |
| - .clipShape(RoundedRectangle(cornerRadius: panelVisible ? 12 : 0)) |
| - .padding(.horizontal, panelVisible ? 10 : 0) |
| - .accessibilityHidden(!panelVisible) |
| - } |
| - // A default command pushing back ("when should I remind you?") — |
| - // inline above the composer, occupying layout, never floating. |
| - if let routeNotice { |
| - Text(routeNotice) |
| - .font(.footnote) |
| - .foregroundStyle(.secondary) |
| - .frame(maxWidth: .infinity, alignment: .leading) |
| - .padding(.horizontal, 16) |
| - } |
| - if let replyTo { |
| - banner("Replying to \(replyTo.name ?? "")", detail: replyTo.snippet) { |
| - // While the thread view is open its staged reply IS the |
| - // view's composer state — cancelling it means leaving the |
| - // thread (web parity), not stranding an open thread whose |
| - // sends would land outside it. |
| - if threadRootID != nil { closeThread() } else { self.replyTo = nil } |
| + // The native composer — the bar itself is MessageComposerBar (file |
| + // scope, below): one instantiable view, so the docked thread pane can |
| + // mount a second one with its own draft. This is the MAIN mount: |
| + // user-armed reply/edit banners (fixedReply nil), commands on, the |
| + // command-host panel in the slot, and the send/staging/host machinery |
| + // below supplied as closures. |
| + private var composer: some View { |
| + MessageComposerBar( |
| + fixedReply: nil, |
| + allowCommands: true, |
| + placeholder: defaultCommand?.placeholder ?? "Message", |
| + formatAsYouType: formatAsYouType, |
| + roster: store.roster, |
| + myUserId: store.myUserId, |
| + hasBot: hasBot, |
| + // App-local verbs (/add in To-do) lead the strip, bot or no bot — |
| + // the web's commandCatalog(threadId). |
| + appCommands: AppDefaultCommand.localCommands(appID: thread.appId), |
| + displayNames: store.displayLabelMap(), |
| + hostRows: hostRows, |
| + draft: $draft, |
| + composerFocused: $composerFocused, |
| + controller: composerController, |
| + staged: staged, |
| + replyTo: replyTo, |
| + editingID: editingID, |
| + routeNotice: routeNotice, |
| + onSend: sendDraft, |
| + // A default-command thread is text-only — with no one to read a |
| + // file, an attachment is a dead end, so the "+" and paste/drop |
| + // staging go (the web composer hides its attach button the same |
| + // way). |
| + attach: defaultCommand == nil ? attachHooks(into: .main) : nil, |
| + onUnstage: { s in staged.removeAll { $0.id == s.id } }, |
| + onCancelReply: cancelReply, |
| + onCancelEdit: cancelEdit, |
| + onDraftChanged: { text in |
| + model.typingBeat(active: !text.isEmpty && editingID == nil) |
| + // Pre-warm the command host the moment a command might be |
| + // coming — its page load happens during typing, and its rows |
| + // upgrade the "/" typeahead (Mac parity). |
| + if text.first == "/", commandHost == nil { ensureCommandHost() } |
| + }, |
| + onEscapeFallback: { |
| + // An open command panel is the most recently summoned layer — |
| + // Escape peels it (after the bar's own typeahead) before the |
| + // bar's own armed edit/reply, exactly what the banner ✕ does. |
| + // The channel bar never closes the docked pane — that's the |
| + // pane bar's Escape, or its ✕ — so a reflex Escape here can't |
| + // take the thread away. |
| + if panelVisible { |
| + commandHost?.dismissPanel() |
| + return true |
| } |
| - } |
| - if editingID != nil { |
| - banner("Editing message", detail: nil) { cancelEdit() } |
| - } |
| - if !staged.isEmpty { |
| - stagedChips |
| - } |
| - composerRow |
| + if editingID != nil { |
| + cancelEdit() |
| + return true |
| + } |
| + if replyTo != nil { |
| + // Under the overlay, cancelling the staged root reply IS |
| + // closing the thread (cancelReply) — and the draft would |
| + // silently stop being a thread reply. The pane bar's |
| + // rule holds here too: keyboard Escape is never |
| + // destructive, so typed/staged content makes it a no-op |
| + // (the banner ✕ closes with content); only an empty bar |
| + // peels the reply. A plain (non-thread) reply always |
| + // cancels — the draft stays, only the banner goes. |
| + if threadRootID != nil, !threadDocked, hasContent { return false } |
| + cancelReply() |
| + return true |
| + } |
| + return false |
| + }, |
| + onEditLatest: { |
| + // ↑ edits the latest own message on the surface in front of |
| + // the reader: the OVERLAY thread's (root included) while it |
| + // is up, else the FEED's — a thread-only reply of mine isn't |
| + // in the transcript. Docked, this bar IS the channel's, so |
| + // the open pane never claims it (the pane bar edits within |
| + // its own thread). |
| + let pool = threadRootID.flatMap { root in |
| + threadDocked ? nil : ChatFeedModel.threadItems(in: model.messages, root: root) |
| + } ?? model.feedMessages |
| + if let last = pool.last(where: { $0.mine && !$0.action }) { |
| + startEdit(last) |
| + } |
| + }, |
| + panel: commandPanel) |
| + } |
| + |
| + // The command panel — a command's transient interactive surface |
| + // (/emoji, /gif), the webview revealed above the composer while a |
| + // panel is showing. Mounted permanently once a host exists (height |
| + // 0 when closed) so the page loads and keeps its state — the Mac |
| + // composer's exact shape. The main bar's panel slot, between the |
| + // typeahead and the banners; the pane bar passes an EmptyView. |
| + @ViewBuilder private var commandPanel: some View { |
| + if let commandHost { |
| + CommandPanelWebView(host: commandHost) |
| + .frame(height: panelVisible ? panelHeight : 0) |
| + .clipShape(RoundedRectangle(cornerRadius: panelVisible ? 12 : 0)) |
| + .padding(.horizontal, panelVisible ? 10 : 0) |
| + .accessibilityHidden(!panelVisible) |
| } |
| - .padding(.top, 4) |
| } |
| |
| - // The floating-glass composer: the field self-sizes |
| - // (GrowingTextView), and it shares one glass capsule with the send button. |
| - private var composerRow: some View { |
| - HStack(alignment: .bottom, spacing: 8) { |
| - // A default-command thread is text-only — with no one to read a |
| - // file, an attachment is a dead end, so the affordance goes (the |
| - // web composer hides its attach button the same way). |
| - if defaultCommand == nil { |
| - Menu { |
| - // Simulators (and some iPads) have no camera — the row |
| - // just doesn't exist there rather than failing on tap. |
| - if UIImagePickerController.isSourceTypeAvailable(.camera) { |
| - Button { showCamera = true } label: { |
| - Label("Camera", systemImage: "camera") |
| - } |
| - } |
| - Button { showPhotoPicker = true } label: { |
| - Label("Photo or Video", systemImage: "photo") |
| - } |
| - Button { showFilePicker = true } label: { |
| - Label("File", systemImage: "doc") |
| - } |
| - } label: { |
| - Image(systemName: "plus") |
| - .font(.system(size: 18, weight: .medium)) |
| - .frame(width: 36, height: 36) |
| - .floatingGlass(in: Circle()) |
| - } |
| - .buttonStyle(.plain) |
| - } |
| + // Which composer a staged file lands on: the channel bar or the docked |
| + // thread pane's. Threaded through the one staging/upload pipeline below |
| + // so both bars share it — each keeps its own chip list. |
| + private enum ComposerTarget { case main, pane } |
| |
| - HStack(alignment: .bottom, spacing: 6) { |
| - // sendOnReturn: false — touch Enter newlines, the send button |
| - // sends (the shouldSend rule). |
| - ComposerField(text: $draft, focused: $composerFocused, caret: $caret, |
| - placeholder: defaultCommand?.placeholder ?? "Message", |
| - sendOnReturn: false, |
| - formatAsYouType: formatAsYouType, |
| - controller: composerController, |
| - onSend: sendDraft, |
| - onPasteImage: { if defaultCommand == nil { stageImageData($0) } }, |
| - onEditLast: { |
| - // ↑ edits the latest own message on the |
| - // surface in front of the reader: the open |
| - // thread's (root included) while the view |
| - // is up, else the FEED's — a thread-only |
| - // reply of mine isn't in the transcript. |
| - let pool = threadRootID.map { |
| - ChatFeedModel.threadItems(in: model.messages, root: $0) |
| - } ?? model.feedMessages |
| - if let last = pool.last(where: { $0.mine && !$0.action }) { |
| - startEdit(last) |
| - } |
| - }) |
| - .padding(.leading, 14) |
| - .padding(.vertical, 8) |
| - .onChange(of: draft) { _, _ in |
| - refreshTypeahead() |
| - model.typingBeat(active: !draft.isEmpty && editingID == nil) |
| - // Pre-warm the command host the moment a command might |
| - // be coming — its page load happens during typing, and |
| - // its rows upgrade the "/" typeahead (Mac parity). |
| - if draft.first == "/", commandHost == nil { ensureCommandHost() } |
| - } |
| - .onChange(of: caret) { _, _ in refreshTypeahead() } |
| + private func stagedBinding(_ target: ComposerTarget) -> Binding<[StagedUpload]> { |
| + target == .main ? $staged : $paneStaged |
| + } |
| |
| - Button(action: sendDraft) { |
| - Image(systemName: "arrow.up.circle.fill") |
| - .font(.system(size: 28)) |
| - .foregroundStyle(canSend ? Color.accentColor : Color(.tertiaryLabel)) |
| - // 44pt target — a near-miss must send, not close the keyboard. |
| - .frame(width: 44, height: 40) |
| - .contentShape(Rectangle()) |
| - } |
| - .buttonStyle(.plain) |
| - .disabled(!canSend) |
| - .accessibilityLabel("Send") |
| - } |
| - .floatingGlass(in: RoundedRectangle(cornerRadius: 22)) |
| - } |
| - .padding(.horizontal, 10) |
| - .padding(.vertical, 8) |
| - .photosPicker(isPresented: $showPhotoPicker, selection: $pickerItem, matching: .any(of: [.images, .videos])) |
| - .onChange(of: pickerItem) { _, item in |
| - guard let item else { return } |
| - pickerItem = nil |
| - stagePickerItem(item) |
| - } |
| - .fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.item]) { result in |
| - if case .success(let url) = result { stageFile(url) } |
| - } |
| - // A fresh capture stages exactly like a pasted image — the upload |
| - // path's processImage downscales and strips EXIF/location before |
| - // encryption, same as every other photo source. |
| - .fullScreenCover(isPresented: $showCamera) { |
| - CameraCapture { data in stageImageData(data, name: "photo.jpg") } |
| - .ignoresSafeArea() |
| - } |
| - // Drop-to-attach (iPad split view, iPhone app-to-app drags): images, |
| - // movies and files stage exactly like the pickers' choices. The system |
| - // drag badge is the whole affordance — no overlay (matching the Mac). |
| - .onDrop(of: defaultCommand == nil ? [.image, .movie, .fileURL] : [], |
| - isTargeted: nil) { providers in |
| - stageDroppedProviders(providers) |
| - return true |
| - } |
| + // A bar's attachment sources, all landing on ONE target's chip list: the |
| + // "+" menu's camera/photo/file picks, an image paste, a drop. |
| + private func attachHooks(into target: ComposerTarget) -> ComposerAttachHooks { |
| + ComposerAttachHooks( |
| + onPickerItem: { stagePickerItem($0, into: target) }, |
| + onFile: { stageFile($0, into: target) }, |
| + onImageData: { stageImageData($0, name: $1, into: target) }, |
| + onDrop: { stageDroppedProviders($0, into: target) }) |
| } |
| |
| // One provider pipeline for drops: prefer a real file (name + full |
| // quality), else raw image/movie data with the provider's suggested name. |
| - private func stageDroppedProviders(_ providers: [NSItemProvider]) { |
| + private func stageDroppedProviders(_ providers: [NSItemProvider], |
| + into target: ComposerTarget) { |
| for provider in providers { |
| if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { |
| _ = provider.loadObject(ofClass: URL.self) { url, _ in |
| - if let url { DispatchQueue.main.async { stageFile(url) } } |
| + if let url { DispatchQueue.main.async { stageFile(url, into: target) } } |
| } |
| continue |
| } |
| @@ -1306,7 +1592,7 @@ struct NativeChatScreen: View { |
| provider.loadDataRepresentation(forTypeIdentifier: UTType.movie.identifier) { data, _ in |
| guard let data else { return } |
| let name = provider.suggestedName ?? "video" |
| - DispatchQueue.main.async { stageVideoData(data, name: name) } |
| + DispatchQueue.main.async { stageVideoData(data, name: name, into: target) } |
| } |
| continue |
| } |
| @@ -1314,21 +1600,35 @@ struct NativeChatScreen: View { |
| provider.loadDataRepresentation(forTypeIdentifier: UTType.image.identifier) { data, _ in |
| guard let data else { return } |
| let name = provider.suggestedName ?? "photo.jpg" |
| - DispatchQueue.main.async { stageImageData(data, name: name) } |
| + DispatchQueue.main.async { stageImageData(data, name: name, into: target) } |
| } |
| } |
| } |
| } |
| |
| - @State private var showPhotoPicker = false |
| - @State private var showCamera = false |
| - |
| - private var canSend: Bool { |
| + // Sendable = something typed or something uploaded, nothing still in |
| + // flight. Static so the bar's send button and both send paths (channel, |
| + // pane) read the one rule. |
| + static func canSend(draft: String, staged: [StagedUpload]) -> Bool { |
| let hasText = !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
| let uploading = staged.contains { $0.attachment == nil } |
| return !uploading && (hasText || staged.contains { $0.attachment != nil }) |
| } |
| |
| + private var canSend: Bool { Self.canSend(draft: draft, staged: staged) } |
| + |
| + // Anything at all on the channel bar — typed or staged, uploaded or not |
| + // (the size-class handoff only ever moves a draft into an EMPTY bar). |
| + private var hasContent: Bool { |
| + !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !staged.isEmpty |
| + } |
| + |
| + // An upload still in flight on a bar's chip list — its task will write |
| + // back through the binding it started with, so the list must stay put. |
| + private func uploading(_ staged: [StagedUpload]) -> Bool { |
| + staged.contains { $0.attachment == nil } |
| + } |
| + |
| // Show (or clear, with nil) the default-command push-back. Every showing |
| // bumps the generation, so an in-flight clear timer from an EARLIER |
| // showing — even of the identical string — can never wipe this one early. |
| @@ -1440,10 +1740,12 @@ struct NativeChatScreen: View { |
| if model.send(wireDraft, attachments: staged.compactMap(\.attachment), replyTo: replyTo) { |
| draft = "" |
| staged = [] |
| - // Sending from an open thread view keeps it open and re-stages the |
| - // reply to its root — the new reply folds in live and the next |
| - // send continues the same thread, like iMessage (web parity). |
| - if threadRootID != nil { stageThreadReply() } else { replyTo = nil } |
| + // Overlay model only: sending from an open thread view keeps it |
| + // open and re-stages the reply to its root — the new reply folds |
| + // in live and the next send continues the same thread, like |
| + // iMessage (web parity). Docked, the channel bar stays on the |
| + // channel — the pane's own composer carries the thread. |
| + if threadRootID != nil, !threadDocked { stageThreadReply() } else { replyTo = nil } |
| } |
| } |
| |
| @@ -1487,10 +1789,10 @@ struct NativeChatScreen: View { |
| // inline slot the default-command push-back uses. |
| setRouteNotice(from.isEmpty ? text : "\(from) — \(text)") |
| case .commands(let rows): |
| - // The thread's live command catalogue landed — the typeahead |
| - // and the forwarding decision follow it from here. |
| + // The thread's live command catalogue landed — the forwarding |
| + // decision follows it from here, and the composer bar re-ranks |
| + // its typeahead on the rows change (onChange(of: hostRows)). |
| hostRows = rows |
| - refreshTypeahead() |
| } |
| } |
| // Cross-thread command routing (docs/native-apps.md phase 3): the page |
| @@ -1521,6 +1823,16 @@ struct NativeChatScreen: View { |
| composerFocused = true |
| } |
| |
| + // While the OVERLAY thread view is open its staged reply IS the view's |
| + // composer state — cancelling it (the banner's ✕, or a hardware Escape) |
| + // means leaving the thread (web parity), not stranding an open thread |
| + // whose sends would land outside it. Docked, the channel bar's banner is |
| + // always the reader's own staging: cancel just cancels, and the pane |
| + // closes through its own Escape/✕. |
| + private func cancelReply() { |
| + if threadRootID != nil, !threadDocked { closeThread() } else { replyTo = nil } |
| + } |
| + |
| private func startEdit(_ m: ChatFeedModel.Message) { |
| replyTo = nil |
| editingID = m.id |
| @@ -1531,13 +1843,381 @@ struct NativeChatScreen: View { |
| private func cancelEdit() { |
| editingID = nil |
| draft = "" |
| - // An edit inside an open thread view borrowed the composer (startEdit |
| - // clears replyTo); hand it back so the next send still continues the |
| - // thread (web cancelEdit). |
| - if threadRootID != nil { stageThreadReply() } |
| + // Overlay model: an edit inside an open thread view borrowed the |
| + // composer (startEdit clears replyTo); hand it back so the next send |
| + // still continues the thread (web cancelEdit). Docked edits ride the |
| + // pane's own bar (cancelPaneEdit), so nothing to hand back. |
| + if threadRootID != nil, !threadDocked { stageThreadReply() } |
| } |
| |
| - private func banner(_ title: String, detail: String?, onCancel: @escaping () -> Void) -> some View { |
| + // ---- uploads ------------------------------------------------------------- |
| + |
| + // The encrypt+upload pipeline behind both bars' chips: each stage* call |
| + // lands on ONE target's list (stagedBinding), the chip flips from |
| + // progress to ready when its blob id comes back. |
| + |
| + private func stagePickerItem(_ item: PhotosPickerItem, into target: ComposerTarget) { |
| + let staged = stagedBinding(target) |
| + let isVideo = item.supportedContentTypes.contains { $0.conforms(to: .movie) } |
| + let name = isVideo ? "video.mp4" : "photo.jpg" |
| + let entry = StagedUpload(name: name) |
| + staged.wrappedValue.append(entry) |
| + let onProgress: @MainActor (Double) -> Void = { p in |
| + if let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) { |
| + staged.wrappedValue[i].progress = p |
| + } |
| + } |
| + Task { |
| + guard let data = try? await item.loadTransferable(type: Data.self) else { |
| + staged.wrappedValue.removeAll { $0.id == entry.id } |
| + return |
| + } |
| + // The composer-chip preview downscale is a full decode — detached, |
| + // like the upload-side processImage in ChatFeedModel. |
| + if !isVideo, |
| + let thumb = await Task.detached(operation: { processImage(data, maxDim: 288) }).value, |
| + let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) { |
| + staged.wrappedValue[i].preview = UIImage(data: thumb.bytes) |
| + } |
| + let att = isVideo |
| + ? await model.uploadVideo(data, name: name, mimeType: "video/mp4", onProgress: onProgress) |
| + : await model.uploadImage(data, name: name, onProgress: onProgress) |
| + if let att, let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) { |
| + staged.wrappedValue[i].attachment = att |
| + } else { |
| + staged.wrappedValue.removeAll { $0.id == entry.id } |
| + } |
| + } |
| + } |
| + |
| + // Stage raw image bytes (paste / camera / share-sheet handoff) as an upload. |
| + private func stageImageData(_ data: Data, name: String = "pasted.jpg", |
| + into target: ComposerTarget = .main) { |
| + let staged = stagedBinding(target) |
| + let entry = StagedUpload(name: name) |
| + staged.wrappedValue.append(entry) |
| + let onProgress: @MainActor (Double) -> Void = { p in |
| + if let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) { |
| + staged.wrappedValue[i].progress = p |
| + } |
| + } |
| + Task { |
| + // The chip shows name-only for a beat while the preview downscale |
| + // (a full decode of the pasted image) runs detached. |
| + if let thumb = await Task.detached(operation: { processImage(data, maxDim: 288) }).value, |
| + let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) { |
| + staged.wrappedValue[i].preview = UIImage(data: thumb.bytes) |
| + } |
| + let att = await model.uploadImage(data, name: name, onProgress: onProgress) |
| + if let att, let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) { |
| + staged.wrappedValue[i].attachment = att |
| + } else { |
| + staged.wrappedValue.removeAll { $0.id == entry.id } |
| + } |
| + } |
| + } |
| + |
| + private func stageVideoData(_ data: Data, name: String, into target: ComposerTarget = .main) { |
| + let staged = stagedBinding(target) |
| + let entry = StagedUpload(name: name) |
| + staged.wrappedValue.append(entry) |
| + let onProgress: @MainActor (Double) -> Void = { p in |
| + if let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) { |
| + staged.wrappedValue[i].progress = p |
| + } |
| + } |
| + Task { |
| + let att = await model.uploadVideo(data, name: name, mimeType: "video/mp4", |
| + onProgress: onProgress) |
| + if let att, let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) { |
| + staged.wrappedValue[i].attachment = att |
| + } else { |
| + staged.wrappedValue.removeAll { $0.id == entry.id } |
| + } |
| + } |
| + } |
| + |
| + private func stageFile(_ url: URL, into target: ComposerTarget) { |
| + let staged = stagedBinding(target) |
| + let name = url.lastPathComponent |
| + let entry = StagedUpload(name: name) |
| + staged.wrappedValue.append(entry) |
| + let type = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType |
| + let onProgress: @MainActor (Double) -> Void = { p in |
| + if let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) { |
| + staged.wrappedValue[i].progress = p |
| + } |
| + } |
| + Task { |
| + // Whole-file read detached — a picked file can be tens of MB, and |
| + // the security scope only needs to bracket the read itself. |
| + let data = await Task.detached { () -> Data? in |
| + guard url.startAccessingSecurityScopedResource() else { return nil } |
| + defer { url.stopAccessingSecurityScopedResource() } |
| + return try? Data(contentsOf: url) |
| + }.value |
| + guard let data else { |
| + staged.wrappedValue.removeAll { $0.id == entry.id } |
| + return |
| + } |
| + let att = await model.uploadFile(data, name: name, mimeType: type, onProgress: onProgress) |
| + if let att, let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) { |
| + staged.wrappedValue[i].attachment = att |
| + } else { |
| + staged.wrappedValue.removeAll { $0.id == entry.id } |
| + } |
| + } |
| + } |
| +} |
| + |
| +// Attachments staged on the draft: picked → uploading (progress) → ready. |
| +// Several can ride one message, like the web's pending list. File scope: |
| +// NativeChatScreen owns the encrypt+upload pipeline that fills these; |
| +// MessageComposerBar renders them as chips. |
| +struct StagedUpload: Identifiable { |
| + let id = UUID() |
| + let name: String |
| + var preview: UIImage? // image picks/pastes show a real thumbnail |
| + var progress = 0.0 |
| + var attachment: Attachment? |
| +} |
| + |
| +// A bar's attachment sources — the "+" menu's camera / photo / file picks, |
| +// an image paste, a drop — each handed to the container's staging pipeline |
| +// for ONE composer. nil = no "+", no paste/drop staging (a default-command |
| +// thread's text-only surface). |
| +struct ComposerAttachHooks { |
| + let onPickerItem: (PhotosPickerItem) -> Void |
| + let onFile: (URL) -> Void |
| + let onImageData: (Data, String) -> Void // bytes + a filename (camera, paste) |
| + let onDrop: ([NSItemProvider]) -> Void |
| +} |
| + |
| +// The chat composer as ONE instantiable view — extracted from |
| +// NativeChatScreen so the docked reply-thread pane (regular widths) can |
| +// mount a SECOND composer, with its own draft and typeahead, beside the |
| +// channel bar. The Mac's MessageComposerBar boundary, phone-shaped: the bar |
| +// owns only what is genuinely composer-internal (the caret, the typeahead, |
| +// the picker/camera sheet flags); everything code outside the composer |
| +// reaches — the draft (startEdit writes it), focus (thread open/close |
| +// claims and releases it), reply/edit staging, the staged uploads (the |
| +// share-sheet handoff feeds them), the command host — stays with the |
| +// container and arrives as bindings/values/closures, so a second instance |
| +// is just a second container state set. |
| +// |
| +// The two presentation knobs (thread-pane spec §6): |
| +// - fixedReply: nil = the MAIN bar — user-armed reply/edit banners. Non-nil = |
| +// a pane bar: the reply target derives fresh at send time (inside the |
| +// container's onSend), so no reply banner and no cancel-✕ render here. |
| +// - allowCommands: false = no slash-command typeahead — "/" text sends |
| +// verbatim (the container's onSend skips command routing and passes no |
| +// panel). @mention and :emoji: completion still work. |
| +struct MessageComposerBar<Panel: View>: View { |
| + let fixedReply: (() -> ReplyRef?)? |
| + let allowCommands: Bool |
| + let placeholder: String |
| + let formatAsYouType: Bool |
| + // Typeahead inputs (the web shell.js typeaheadItems' arguments) — plain |
| + // values, read point-in-time by the container like its other store reads. |
| + let roster: [RosterEntry] |
| + let myUserId: String |
| + let hasBot: Bool |
| + let appCommands: [(name: String, desc: String)] |
| + let displayNames: [String: String] |
| + let hostRows: [CommandRow] |
| + // Container-owned composer state (written from outside the bar — see the |
| + // header comment). |
| + @Binding var draft: String |
| + @Binding var composerFocused: Bool |
| + // Programmatic edits (typeahead splices, panel inserts) act on the field |
| + // via the controller, so styling and caret survive; the wire markdown is |
| + // read from it at send. Container-held: the command host's sink inserts |
| + // through it. |
| + let controller: ComposerFieldController |
| + let staged: [StagedUpload] |
| + let replyTo: ReplyRef? |
| + let editingID: Int64? |
| + let routeNotice: String? |
| + // Actions the container supplies — the send path, the staging pipeline |
| + // and the command host stay with it (they reach thread/store state). |
| + let onSend: () -> Void |
| + let attach: ComposerAttachHooks? // nil hides the "+" and paste/drop |
| + let onUnstage: (StagedUpload) -> Void |
| + let onCancelReply: () -> Void |
| + let onCancelEdit: () -> Void |
| + let onDraftChanged: (String) -> Void // typing beat + "/" host pre-warm |
| + let onEscapeFallback: () -> Bool // the container's deeper Escape layers |
| + // Hardware ↑ on an empty draft edits the latest editable message in THIS |
| + // bar's SCOPE — the channel feed, or the docked pane's thread. |
| + let onEditLatest: () -> Void |
| + // The container's command-panel slot, between the typeahead and the |
| + // banners (the main bar mounts the command-host webview here; a |
| + // commands-off bar passes an EmptyView). |
| + let panel: Panel |
| + |
| + // ---- bar-owned state ---------------------------------------------------- |
| + // The caret as a UTF-16 offset into `draft` — the typeahead needs to know |
| + // which token the caret sits in. |
| + @State private var caret = 0 |
| + @State private var nativeTypeahead: (token: ComposerToken, items: [TypeaheadItem])? |
| + // The "+" menu's sheets. |
| + @State private var pickerItem: PhotosPickerItem? |
| + @State private var showPhotoPicker = false |
| + @State private var showCamera = false |
| + @State private var showFilePicker = false |
| + |
| + var body: some View { |
| + VStack(spacing: 6) { |
| + if let ta = nativeTypeahead { |
| + typeaheadStrip(ta.items) |
| + } |
| + panel |
| + // A default command pushing back ("when should I remind you?") — |
| + // inline above the composer, occupying layout, never floating. |
| + if let routeNotice { |
| + Text(routeNotice) |
| + .font(.footnote) |
| + .foregroundStyle(.secondary) |
| + .frame(maxWidth: .infinity, alignment: .leading) |
| + .padding(.horizontal, 16) |
| + } |
| + if fixedReply == nil, let replyTo { |
| + banner("Replying to \(replyTo.name ?? "")", detail: replyTo.snippet, |
| + cancelLabel: "Cancel reply", onCancel: onCancelReply) |
| + } |
| + if editingID != nil { |
| + banner("Editing message", detail: nil, |
| + cancelLabel: "Cancel edit", onCancel: onCancelEdit) |
| + } |
| + if !staged.isEmpty { |
| + stagedChips |
| + } |
| + composerRow |
| + } |
| + .padding(.top, 4) |
| + } |
| + |
| + // The floating-glass composer: the field self-sizes |
| + // (GrowingTextView), and it shares one glass capsule with the send button. |
| + private var composerRow: some View { |
| + HStack(alignment: .bottom, spacing: 8) { |
| + // The container passes nil in a default-command thread (To-do) — |
| + // text-only surface, matching the web and Mac composers. |
| + if attach != nil { |
| + Menu { |
| + // Simulators (and some iPads) have no camera — the row |
| + // just doesn't exist there rather than failing on tap. |
| + if UIImagePickerController.isSourceTypeAvailable(.camera) { |
| + Button { showCamera = true } label: { |
| + Label("Camera", systemImage: "camera") |
| + } |
| + } |
| + Button { showPhotoPicker = true } label: { |
| + Label("Photo or Video", systemImage: "photo") |
| + } |
| + Button { showFilePicker = true } label: { |
| + Label("File", systemImage: "doc") |
| + } |
| + } label: { |
| + Image(systemName: "plus") |
| + .font(.system(size: 18, weight: .medium)) |
| + .frame(width: 36, height: 36) |
| + .floatingGlass(in: Circle()) |
| + } |
| + .buttonStyle(.plain) |
| + .accessibilityLabel("Attach") |
| + } |
| + |
| + HStack(alignment: .bottom, spacing: 6) { |
| + // sendOnReturn: false — touch Enter newlines, the send button |
| + // sends (the shouldSend rule). |
| + ComposerField(text: $draft, focused: $composerFocused, caret: $caret, |
| + placeholder: placeholder, |
| + sendOnReturn: false, |
| + formatAsYouType: formatAsYouType, |
| + controller: controller, |
| + onSend: onSend, |
| + onPasteImage: onPasteImage, |
| + onEditLast: onEditLatest, |
| + onEscape: { |
| + if nativeTypeahead != nil { |
| + nativeTypeahead = nil |
| + return true |
| + } |
| + // Deeper Escape layers belong to the |
| + // container (command panel, armed |
| + // edit/reply, the docked pane). |
| + return onEscapeFallback() |
| + }) |
| + .padding(.leading, 14) |
| + .padding(.vertical, 8) |
| + .onChange(of: draft) { _, text in |
| + refreshTypeahead() |
| + onDraftChanged(text) |
| + } |
| + .onChange(of: caret) { _, _ in refreshTypeahead() } |
| + // The thread's live command rows landing re-ranks an open |
| + // "/" menu — this stands in for the direct refresh the |
| + // container's command-host sink used to make when the |
| + // state all lived together. |
| + .onChange(of: hostRows) { _, _ in refreshTypeahead() } |
| + |
| + Button(action: onSend) { |
| + Image(systemName: "arrow.up.circle.fill") |
| + .font(.system(size: 28)) |
| + .foregroundStyle(canSend ? Color.accentColor : Color(.tertiaryLabel)) |
| + // 44pt target — a near-miss must send, not close the keyboard. |
| + .frame(width: 44, height: 40) |
| + .contentShape(Rectangle()) |
| + } |
| + .buttonStyle(.plain) |
| + .disabled(!canSend) |
| + .accessibilityLabel("Send") |
| + } |
| + .floatingGlass(in: RoundedRectangle(cornerRadius: 22)) |
| + } |
| + .padding(.horizontal, 10) |
| + .padding(.vertical, 8) |
| + .photosPicker(isPresented: $showPhotoPicker, selection: $pickerItem, matching: .any(of: [.images, .videos])) |
| + .onChange(of: pickerItem) { _, item in |
| + guard let item else { return } |
| + pickerItem = nil |
| + attach?.onPickerItem(item) |
| + } |
| + .fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.item]) { result in |
| + if case .success(let url) = result { attach?.onFile(url) } |
| + } |
| + // A fresh capture stages exactly like a pasted image — the upload |
| + // path's processImage downscales and strips EXIF/location before |
| + // encryption, same as every other photo source. |
| + .fullScreenCover(isPresented: $showCamera) { |
| + CameraCapture { data in attach?.onImageData(data, "photo.jpg") } |
| + .ignoresSafeArea() |
| + } |
| + // Drop-to-attach (iPad split view, iPhone app-to-app drags): images, |
| + // movies and files stage exactly like the pickers' choices. The system |
| + // drag badge is the whole affordance — no overlay (matching the Mac). |
| + .onDrop(of: attach != nil ? [.image, .movie, .fileURL] : [], |
| + isTargeted: nil) { providers in |
| + attach?.onDrop(providers) |
| + return true |
| + } |
| + } |
| + |
| + // A pasted image stages as an upload (the composer's "paste to upload"); |
| + // nil when the bar has no attachment sources, so a text paste there stays |
| + // a plain text paste. |
| + private var onPasteImage: ((Data) -> Void)? { |
| + guard let attach else { return nil } |
| + return { attach.onImageData($0, "pasted.jpg") } |
| + } |
| + |
| + // The container's send paths keep the same rule (NativeChatScreen.canSend) |
| + // — both read the same draft + staged, so they can't disagree. |
| + private var canSend: Bool { NativeChatScreen.canSend(draft: draft, staged: staged) } |
| + |
| + private func banner(_ title: String, detail: String?, cancelLabel: String, |
| + onCancel: @escaping () -> Void) -> some View { |
| HStack(spacing: 8) { |
| VStack(alignment: .leading, spacing: 1) { |
| Text(title).font(.system(size: 12, weight: .semibold)) |
| @@ -1555,6 +2235,7 @@ struct NativeChatScreen: View { |
| .contentShape(Rectangle()) |
| } |
| .buttonStyle(.plain) |
| + .accessibilityLabel(cancelLabel) |
| } |
| .padding(.leading, 12) |
| .padding(.trailing, 2) |
| @@ -1569,16 +2250,17 @@ struct NativeChatScreen: View { |
| let utf16 = min(max(caret, 0), draft.utf16.count) |
| let idx = String.Index(utf16Offset: utf16, in: draft) |
| let charCaret = draft.distance(from: draft.startIndex, to: idx) |
| - guard let token = composerToken(draft, caret: charCaret) else { |
| + // A commands-off bar never menus a "/" draft — the text sends |
| + // verbatim, matching the web pane composer (cfg.commands:false). |
| + guard let token = composerToken(draft, caret: charCaret), |
| + allowCommands || token.kind != .command else { |
| nativeTypeahead = nil |
| return |
| } |
| - // App-local verbs (/add in To-do) lead the strip, bot or no bot — |
| - // the web's commandCatalog(threadId). |
| - let items = typeaheadItems(token, roster: store.roster, |
| - myUserId: store.myUserId, hasBot: hasBot, |
| - appCommands: AppDefaultCommand.localCommands(appID: thread.appId), |
| - displayNames: store.displayLabelMap(), |
| + let items = typeaheadItems(token, roster: roster, |
| + myUserId: myUserId, hasBot: hasBot, |
| + appCommands: appCommands, |
| + displayNames: displayNames, |
| rows: hostRows) |
| nativeTypeahead = items.isEmpty ? nil : (token, items) |
| } |
| @@ -1624,13 +2306,13 @@ struct NativeChatScreen: View { |
| let end = max(start, min(charCaret, chars.count)) |
| let head = String(chars[0..<start]) |
| let span = String(chars[start..<end]) |
| - composerController.insert(item.insert, replacingUTF16: NSRange( |
| + controller.insert(item.insert, replacingUTF16: NSRange( |
| location: (head as NSString).length, length: (span as NSString).length)) |
| nativeTypeahead = nil |
| composerFocused = true |
| } |
| |
| - // ---- uploads ------------------------------------------------------------- |
| + // ---- staged chips ------------------------------------------------------ |
| |
| // Staged pictures read as a real preview — a thumbnail tray, WhatsApp/ |
| // Signal proportions. No filename on an image (a synthetic "pasted.jpg" |
| @@ -1673,7 +2355,7 @@ struct NativeChatScreen: View { |
| .overlay(alignment: .topTrailing) { |
| // Dark scrim keeps the ✕ legible over any photo, either theme. |
| Button { |
| - staged.removeAll { $0.id == s.id } |
| + onUnstage(s) |
| } label: { |
| Image(systemName: "xmark") |
| .font(.system(size: 10, weight: .bold)) |
| @@ -1697,7 +2379,7 @@ struct NativeChatScreen: View { |
| } |
| Text(s.name).font(.system(size: 12)).lineLimit(1) |
| Button { |
| - staged.removeAll { $0.id == s.id } |
| + onUnstage(s) |
| } label: { |
| Image(systemName: "xmark.circle.fill") |
| .font(.system(size: 12)) |
| @@ -1709,106 +2391,6 @@ struct NativeChatScreen: View { |
| .padding(.vertical, 4) |
| .background(Capsule().fill(.primary.opacity(0.06))) |
| } |
| - |
| - private func stagePickerItem(_ item: PhotosPickerItem) { |
| - let isVideo = item.supportedContentTypes.contains { $0.conforms(to: .movie) } |
| - let name = isVideo ? "video.mp4" : "photo.jpg" |
| - let entry = StagedUpload(name: name) |
| - staged.append(entry) |
| - let onProgress: @MainActor (Double) -> Void = { p in |
| - if let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].progress = p } |
| - } |
| - Task { |
| - guard let data = try? await item.loadTransferable(type: Data.self) else { |
| - staged.removeAll { $0.id == entry.id } |
| - return |
| - } |
| - // The composer-chip preview downscale is a full decode — detached, |
| - // like the upload-side processImage in ChatFeedModel. |
| - if !isVideo, |
| - let thumb = await Task.detached(operation: { processImage(data, maxDim: 288) }).value, |
| - let i = staged.firstIndex(where: { $0.id == entry.id }) { |
| - staged[i].preview = UIImage(data: thumb.bytes) |
| - } |
| - let att = isVideo |
| - ? await model.uploadVideo(data, name: name, mimeType: "video/mp4", onProgress: onProgress) |
| - : await model.uploadImage(data, name: name, onProgress: onProgress) |
| - if let att, let i = staged.firstIndex(where: { $0.id == entry.id }) { |
| - staged[i].attachment = att |
| - } else { |
| - staged.removeAll { $0.id == entry.id } |
| - } |
| - } |
| - } |
| - |
| - // Stage raw image bytes (paste / share-sheet handoff) as an upload. |
| - private func stageImageData(_ data: Data, name: String = "pasted.jpg") { |
| - let entry = StagedUpload(name: name) |
| - staged.append(entry) |
| - let onProgress: @MainActor (Double) -> Void = { p in |
| - if let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].progress = p } |
| - } |
| - Task { |
| - // The chip shows name-only for a beat while the preview downscale |
| - // (a full decode of the pasted image) runs detached. |
| - if let thumb = await Task.detached(operation: { processImage(data, maxDim: 288) }).value, |
| - let i = staged.firstIndex(where: { $0.id == entry.id }) { |
| - staged[i].preview = UIImage(data: thumb.bytes) |
| - } |
| - let att = await model.uploadImage(data, name: name, onProgress: onProgress) |
| - if let att, let i = staged.firstIndex(where: { $0.id == entry.id }) { |
| - staged[i].attachment = att |
| - } else { |
| - staged.removeAll { $0.id == entry.id } |
| - } |
| - } |
| - } |
| - |
| - private func stageVideoData(_ data: Data, name: String) { |
| - let entry = StagedUpload(name: name) |
| - staged.append(entry) |
| - let onProgress: @MainActor (Double) -> Void = { p in |
| - if let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].progress = p } |
| - } |
| - Task { |
| - let att = await model.uploadVideo(data, name: name, mimeType: "video/mp4", |
| - onProgress: onProgress) |
| - if let att, let i = staged.firstIndex(where: { $0.id == entry.id }) { |
| - staged[i].attachment = att |
| - } else { |
| - staged.removeAll { $0.id == entry.id } |
| - } |
| - } |
| - } |
| - |
| - private func stageFile(_ url: URL) { |
| - let name = url.lastPathComponent |
| - let entry = StagedUpload(name: name) |
| - staged.append(entry) |
| - let type = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType |
| - let onProgress: @MainActor (Double) -> Void = { p in |
| - if let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].progress = p } |
| - } |
| - Task { |
| - // Whole-file read detached — a picked file can be tens of MB, and |
| - // the security scope only needs to bracket the read itself. |
| - let data = await Task.detached { () -> Data? in |
| - guard url.startAccessingSecurityScopedResource() else { return nil } |
| - defer { url.stopAccessingSecurityScopedResource() } |
| - return try? Data(contentsOf: url) |
| - }.value |
| - guard let data else { |
| - staged.removeAll { $0.id == entry.id } |
| - return |
| - } |
| - let att = await model.uploadFile(data, name: name, mimeType: type, onProgress: onProgress) |
| - if let att, let i = staged.firstIndex(where: { $0.id == entry.id }) { |
| - staged[i].attachment = att |
| - } else { |
| - staged.removeAll { $0.id == entry.id } |
| - } |
| - } |
| - } |
| } |
| |
| // The system camera (UIImagePickerController — SwiftUI has no native capture |