eleven / ocho Public

ios: docked thread pane on iPad — reply threads dock beside the chat (#1470)

The thread-pane spec's iPad slice (§6 mirrored, §10.5–10.7 rules): in a
REGULAR horizontal size class the reply-thread view docks — a flush
right-hand column INSIDE the chat surface, Slack-style, with its own
composer — while compact widths keep today's lifted overlay
byte-for-byte (the kept !threadDocked path). threadDocked derives live
from the size class, so an iPad multitasking resize mid-thread
re-presents in place; the .onChange(of: sizeClass) handoff carries the
thread's composer state across (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),
only ever into an EMPTY bar — two live drafts leave the pane's parked in
its own state, back on screen when the width returns. An iPhone never
reaches regular (portrait-locked), so nothing changes there.

Part 1 — MessageComposerBar (the Mac's slice-4 boundary, phone-shaped):
NativeChatScreen's composer — typeahead strip, command-panel slot,
route notice, reply/edit banners, staged chips, the "+" menu with its
camera/photo/file sheets, the ComposerField row, send button, paste and
drop — extracts into one instantiable view. The bar owns only what is
composer-internal (caret, typeahead, the sheet flags); the draft, focus,
reply/edit staging, the staged uploads and the command host stay with
the container and arrive as bindings/values/closures. fixedReply +
allowCommands are the spec's two knobs; attachment sources arrive as a
ComposerAttachHooks value (nil = no "+", no paste/drop — the
default-command thread's text-only surface). StagedUpload hoists to file
scope so both types share it. Zero behaviour change on compact.

Part 2 — the fork: body becomes HStack { feedAndComposer; Divider;
threadPane } — the feed, its overlay and the composer inset move onto
the feed column so the channel bar stops at the divider. threadPane
mounts ReplyThreadView(docked: true) over the theme's --panel
background as a "Reply thread" a11y container, plus a second bar over
its own state set (paneDraft/paneFocused/paneStaged/paneEditingID):
fixedReply derives the root FRESH at send (ChatFeedModel.threadReplyRef),
allowCommands:false sends "/" verbatim, the edit banner renders (§10.5).
Pane-bubble Reply / Reply in thread / Edit route to the pane bar
(paneMessageActions); the staging pipeline gains a ComposerTarget so
both bars share it. Focus goes into the pane on open and back to the
channel bar on close — the latter only if the pane's field HAD it, so
closing never pops a keyboard that was down. The channel bar stays on
the channel while docked: no stageThreadReply, cancel just cancels,
post-send re-stage and cancelEdit's hand-back are gated to the overlay.

Escape (iPad hardware keyboards): ComposerField gains onEscape, and each
bar peels its OWN innermost layer — the channel bar its typeahead, the
command panel, an armed edit, an armed reply (which under the overlay
closes the thread, the banner ✕'s contract), never the docked pane; the
pane bar its typeahead, an armed edit, then a no-op while there's typed
or staged content (keyboard Escape is never destructive — the ✕ closes
with content), and only an EMPTY pane composer closes the thread.

A11y: the "+" menu and the banner ✕ get labels (Attach / Cancel reply /
Cancel edit) — pre-existing gaps, fixed here since the bar now mounts
twice. Named deltas: the field's paste/↑/Escape hooks now refresh in
updateUIView (a default-command thread that gains a bot picks up paste-
to-stage without a remount); a default-command thread no longer offers
Paste for an image on the clipboard (it was a no-op); the unused
sendQueued state is gone.

Review fixes (four findings + two calls): (1) pane state parked by a
compact-width resize now dies with the thread — openThread resets it on
ANY root switch and closeThread in BOTH presentations, so a stale
paneEditingID can never resurface pointing at a message from another
thread. (2) The size-class handoff refuses to move a bar whose upload is
still in flight (the task writes back through the binding it started
with; a moved chip froze on the other bar and wedged its send) — the
whole draft stays parked with that bar, text included, so one message
never splits across two; the pane-edit move now carries the pane's
staged rows too instead of dropping them. (3) A tally/quote open focuses
the pane only with a hardware keyboard attached (GCKeyboard.coalesced) —
on a touch-only iPad the software keyboard no longer rises over the
replies just opened; the reply verbs still focus. (4) The pane width is
the web's min(340, 40%) of the measured chat surface, not a hard 340.
Decided: under the overlay a hardware Escape with typed/staged content
is now a no-op too (the banner ✕ closes with content; only an empty bar
peels the root reply) — the pane's "never destructive" rule applied
symmetrically, since a draft that silently stops being a thread reply is
the same loss in a different coat.

Rebased over thread-only replies (#1475), which landed first and rewrote
the same screen: the pane's send and its fixedReply now carry the
thread flag (ReplyRef.inThread — a pane reply lives in the thread, like
every send from the overlay), pane bubbles route through
threadMessageActions so they inherit its dropped "Reply in thread" and
override only the two verbs the pane owns, the docked pane takes
jumpTarget/jumpTick so jumpIntoThread lands on the asked-for reply there
too, and the channel bar's ↑-edit uses that PR's surface-aware pool
(the overlay thread's items, else feedMessages — never the open pane's,
which edits within its own thread). #1479's quote-card thread link stays
suppressed inside the pane: it rides ReplyThreadView's shared
panelContent, which both presentations mount.

No changelog entry — native holds for the release mint.


Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 79f0069e62c4d590958df9596587ed67a780be41 parent c2155a8
2 files changed, +941 −341
  • ios/LChat/Views/ComposerField.swift +18 −0
  • ios/LChat/Views/NativeChatScreen.swift +923 −341
diff --git a/ios/LChat/Views/ComposerField.swift b/ios/LChat/Views/ComposerField.swift
index 5d56d45..02a2625 100644
--- a/ios/LChat/Views/ComposerField.swift
+++ b/ios/LChat/Views/ComposerField.swift
@@ -42,6 +42,10 @@ struct ComposerField: UIViewRepresentable {
var onPasteImage: ((Data) -> Void)? = nil
// Hardware-keyboard ↑ with an empty draft: edit your last message.
var onEditLast: (() -> Void)? = nil
+ // Hardware-keyboard Escape (iPad): the composer's peel ladder — typeahead,
+ // command panel, an armed edit/reply, the docked thread pane. Returns
+ // whether the press was consumed; unconsumed falls through to UIKit.
+ var onEscape: (() -> Bool)? = nil
func makeCoordinator() -> Coordinator { Coordinator(self) }
@@ -49,6 +53,7 @@ struct ComposerField: UIViewRepresentable {
let tv = GrowingTextView()
tv.onPasteImage = onPasteImage
tv.onEditLast = onEditLast
+ tv.onEscape = onEscape
tv.delegate = context.coordinator
tv.font = tv.baseBodyFont
// Live-track the system Text Size (Dynamic Type) — preferredFont only
@@ -71,6 +76,12 @@ struct ComposerField: UIViewRepresentable {
context.coordinator.parent = self
controller?.view = tv
tv.formatAsYouType = formatAsYouType
+ // The key/paste hooks follow the latest values — a bar whose
+ // attachment sources change (a bot joining flips a default-command
+ // thread) must not keep the closures it was made with.
+ tv.onPasteImage = onPasteImage
+ tv.onEditLast = onEditLast
+ tv.onEscape = onEscape
// A plain string compare — user edits round-trip exactly, so only
// EXTERNAL sets (edit-load, clear after send, pushback) land here,
// and those are literal plain text by contract.
@@ -212,6 +223,8 @@ final class GrowingTextView: UITextView {
var onPasteImage: ((Data) -> Void)?
// Hardware-keyboard ↑ with an empty draft (the desktop edit convention).
var onEditLast: (() -> Void)?
+ // Hardware-keyboard Escape — the container's peel ladder; true = consumed.
+ var onEscape: (() -> Bool)?
// Format-as-you-type bookkeeping (see Coordinator + maybeApplyInlineRule).
var pendingRuleCheck = false
var editCounter: UInt64 = 0
@@ -465,6 +478,11 @@ final class GrowingTextView: UITextView {
onEditLast()
return
}
+ // Escape peels the composer's innermost layer (typeahead → command
+ // panel → armed edit/reply → …); an unconsumed press stays UIKit's.
+ if presses.first?.key?.keyCode == .keyboardEscape, let onEscape, onEscape() {
+ return
+ }
super.pressesBegan(presses, with: event)
}
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
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