eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import LChatCore
2import SwiftUI
3import AVKit
4import GameController
5import PhotosUI
6import UniformTypeIdentifiers
7
8// The iOS native feed (docs/native-chat.md, iOS adoption): the same shared
9// pieces the Mac takeover renders — ChatFeedModel (parked via
10// NativeFeedModelCache), MessageBubble and friends — in a phone-shaped
11// container. THE chat surface, full stop: plain chats mount it directly
12// (ThreadDestination) and app-thread Chat views mount it through
13// SchemaAppScreen/AppThreadScreen — chat never renders in a webview.
14//
15// Phone-appropriate divergences from the Mac container: actions ride the
16// long-press context menu MessageBubble already provides on iOS (no hover, no
17// NSMenu); photos open a full-screen viewer; encrypted videos play in a
18// full-screen native player (VideoViewer — an AVPlayerViewController; the
19// macOS crash was in the Mac overlay, and SwiftUI's VideoPlayer couldn't do
20// the layered dismiss); attachments come from PhotosPicker + the document
21// picker. Not yet
22// here: Show Times, file-chip export, scroll-back wave.
23struct NativeChatScreen: View {
24 let instance: SavedInstance
25 let thread: ChatThread
26 // Deliberately NOT @ObservedObject: the hitch log showed every ThreadStore
27 // publish (mark-reads, refreshes, any bot activity) re-rendering the whole
28 // feed — 34-88ms stalls mid-scroll. This screen only needs point-in-time
29 // reads (roster for the typeahead, thread state for the ⋮ menu) and calls
30 // methods; the feed itself renders from its own ChatFeedModel.
31 let store: ThreadStore
32 // The app posts into this chat, the member doesn't (the manifest's
33 // chatReadOnly — Admin's update stream): no composer at all, the web's
34 // .chatapp-root.readonly rule.
35 let readOnlyChat: Bool
36 // Non-nil = this is an app thread's Chat view with an Interface to switch
37 // to: the ⋮ menu leads with the Chat ↔ Interface picker rows (the parent
38 // AppThreadScreen/SchemaAppScreen owns the mode).
39 let appView: Binding<String>?
40 @ObservedObject private var model: ChatFeedModel
41 // The navigation path this screen sits on — for surfaces that hop
42 // threads (a profile card's "Message them", like ElevenChatScreen's
43 // "Say hi" chip).
44 @Binding private var path: [Route]
45
46 init(instance: SavedInstance, thread: ChatThread, store: ThreadStore,
47 path: Binding<[Route]>,
48 readOnlyChat: Bool = false, appView: Binding<String>? = nil) {
49 self.instance = instance
50 self.thread = thread
51 self.store = store
52 self._path = path
53 self.readOnlyChat = readOnlyChat
54 self.appView = appView
55 self.model = NativeFeedModelCache.shared.model(for: instance, thread: thread,
56 myUserId: store.myUserId)
57 // Opened from a notification tap: capture the read watermark NOW —
58 // init runs before ThreadDestination's markRead bumps it — so the
59 // settle pass can land the FIRST UNREAD at the top of the view
60 // instead of pinning the bottom.
61 if PushManager.forceScrollThreadID == thread.id {
62 PushManager.forceScrollThreadID = nil
63 let seen = ReadState.lastSeen(instance: instance.id, thread: thread.id)
64 if seen > 0 { _unreadCutoff = State(initialValue: seen) }
65 }
66 // Reopen on the view this group was last showing (chat or a shared app).
67 _groupAppID = State(initialValue: GroupAppMemory.selection[thread.id])
68 // The app-host projection queries (docs/native-apps.md phase 5): the
69 // model asks the thread's command host to project app events it has
70 // no built-in projection for. Lazy — the host webview only spins up
71 // when a thread actually contains such an event.
72 let projInstance = instance, projThread = thread
73 let projUserId = store.myUserId, projIK = store.instanceKey()
74 model.projector = { eventJSON, group in
75 let host = CommandHostCache.shared.host(for: projInstance, thread: projThread,
76 myUserId: projUserId, instanceKeyB64: projIK)
77 return await host.projectEvent(eventJSON, group: group)
78 }
79 }
80
81 // The pre-tap last-seen time; non-nil = this open should anchor unreads.
82 @State private var unreadCutoff: Int64?
83
84 // A search hit's "open the chat ON this message" request, set by the
85 // chat list before pushing this screen.
86 static var pendingJump: (thread: String, message: Int64)?
87 // Shared files (share-sheet handoff) to stage into this chat's composer.
88 static var pendingShare: (thread: String, files: [URL])?
89
90 @State private var draft = ""
91 // The per-thread command host (LChatCore CommandHost — the web command
92 // engine, headless): slash commands the native floor doesn't own forward
93 // here; replies come back as inline notices, panels as the webview above
94 // the composer. Created lazily on the first "/" typed.
95 @State private var commandHost: CommandHost?
96 // This thread's commandItems() rows (the host reports them once ready) —
97 // they feed the "/" typeahead and the forward-or-send routing decision.
98 @State private var hostRows: [CommandRow] = []
99 @State private var panelVisible = false
100 @State private var panelHeight: CGFloat = 240
101 @State private var panelAnimates = true // the open post's animate flag, reused on close
102 @State private var composerFocused = false // ComposerField's own focus binding (UIKit-backed)
103 // A default command's push-back line, generation-tokened so a re-shown
104 // identical prompt is never wiped by an earlier showing's clear timer;
105 // dcRouting keeps one async routing in flight (no double-sent reminders).
106 @State private var routeNotice: String?
107 @State private var routeNoticeGen = 0
108 @State private var dcRouting = false
109 @State private var replyTo: ReplyRef?
110 @State private var editingID: Int64?
111 // Programmatic composer edits (typeahead splices) act on the field via
112 // the controller; the wire markdown is read from it at send (#890).
113 @State private var composerController = ComposerFieldController()
114 @AppStorage("formatAsYouType") private var formatAsYouType = true
115 // The open reply-thread view's root id (nil = closed).
116 @State private var threadRootID: Int64?
117 // A jump whose target lives only in its thread (search hit, media-sheet
118 // jump, a notification's newest unread): the view's row to land on + its
119 // wave — the feed's danceID/danceTick twins for the thread surface
120 // (jumpIntoThread).
121 @State private var threadJump: Int64?
122 @State private var threadJumpTick = 0
123 @State private var threadDanceID: Int64?
124 @State private var threadDanceTick = 0
125 // The notification-tap thread-reply check ran for this open (one-shot,
126 // like the unread anchor it complements — landOnUnreadThreadReply).
127 @State private var unreadThreadChecked = false
128 // The feed's inline status line (FeedNoteBar, under the header): a jump
129 // that has to page history back for its target says so, and says why when
130 // it can't reach it. `revealing` is the target of the walk in flight —
131 // one at a time, so a second tap can't stack walks.
132 @State private var feedNote: String?
133 @State private var feedNoteGen = 0
134 @State private var revealing: Int64?
135 // A jump target was consumed for this open (search hit, media sheet, or a
136 // notification tap carrying its message id) — the guess-work fallback
137 // stands down when the tap said exactly where to land.
138 @State private var jumpTargeted = false
139 // Where the open thread presents (thread-pane spec §6, the iPad slice):
140 // in a REGULAR horizontal size class it docks — a flush right-hand pane
141 // beside the feed with its own composer, Slack-style; compact keeps the
142 // lifted overlay byte-for-byte. Read live, so an iPad multitasking
143 // resize mid-thread re-presents (the .onChange(of: sizeClass) handoff
144 // carries the draft across). An iPhone can't reach regular: the app is
145 // portrait-locked there.
146 @Environment(\.horizontalSizeClass) private var sizeClass
147 private var threadDocked: Bool { sizeClass == .regular }
148 // The docked pane's OWN composer state — a second container state set,
149 // exactly what the MessageComposerBar extraction was for. No replyTo
150 // twin: the pane's reply target is fixed (the thread root, derived fresh
151 // at send), so nothing user-armed lives here.
152 @State private var paneDraft = ""
153 @State private var paneFocused = false
154 @State private var paneController = ComposerFieldController()
155 @State private var paneStaged: [StagedUpload] = []
156 @State private var paneEditingID: Int64?
157 // Attachments staged on the draft (StagedUpload is file-scope, shared
158 // with MessageComposerBar, which renders the chips; the encrypt+upload
159 // pipeline that fills them stays here — the share-sheet handoff feeds it).
160 @State private var staged: [StagedUpload] = []
161 @State private var quickLookItem: QuickLookItem?
162 @State private var playingVideo: PlayingVideo?
163 // A downloaded file staged on disk for Quick Look, plus the temp directory
164 // to burn once the preview closes (decrypted plaintext never lingers).
165 @State private var filePreview: FilePreviewItem?
166 @State private var previewCleanupDir: URL?
167
168 struct FilePreviewItem: Identifiable {
169 let id = UUID()
170 let url: URL
171 }
172
173 struct PlayingVideo: Identifiable {
174 let id: String
175 let player: AVPlayer
176 let loader: MemoryVideoAsset
177 let sourceFrame: CGRect // the video card's window frame — where the zoom starts
178 }
179
180 // Belt-and-braces: any thread with 3+ members shows sender names,
181 // whatever kind it reports.
182 private var isGroup: Bool { thread.kind == "group" || thread.members.count > 2 }
183 private var hasBot: Bool {
184 let members = Set(thread.members)
185 return store.roster.contains { members.contains($0.user_id) && $0.isAgent }
186 }
187
188 // The app-thread's default command (To-do: plain text adds a to-do) — the
189 // web's defaultCommandFor, so both composers route identically. Nil for a
190 // normal chat, an app with no default, or once a bot member is present.
191 private var defaultCommand: AppDefaultCommand.Spec? {
192 AppDefaultCommand.spec(appID: thread.appId, hasBot: hasBot)
193 }
194
195 // "2 members, 1 bot, 2 AI agents · 1 online" — the web group header's
196 // subtitle; the online count is people only (bots' always-on sockets would
197 // pin every room at its bot count).
198 private var memberStats: String? {
199 guard isGroup else { return nil }
200 var bots = 0, agents = 0
201 for id in thread.members {
202 guard let e = store.roster.first(where: { $0.user_id == id }), e.isAgent else { continue }
203 if e.isAI { agents += 1 } else { bots += 1 }
204 }
205 let label = memberCountLabel(people: thread.members.count - bots - agents, bots: bots, agents: agents)
206 let online = store.onlinePeopleCount(of: thread)
207 return online > 0 ? label + " · \(online) online" : label
208 }
209
210 // The DM partner's live status — read from the store (kept current by
211 // presence frames), falling back to the snapshot this screen was built with.
212 private var dmPresence: String? {
213 store.threads.first { $0.id == thread.id }?.presence ?? thread.presence
214 }
215
216 @Environment(\.dismiss) private var dismiss
217 @Environment(\.scenePhase) private var scenePhase
218 @Environment(\.colorScheme) private var scheme
219 @AppStorage("nativeTheme") private var themeName = "classic"
220 // Dynamic Type: read at the system body size (17 at the default setting —
221 // also the web's touch size), so Settings → Text Size scales the feed.
222 @ScaledMetric(relativeTo: .body) private var chatFontSize: CGFloat = 17
223 @State private var avatarPop = false
224 // The DM partner's profile card (rename them for yourself there — #577).
225 @State private var headerProfile: HeaderProfileTarget?
226 struct HeaderProfileTarget: Identifiable { let id: String }
227 // A message's Report action (#578): the reported sender + the message as
228 // evidence, feeding the shared ReportSheet.
229 @State private var reportTarget: ReportTarget?
230 struct ReportTarget: Identifiable {
231 let uid: String
232 let evidence: ReportEvidence
233 var id: Int64 { evidence.sid }
234 }
235 // A message's "Convert to thread" action: the chain to move, held while the
236 // confirm is up. The ids are snapshotted at the tap, so what moves is what
237 // the person was looking at — not a set re-derived after the fact.
238 @State private var convertTarget: ConvertTarget?
239 // Set in the SAME update as convertTarget: the alert's message closure is
240 // evaluated before any onChange could seed it, and again as it dismisses
241 // (target already nil), so the count can't be read through the optional.
242 @State private var convertCount = 0
243 struct ConvertTarget: Identifiable {
244 let root: Int64
245 let ids: [Int64]
246 var id: Int64 { root }
247 }
248 // The header/composer overlay heights (probes above) — the feed extends
249 // under both and rests content between them via content insets.
250 @State private var headerHeight: CGFloat = 0
251 @State private var composerHeight: CGFloat = 0
252 // The chat surface's width (probe on the feed/pane HStack) — the docked
253 // pane clamps to it: the web's min(340px, 40%), so a Split View 2/3 or a
254 // Stage Manager window (~640pt) still leaves the feed the larger share.
255 @State private var chatWidth: CGFloat = 0
256 private var paneWidth: CGFloat {
257 chatWidth > 0 ? min(340, chatWidth * 0.4) : 340
258 }
259 // Whether a hardware keyboard is attached (GameController's coalesced
260 // keyboard is the standard signal). Gates the focus-on-open courtesy: a
261 // keyboard-driven reader wants the caret to move into the pane; a
262 // touch-only iPad would get the software keyboard raised over the
263 // replies just opened — this screen's own rule against exactly that.
264 private var hardwareKeyboardAttached: Bool { GCKeyboard.coalesced != nil }
265
266 @State private var showMembers = false
267 @State private var showRename = false
268 @State private var showGroupApps = false
269 // The per-thread shared-content inventory (spec: media-links-docs) —
270 // DMs and groups alike, hidden on app threads (they have no chat feed
271 // of shared content to inventory).
272 @State private var showMediaView = false
273 // The Threads list — this chat's threads, freshest first, so one you
274 // missed is findable rather than lost behind a notification.
275 @State private var showThreadList = false
276 // End conversation (#578): the destructive confirm, and the closed
277 // banner's in-flight / refusal state (inline copy, never an alert).
278 @State private var showEndConfirm = false
279 @State private var reopenBusy = false
280 @State private var reopenNote: String?
281
282 // The group's chosen view for this session: nil = the conversation, an app
283 // id = that shared app's interface (GroupAppMemory keeps it across pushes,
284 // like the web's groupAppView).
285 @State private var groupAppID: String?
286
287 // The apps this group has added (the model's groupapps fold, already
288 // catalog-filtered). Empty for anything that isn't a group.
289 private var groupApps: [GroupApps.Item] { model.groupApps }
290
291 // The selection, validated against the live fold — an app someone removed
292 // while we were looking at it falls back to the conversation.
293 private var activeGroupAppID: String? {
294 guard let id = groupAppID, groupApps.contains(where: { $0.id == id }) else { return nil }
295 return id
296 }
297
298 // The header menu's view picker ("" = chat) — writes through to the
299 // session memory so reopening the chat restores the same view.
300 private var groupViewSelection: Binding<String> {
301 Binding(
302 get: { activeGroupAppID ?? "" },
303 set: { picked in
304 groupAppID = picked.isEmpty ? nil : picked
305 GroupAppMemory.selection[thread.id] = picked.isEmpty ? nil : picked
306 })
307 }
308
309 private var isPinned: Bool {
310 store.threads.first { $0.id == thread.id }?.pinned ?? thread.pinned
311 }
312 private var isUnread: Bool {
313 store.threads.first { $0.id == thread.id }?.unread ?? false
314 }
315 private var isMuted: Bool {
316 store.threads.first { $0.id == thread.id }?.isMuted ?? thread.isMuted
317 }
318
319 // The DM partner's uid — the header opens their profile card (rename them
320 // for yourself there, #577), like tapping the web header title.
321 private var dmPartner: String? {
322 guard thread.kind == "dm" else { return nil }
323 return thread.members.first { $0 != store.myUserId }
324 }
325
326 // The Messages-style header: big avatar level with the
327 // bar buttons, the name in a glass pill tucked under it.
328 private var chatHeader: some View {
329 VStack(spacing: -5) {
330 // Presence on mobile is the avatar ring, not a status line: online
331 // wears a green halo; away/offline/unknown look like any other
332 // header. (Web + Mac keep the dot+word line — they have the room.)
333 // zIndex(1) keeps the ring above the glass capsule below.
334 // Decorative here — the title/pill below carries the name to
335 // VoiceOver, so the avatar needn't be its own element.
336 Avatar(name: thread.title, size: 54, source: thread.avatar,
337 onlineRing: thread.kind == "dm" && dmPresence == "online")
338 .scaleEffect(avatarPop ? 1 : 0.7)
339 .zIndex(1)
340 .accessibilityHidden(true)
341 // In a group the pill is a fast path to the members list — the
342 // counts *read* as tappable, so make them so (View Members also
343 // stays in the ⋯ menu as the labelled/fallback path). The pill is
344 // already ~44pt tall, so no hit-area hacks, and it becomes its own
345 // actionable a11y element instead of a flattened, action-less one.
346 // (Groups own their tap here; the DM whole-header tap below never
347 // fires for them, since memberStats and dmPartner are exclusive.)
348 if memberStats != nil {
349 Button { showMembers = true } label: { headerPill }
350 .buttonStyle(.plain)
351 .accessibilityElement()
352 .accessibilityLabel(headerAccessibilityLabel)
353 .accessibilityHint("View members")
354 .accessibilityAddTraits(.isButton)
355 } else {
356 headerPill
357 }
358 }
359 .frame(maxWidth: .infinity)
360 .padding(.bottom, 6)
361 .offset(y: -52) // rise level with the back button / ⋯ menu
362 // DMs: the whole header taps through to the partner's profile (#577).
363 // Groups handle their own tap on the pill above, so keep them separate
364 // a11y elements (.contain) rather than flattening the pill button away.
365 .accessibilityElement(children: dmPartner != nil ? .combine : .contain)
366 .contentShape(Rectangle())
367 .onTapGesture { if let uid = dmPartner { headerProfile = HeaderProfileTarget(id: uid) } }
368 .accessibilityAddTraits(dmPartner != nil ? .isButton : [])
369 .accessibilityHint(dmPartner != nil ? "Shows their profile" : "")
370 }
371
372 // The name + optional member-stats capsule; extracted so the group case can
373 // wrap it in a Button without duplicating the layout.
374 private var headerPill: some View {
375 VStack(spacing: 0) {
376 Text(thread.title)
377 .font(.headline)
378 .lineLimit(1)
379 .foregroundStyle(.primary)
380 if let stats = memberStats {
381 Text(stats)
382 .font(.system(size: 11))
383 .foregroundStyle(.secondary)
384 .lineLimit(1)
385 }
386 }
387 .padding(.horizontal, 12)
388 .padding(.vertical, 4)
389 // Layering step 1 (2026-07-21): liquid glass back on the pill — the
390 // suspect being tested is the lens's per-frame backdrop sampling
391 // over a scrolling feed.
392 .floatingGlass(in: Capsule())
393 }
394
395 // "Standup, 5 members, 2 agents, 3 online" — the pill's counts spoken
396 // cleanly (" · " reads awkwardly); the "View members" action rides the hint.
397 private var headerAccessibilityLabel: String {
398 guard let stats = memberStats else { return thread.title }
399 return thread.title + ", " + stats.replacingOccurrences(of: " · ", with: ", ")
400 }
401
402 // A group's shared-app surface: schema-described apps (To-do, Reminders)
403 // render the shared instance NATIVE, folded from the group's log; the
404 // rest keep the webview embed.
405 @ViewBuilder private func groupAppSurface(_ appID: String) -> some View {
406 if AppSchemaRegistry.hasSchema(for: appID), !thread.threadKeyB64.isEmpty,
407 let schemaStore = SchemaAppStoreCache.shared.store(
408 for: instance, thread: thread, myUserId: store.myUserId, appId: appID) {
409 SchemaAppView(store: schemaStore)
410 .id(thread.id + "|" + thread.threadKeyB64 + "|" + appID + "|schema")
411 } else {
412 GroupAppPane(instance: instance, thread: thread, myUserId: store.myUserId,
413 instanceKeyB64: store.instanceKey(), app: appID)
414 }
415 }
416
417 var body: some View {
418 Group {
419 // A picked shared app takes the feed's place (iOS has no split —
420 // like the web on a narrow layout); Chat brings the feed back.
421 if let appID = activeGroupAppID {
422 groupAppSurface(appID)
423 } else {
424 HStack(spacing: 0) {
425 feedAndComposer
426 // The docked thread pane (thread-pane spec §6): a flush
427 // right column INSIDE the chat surface, beside the feed —
428 // never over it. Regular width only; the lifted overlay
429 // is the kept !threadDocked path inside feedAndComposer.
430 if let rootID = threadRootID, threadDocked {
431 Divider()
432 threadPane(rootID)
433 .transition(.move(edge: .trailing).combined(with: .opacity))
434 }
435 }
436 // Measured for the pane's width clamp (the web's min(340px,
437 // 40%)) — the feed column flexes, so this is the chat
438 // surface's full width whether or not the pane is up; the
439 // probe never shapes layout (the header/composer pattern).
440 .background(GeometryReader { g in
441 Color.clear
442 .onAppear { chatWidth = g.size.width }
443 .onChange(of: g.size.width) { _, w in chatWidth = w }
444 })
445 }
446 }
447 .navigationTitle("")
448 .navigationBarTitleDisplayMode(.inline)
449 .toolbarBackground(.hidden, for: .navigationBar)
450 .safeAreaInset(edge: .top, spacing: 0) {
451 VStack(spacing: 0) {
452 chatHeader
453 // A DM partner renamed themself past my name for them
454 // (#577): the keep/adopt choice, inline under the header.
455 RenameBanner(store: store, thread: thread)
456 // The feed's inline status line — a jump that has to page
457 // history back for its target, and why when it can't
458 // reach it. Inside the measured stack on purpose: the
459 // feed's rest inset grows with it, so the note takes real
460 // layout space instead of floating over the transcript.
461 FeedNoteBar(feedNote)
462 }
463 // Measured for the feed's rest inset (the feed extends under
464 // the header; the probe never shapes layout).
465 .background(GeometryReader { g in
466 Color.clear
467 .onAppear { headerHeight = g.size.height }
468 .onChange(of: g.size.height) { _, h in headerHeight = h }
469 })
470 }
471 .sheet(item: $headerProfile) { target in
472 // Message them hops to (or creates) their DM — hidden when
473 // this chat already IS their DM (the web card's gate).
474 MemberProfileSheet(store: store, uid: target.id,
475 onOpenThread: dmPartner == target.id ? nil : { id in
476 headerProfile = nil
477 path.append(.thread(id))
478 }) { e in
479 Avatar(name: store.displayName(for: e), size: 72,
480 source: store.avatarSource(for: e))
481 }
482 .presentationDetents([.medium])
483 }
484 // Report a message to the admins (#578) — the shared sheet, with
485 // the message as evidence and this thread's client-side name.
486 .sheet(item: $reportTarget) { target in
487 ReportSheet(store: store, uid: target.uid, threadID: thread.id,
488 threadName: thread.title, evidence: target.evidence)
489 .presentationDetents([.medium, .large])
490 }
491 // "Convert to thread" confirms, because it reorganises the chat for
492 // EVERYONE and, in v1, can't be undone. As its own modifier: this
493 // body is a long chain, and the alert inline pushed the type-checker
494 // over its budget.
495 .modifier(ConvertToThreadConfirm(target: $convertTarget, count: convertCount) { root, ids in
496 // A conversion that can't reach the server has to SAY so:
497 // nothing about a failed one is visible — the chain stays
498 // where it was, exactly as if nobody had converted anything —
499 // so after confirming a one-way act the reader would be left
500 // believing it happened. The inline note bar, never a toast.
501 if !model.convertToThread(root: root, ids: ids) {
502 showFeedNote("Those replies didn't move — you're offline.")
503 }
504 })
505 // An iPad multitasking resize can flip the size class with a
506 // thread open — the presentation follows live (threadDocked is
507 // derived), and this hands the thread's composer state across so
508 // nothing typed is lost: overlay → dock moves the root-staged
509 // main reply into the pane; dock → overlay folds the pane's draft
510 // (or in-flight edit) back onto the main bar and re-stages the
511 // root reply, the overlay's contract. A draft is only ever moved
512 // into an EMPTY bar, and never while one of its uploads is still
513 // in flight — the upload task writes its progress/blob id back
514 // through the binding it was started with, so a chip moved
515 // mid-upload would freeze on the other bar and wedge its send;
516 // a mid-upload bar keeps its WHOLE draft (text included, so one
517 // message never splits across two bars). Either refusal leaves
518 // the pane's state parked, back on screen when the width
519 // returns — the park survives a RESIZE only; a close or a root
520 // switch resets it (openThread/closeThread).
521 .onChange(of: sizeClass) { _, _ in
522 guard let rootID = threadRootID else { return }
523 if threadDocked {
524 if replyTo?.id == rootID {
525 replyTo = nil
526 if editingID == nil, !paneHasContent, hasContent, !uploading(staged) {
527 paneDraft = draft
528 paneStaged = staged
529 draft = ""
530 staged = []
531 }
532 }
533 if composerFocused {
534 composerFocused = false
535 paneFocused = true
536 }
537 } else {
538 if let id = paneEditingID {
539 if editingID == nil, !hasContent, !uploading(paneStaged) {
540 replyTo = nil
541 editingID = id
542 draft = paneDraft
543 staged = paneStaged
544 resetPaneComposer()
545 }
546 } else if paneHasContent, !hasContent, !uploading(paneStaged) {
547 draft = paneDraft
548 staged = paneStaged
549 resetPaneComposer()
550 }
551 if editingID == nil { stageThreadReply() }
552 if paneFocused {
553 paneFocused = false
554 composerFocused = true
555 }
556 }
557 }
558 // Coming back to the app with a thread pane open: the caret belongs
559 // to the CHANNEL's composer, not the pane's (Paul, 2026-08-29) —
560 // you return to a conversation, not to the reply you were parked
561 // in. Only ever a HANDOFF, never a claim: a keyboard that was up
562 // moves over, a keyboard that was down stays down (closeThread's
563 // rule). Deliberate moves INTO the pane — opening a thread, a
564 // pane bubble's Reply, a notification landing — are untouched.
565 .onChange(of: scenePhase) { _, phase in
566 guard phase == .active, threadDocked, threadRootID != nil, paneFocused else { return }
567 paneFocused = false
568 composerFocused = true
569 }
570 .toolbar {
571 ToolbarItem(placement: .primaryAction) {
572 Menu {
573 // An app thread's Chat ↔ Interface switch — dropdown
574 // picker rows, never bar tabs (the parent screen owns
575 // the mode; picking Interface swaps this screen out).
576 if let appView {
577 AppViewPicker(store: store, thread: thread, view: appView)
578 }
579 // The group view picker — the same shape, grown to
580 // list every app this group has added.
581 if !groupApps.isEmpty {
582 Picker("View", selection: groupViewSelection) {
583 Label("Chat", systemImage: "bubble.left").tag("")
584 ForEach(groupApps) { app in
585 // Every row a Label with an SF-symbol
586 // icon: iOS menus align Labels on one
587 // grid but silently DROP non-image icons
588 // (the emoji glyphs never rendered).
589 Label(model.registry.title(app.id),
590 systemImage: AppIdentity.symbol(for: app.id) ?? "square.grid.2x2")
591 .tag(app.id)
592 }
593 }
594 .pickerStyle(.inline)
595 }
596 // …then this chat's threads, listed. A view switch, so
597 // it rides the kebab with the pickers above it and
598 // ahead of the management verbs — the order the web's
599 // ▤ menu uses. Present exactly while the chat HAS a
600 // thread, so one someone else starts mid-conversation
601 // surfaces the row (the model republishes) and a chat
602 // where nobody has ever used "Reply in thread" is
603 // exactly as it was.
604 if !model.threadListRows.isEmpty {
605 Button {
606 showThreadList = true
607 } label: {
608 Label("Threads", systemImage: "bubble.left.and.bubble.right")
609 }
610 }
611 // Group management (Mac parity: View Members / Rename).
612 if isGroup {
613 Button {
614 showMembers = true
615 } label: {
616 Label("View Members", systemImage: "person.2")
617 }
618 Button {
619 showRename = true
620 } label: {
621 Label("Rename Group", systemImage: "pencil")
622 }
623 Button {
624 showGroupApps = true
625 } label: {
626 Label("Manage Apps", systemImage: "square.grid.2x2")
627 }
628 }
629 // Media, Links & Docs — NOT group-gated (DMs too);
630 // app threads have no shared-content feed, so it
631 // hides there (the web's chatAppFor gate).
632 if appView == nil, !thread.isApp {
633 Button {
634 showMediaView = true
635 } label: {
636 Label("Media, Links & Docs", systemImage: "photo.on.rectangle")
637 }
638 }
639 Button {
640 store.setPinned(thread, !isPinned)
641 } label: {
642 Label(isPinned ? "Unpin chat" : "Pin chat",
643 systemImage: isPinned ? "pin.slash" : "pin")
644 }
645 Button {
646 store.setMuted(thread, !isMuted)
647 } label: {
648 Label(isMuted ? "Unmute chat" : "Mute chat",
649 systemImage: isMuted ? "bell" : "bell.slash")
650 }
651 Button {
652 isUnread ? store.markRead(thread) : store.markUnread(thread)
653 } label: {
654 Label(isUnread ? "Mark as Read" : "Mark as Unread",
655 systemImage: isUnread ? "envelope.open" : "envelope.badge")
656 }
657 Button(role: .destructive) {
658 store.setHidden(thread, true)
659 dismiss()
660 } label: {
661 Label("Close chat", systemImage: "xmark.circle")
662 }
663 // End conversation (#578): DM-only, stronger than Close
664 // chat — mutual and server-enforced, hence the confirm.
665 // Once ended, the banner's Reopen is the way back.
666 if thread.kind == "dm", !model.dmClosed {
667 Button(role: .destructive) {
668 showEndConfirm = true
669 } label: {
670 Label("End conversation", systemImage: "nosign")
671 }
672 }
673 } label: {
674 Image(systemName: "ellipsis.circle")
675 }
676 .accessibilityLabel("Chat options")
677 }
678 }
679 .confirmationDialog("End this conversation?",
680 isPresented: $showEndConfirm, titleVisibility: .visible) {
681 Button("End conversation", role: .destructive) {
682 Task {
683 // Optimistic in the store; a refused POST reverts and
684 // we simply stay in the chat, unchanged.
685 if await store.endConversation(thread) { dismiss() }
686 }
687 }
688 } message: {
689 Text("It closes for both of you, and no new messages can be sent unless someone reopens it.")
690 }
691 .sheet(isPresented: $showMembers) {
692 MembersSheet(store: store, thread: thread,
693 onOpenThread: { id in
694 showMembers = false
695 path.append(.thread(id))
696 })
697 }
698 // The Threads list. A sheet, not a dock: iPhone has no room for a
699 // second column, and the iPad's docked pane is where a tapped row
700 // lands anyway — so the list is the transient index you come out
701 // of, the same way Media, Links & Docs works.
702 .sheet(isPresented: $showThreadList) {
703 ThreadListSheet(model: model,
704 onOpen: { root in
705 showThreadList = false
706 openThread(root)
707 })
708 }
709 .sheet(isPresented: $showRename) { RenameGroupSheet(store: store, thread: thread) }
710 .sheet(isPresented: $showGroupApps) { GroupAppsSheet(model: model) }
711 .sheet(isPresented: $showMediaView) {
712 MediaLinksDocsSheet(instance: instance, thread: thread, store: store,
713 model: model,
714 onJump: { sid in
715 showMediaView = false
716 // The feed may not hold the message —
717 // reuse the search hit's bounded
718 // page-back walk to land on it.
719 Self.pendingJump = (thread.id, sid)
720 runPendingJump()
721 })
722 }
723 .onAppear {
724 model.start()
725 LastOpenedChat.note(origin: instance.origin, thread: thread.id,
726 title: thread.title)
727 // Share-sheet handoff: stage each shared file as an upload
728 // (encrypt + upload run exactly like a picked photo), then
729 // burn the inbox copies. The reads run detached — a shared
730 // video is whole-file IO, and this fires during the chat-open
731 // layout crunch.
732 if let share = Self.pendingShare, share.thread == thread.id {
733 Self.pendingShare = nil
734 Task {
735 for url in share.files {
736 guard let data = await Task.detached(operation: {
737 try? Data(contentsOf: url)
738 }).value else { continue }
739 let ext = url.pathExtension.lowercased()
740 if ["mp4", "mov", "m4v"].contains(ext) {
741 stageVideoData(data, name: url.lastPathComponent)
742 } else {
743 stageImageData(data, name: url.lastPathComponent)
744 }
745 }
746 ShareInbox.clear(share.files)
747 }
748 }
749 DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
750 withAnimation(.spring(response: 0.42, dampingFraction: 0.45)) { avatarPop = true }
751 }
752 }
753 // Tapping a photo opens it in Quick Look — which zooms out of the
754 // tapped bubble (and back on dismiss), and carries Apple's own
755 // chrome plus the system Share / Save to Photos / Markup buttons.
756 // An invisible presenter drives QLPreviewController imperatively from
757 // the thumbnail's frame; the decrypted temp file is burned on
758 // dismiss (cleanupPreview), so plaintext never lingers.
759 .background(ImageQuickLookPresenter(item: $quickLookItem) {
760 cleanupPreview()
761 quickLookItem = nil
762 })
763 .fullScreenCover(item: $playingVideo) { item in
764 VideoViewer(player: item.player, sourceFrame: item.sourceFrame) {
765 coverWithoutSlide { playingVideo = nil }
766 }
767 .presentationBackground(.clear)
768 }
769 // Tapping a file chip previews it in Quick Look (with the system
770 // share button) once its bytes are decrypted to a temp file.
771 .sheet(item: $filePreview, onDismiss: cleanupPreview) { item in
772 QuickLookPreview(url: item.url) { filePreview = nil }
773 .ignoresSafeArea()
774 }
775 // /themes opens the per-server settings, where the theme picker lives.
776 .sheet(isPresented: $showThemeSettings) {
777 ServerSettingsSheet(store: store)
778 }
779 // /integrity read-out (tamper evidence). A private, in-place answer,
780 // like the web command's notice.
781 .alert("Integrity check", isPresented: .init(
782 get: { integrityReport != nil }, set: { if !$0 { integrityReport = nil } })
783 ) {
784 Button("OK", role: .cancel) { integrityReport = nil }
785 } message: {
786 Text(integrityReport ?? "")
787 }
788 }
789
790 // The viewers own their whole choreography — the photo zooms out of (and
791 // back into) its bubble, the video flies off in its swipe direction — so
792 // the cover must both APPEAR and VANISH without its default slide, or the
793 // system transition visibly fights the viewer's own animation.
794 private func coverWithoutSlide(_ change: @escaping () -> Void) {
795 var t = Transaction()
796 t.disablesAnimations = true
797 withTransaction(t, change)
798 }
799
800 // A tapped photo: decrypt the ORIGINAL blob to a temp file (Quick Look reads
801 // by URL and renders the full-fidelity image itself), then open the preview
802 // zooming out of the tapped thumbnail. `thumbnail` is the zoom-transition
803 // anchor; `sourceFrame` is where it zooms from / back to. Reuses the same
804 // temp-file staging + cleanup the file preview uses (a photo and a file tap
805 // are mutually exclusive, so they share previewCleanupDir safely).
806 private func openQuickLook(_ att: Attachment, thumbnail: PlatformImage, sourceFrame: CGRect) {
807 guard interaction.blobDownload == nil else { return }
808 interaction.blobDownload = (att.id, 0)
809 let started = Date()
810 Task {
811 let data = await model.blobData(att) { p in
812 if Date().timeIntervalSince(started) > 0.15 { interaction.blobDownload = (att.id, p) }
813 }
814 interaction.blobDownload = nil
815 guard let data,
816 let url = stagePreviewFile(
817 data, name: AttachmentDownload.safeFilename(name: att.name, mime: att.type))
818 else {
819 setRouteNotice("Couldn’t open that photo.")
820 return
821 }
822 // Returning from Quick Look re-fires the feed's onAppear, but
823 // nothing re-pins on appear any more — the scroll position simply
824 // survives the modal, so the viewed photo stays on screen.
825 quickLookItem = QuickLookItem(id: att.id, url: url,
826 thumbnail: thumbnail, sourceFrame: sourceFrame)
827 }
828 }
829
830 // ---- feed --------------------------------------------------------------
831
832 // The cross-row interaction state (swipe drags, times peek, jump wave,
833 // download progress). Plain @State, NOT @StateObject — only the row views
834 // observe it, so a 60Hz drag re-renders the visible cells and never this
835 // whole screen.
836 @State private var interaction = FeedInteraction()
837 // The screen's handle on the feed's scroll position (jumps, the button).
838 @State private var feedController = FeedScrollController()
839 // The open's "tap → usable feed" measurement (#1210): begun when the feed
840 // first appears, ended by the container's first-layout callback — the
841 // frame the reader actually sees, cold fetch included.
842 @State private var mountSpan: Perf.Span?
843 @State private var mountEnded = false
844
845 // A search hit — or a notification tap's stamped message — lands ON its
846 // message: jumpAndWave pages history back for it through reveal() when it
847 // isn't loaded (the open still lands at the bottom first), and says so
848 // inline when the walk can't reach it — a target deeper than the jump's
849 // reach is explained rather than silently ignored.
850 // (This used to carry its own hand-rolled walk ending in a bare `return`.)
851 private func runPendingJump() {
852 guard let j = Self.pendingJump, j.thread == thread.id, model.ready else { return }
853 Self.pendingJump = nil
854 // This open has a real target, so the notification tap's no-id
855 // heuristic must stand down (landOnUnreadThreadReply).
856 jumpTargeted = true
857 jumpAndWave(j.message)
858 }
859
860 // The feed column — the scrolling transcript, the lifted thread overlay
861 // (compact widths) and the composer inset — split out of body so the
862 // docked thread pane can sit BESIDE it (thread-pane spec §6). The
863 // composer inset rides this column, not the screen: docked, the pane
864 // carries its own bar and the channel bar must stop at the divider.
865 private var feedAndComposer: some View {
866 feed
867 // The thread view lifts over the FEED only (the web's
868 // .chatapp-feedwrap overlay) — applied before the safe-area
869 // insets, so the header pill stays above it and the real composer
870 // below stays live, staged to the thread's root. Kept as the
871 // !threadDocked path: regular widths dock instead.
872 .overlay {
873 if let rootID = threadRootID, !threadDocked {
874 threadOverlay(rootID)
875 .transition(.opacity.combined(with: .offset(y: 12)))
876 }
877 }
878 .safeAreaInset(edge: .bottom) {
879 // A read-only app chat never has a composer (an app surface
880 // owning the whole pane never mounts this column at all).
881 if !readOnlyChat {
882 // An ended DM (#578): the server refuses new messages, so
883 // the composer gives way to the closed banner — the one
884 // honest action left is Reopen.
885 Group {
886 if model.dmClosed {
887 dmClosedBanner
888 } else {
889 composer
890 }
891 }
892 // Measured for the feed's rest inset — the feed extends
893 // under the glass; content must rest above it.
894 .background(GeometryReader { g in
895 Color.clear
896 .onAppear { composerHeight = g.size.height }
897 .onChange(of: g.size.height) { _, h in composerHeight = h }
898 })
899 }
900 }
901 }
902
903 // The feed is a STOCK, unflipped UICollectionView (StockChatFeed —
904 // promoted from the /freshpane experiment, which scrolled silky on
905 // device where the flipped container's dressing didn't). Standard safe
906 // areas: SwiftUI sizes it between the header and the composer/keyboard,
907 // and the container keeps the bottom anchored across those resizes.
908 // This screen only supplies the chrome around it and the row hooks.
909 private var feed: some View {
910 ZStack {
911 StockChatFeed(model: model,
912 interaction: interaction,
913 controller: feedController,
914 hooks: feedHooks,
915 topOverlay: headerHeight,
916 bottomOverlay: composerHeight,
917 composerFocused: composerFocused,
918 unreadCutoff: unreadCutoff,
919 onFirstLayout: { count in
920 mountEnded = true
921 guard let span = mountSpan else { return }
922 mountSpan = nil
923 span.end("\(count) messages")
924 })
925 // Full-bleed both ways, the Messages way: the container is a
926 // view CONTROLLER declaring the glass overlays as REAL safe
927 // areas (additionalSafeAreaInsets + automatic adjustment), so
928 // the system's top-edge treatment gets honest geometry and
929 // degrades to the subtle status-bar fade — every manual-inset
930 // variant fed it garbage and it squashed rows over half the
931 // screen. The keyboard safe area stays live (SwiftUI shrinks
932 // the feed for it; the container keeps the bottom anchored
933 // through the resize).
934 .ignoresSafeArea(.container, edges: .vertical)
935 // Cold loads show the loader; the first layout already lands at
936 // the bottom, so there is no settle pass left to hide.
937 if !model.ready {
938 ProgressView()
939 }
940 }
941 // The open, foregrounded chat is being read — arrivals here must not
942 // accrue an unread badge.
943 .onChange(of: model.messages.last?.id) { _, _ in
944 if scenePhase == .active,
945 let t = store.threads.first(where: { $0.id == thread.id }), t.unread {
946 store.markRead(t)
947 }
948 }
949 // A search hit can arrive before or after history folds — cover both.
950 .onChange(of: model.ready) { _, ready in
951 if ready {
952 runPendingJump()
953 landOnUnreadThreadReply()
954 }
955 }
956 // A notification tap for the chat that's ALREADY on screen: nothing
957 // re-appears and model.ready never flips, so the route posts and the
958 // pending jump is consumed here (the store catch-up below folds the
959 // NSE's row, and the walk waits for it).
960 .onReceive(NotificationCenter.default.publisher(for: .lchatJumpToMessage)) { _ in
961 runPendingJump()
962 }
963 // Foregrounding with this chat on screen: merge whatever the NSE
964 // filed while we were suspended straight from disk — the pushed
965 // message shows before the socket reconnects and re-pulls.
966 .onChange(of: scenePhase) { _, phase in
967 guard phase == .active else { return }
968 Task {
969 await model.catchUpFromStore()
970 // A jump armed by a tap that beat this foreground (or landed
971 // while the fold was still in flight) gets its second chance
972 // once the pushed row is actually in hand.
973 runPendingJump()
974 }
975 }
976 .onAppear {
977 // The mount clock starts at the OPENING appear only — modal
978 // returns (Quick Look, the video viewer) re-fire onAppear on an
979 // already-landed feed and must not restart it.
980 if mountSpan == nil, !mountEnded {
981 mountSpan = Perf.begin("feed.mount", slowMs: 500)
982 }
983 runPendingJump()
984 landOnUnreadThreadReply()
985 }
986 }
987
988 // The FALLBACK for a notification tap that couldn't say which message it
989 // was about. A tap normally carries the NSE's stamped row id and lands on
990 // it exactly (runPendingJump); a tap that doesn't — a push the extension
991 // couldn't decrypt, a banner delivered before the stamp existed, an older
992 // server that sends no id — still routes at THREAD granularity, and then
993 // the feed's unread anchor (performInitialAnchor) searches its own rows
994 // and never sees a reply hidden in its thread, while the open chat marks
995 // itself read on arrival: the reader tapped a banner and saw an unchanged
996 // transcript. When EVERY unread is one of those, open the newest one's
997 // thread and land on it (jumpAndWave → jumpIntoThread). The all-or-nothing
998 // gate lives in the pure unreadThreadJump: with even one unread visible in
999 // the transcript the feed's own anchor is already right (it lands on the
1000 // OLDEST unread), and opening a thread over it would cover the unreads the
1001 // reader came for. Same trigger as that anchor (the tap's cutoff — set
1002 // only on a push tap), same one-shot per open. Routing stays where it is
1003 // (NotificationRoute / LChatApp): this only reads the cutoff it left.
1004 private func landOnUnreadThreadReply() {
1005 guard !jumpTargeted else { return }
1006 guard !unreadThreadChecked, let cutoff = unreadCutoff, model.ready else { return }
1007 unreadThreadChecked = true
1008 guard let id = ChatFeedModel.unreadThreadJump(in: model.messages,
1009 feed: model.feedMessages,
1010 since: cutoff) else { return }
1011 jumpAndWave(id)
1012 }
1013
1014 // Everything a feed row needs from this screen, resolved at cell-configure
1015 // time (rows never observe the model — see FeedHooks).
1016 private var feedHooks: FeedHooks {
1017 FeedHooks(isGroup: isGroup,
1018 themeName: themeName,
1019 chatFontSize: chatFontSize,
1020 senderName: { model.senderName($0) },
1021 foldsAsMuted: { model.foldsAsMuted($0) },
1022 revealMuted: { model.revealMuted($0) },
1023 actions: { messageActions($0) },
1024 onJump: { jumpAndWave($0) },
1025 onOpenThread: { openThread($0) },
1026 blobSource: { model.blobSource($0) },
1027 onImageTap: { att, image, frame in
1028 openQuickLook(att, thumbnail: image, sourceFrame: frame)
1029 },
1030 onFileTap: { openFile($0) },
1031 onVideoTap: { att, frame in playVideo(att, from: frame) },
1032 unfurl: { await model.unfurl($0) },
1033 peekUnfurl: { model.cachedUnfurl($0) },
1034 startReply: { startReply($0) },
1035 mentionIndex: { model.mentionIndex },
1036 // A tapped @mention opens the same profile card the DM
1037 // header does — Message them, rename, mute, block (#577/8).
1038 onMentionTap: { headerProfile = HeaderProfileTarget(id: $0) })
1039 }
1040
1041 // ---- reply-thread view (chat.js openThread/closeThread/stageThreadReply) --
1042
1043 // The lifted thread panel over the dimmed feed. Rows reuse the exact
1044 // MessageBubble wiring the feed uses, so reactions, attachments and the
1045 // long-press menu keep working inside the view; reply actions in here
1046 // resolve to the thread's ROOT via the same startReply path.
1047 private func threadOverlay(_ rootID: Int64) -> some View {
1048 ReplyThreadView(model: model, rootID: rootID, isGroup: isGroup,
1049 swipeDownCloses: true,
1050 jumpTarget: threadJump, jumpTick: threadJumpTick,
1051 onClose: closeThread,
1052 onRootPaged: {
1053 // The root just paged in — refresh the banner to its
1054 // live snapshot, unless an edit borrowed the composer.
1055 if editingID == nil, replyTo?.id == rootID { stageThreadReply() }
1056 },
1057 row: { m, showSender in threadBubble(m, showSender: showSender) })
1058 }
1059
1060 @ViewBuilder
1061 private func threadBubble(_ m: ChatFeedModel.Message, showSender: Bool,
1062 pane: Bool = false) -> some View {
1063 // The muted fold holds inside the thread view too (#578).
1064 if model.foldsAsMuted(m) {
1065 MutedMessageStub { model.revealMuted(m.id) }
1066 } else {
1067 threadMessageBubble(m, showSender: showSender, pane: pane)
1068 }
1069 }
1070
1071 private func threadMessageBubble(_ m: ChatFeedModel.Message, showSender: Bool,
1072 pane: Bool = false) -> some View {
1073 // Wrapped in the interaction reader: the screen deliberately doesn't
1074 // observe FeedInteraction, but the overlay's download rings still need
1075 // live progress.
1076 FeedInteractionReader(interaction: interaction) { inter in
1077 MessageBubble(message: m, showSender: showSender,
1078 senderName: model.senderName(m),
1079 theme: .named(themeName),
1080 blobSource: { model.blobSource($0) },
1081 onImageTap: { att, image, frame in
1082 openQuickLook(att, thumbnail: image, sourceFrame: frame)
1083 },
1084 onFileTap: { openFile($0) },
1085 onVideoTap: { att, frame in playVideo(att, from: frame) },
1086 downloading: inter.blobDownload,
1087 actions: pane ? paneMessageActions(m) : threadMessageActions(m),
1088 unfurl: { await model.unfurl($0) })
1089 .environment(\.chatFontSize, chatFontSize)
1090 .environment(\.mentionIndex, model.mentionIndex)
1091 .environment(\.openURL, OpenURLAction { url in
1092 guard let uid = MentionLink.uid(from: url) else { return .systemAction }
1093 headerProfile = HeaderProfileTarget(id: uid)
1094 return .handled
1095 })
1096 // The jump wave, on the thread surface (jumpIntoThread's target).
1097 .jumpWave(on: m.id == threadDanceID, tick: threadDanceTick)
1098 }
1099 }
1100
1101 // A jump whose target lives only in its thread — loaded, but absent from
1102 // the transcript (mainFeedItems: a thread reply with its root on screen):
1103 // open the thread view on the root (a no-op when already open there),
1104 // then land on THAT reply inside it and wave it — the feed jump's two
1105 // beats, on the view. The web lands inside .chatapp-thread-msgs the same
1106 // way, so a search hit or notification never dead-ends on "the newest
1107 // reply" instead of the one asked for.
1108 private func jumpIntoThread(_ target: ChatFeedModel.Message) {
1109 guard let root = ChatFeedModel.threadRootID(of: target) else { return }
1110 openThread(root)
1111 threadJump = target.id
1112 threadJumpTick += 1
1113 DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
1114 threadDanceID = target.id
1115 threadDanceTick += 1
1116 }
1117 }
1118
1119 // One catalog for the feed and the thread view — reply/edit/delete/copy/
1120 // react, plus Mute/Unmute-sender (#578) on others' uid-carrying messages
1121 // (uid-less legacy messages have nothing to key a mute on).
1122 private func messageActions(_ m: ChatFeedModel.Message) -> MessageActions {
1123 var actions = MessageActions(
1124 canEdit: model.canEdit(m),
1125 onReply: { startReply(m) },
1126 onReplyInThread: { replyInThread(m) },
1127 onEdit: { startEdit(m) },
1128 onDelete: { model.deleteMessage(m.id) },
1129 onCopy: { UIPasteboard.general.string = m.text },
1130 onReact: { model.toggleReaction(m.id, emoji: $0) })
1131 // "Convert to thread" — only where there IS an inline chain under this
1132 // message to move, which is also what keeps it off a message nobody
1133 // answered and off the thread view's bubbles (nothing inline hangs off
1134 // one). Anyone in the chat may convert: it reorganises where messages
1135 // render, it doesn't touch what anyone wrote. The gate is the folded
1136 // flag (this runs for every row on screen); the chain itself is walked
1137 // at the tap, so what moves is what's on screen at that moment.
1138 if m.hasInlineChain {
1139 actions.onConvertToThread = {
1140 let ids = ChatFeedModel.inlineChainIDs(in: model.messages, root: m.id)
1141 guard !ids.isEmpty else { return }
1142 convertCount = ids.count // before the target: the alert reads it immediately
1143 convertTarget = ConvertTarget(root: m.id, ids: ids)
1144 }
1145 }
1146 if !m.mine, let uid = m.raw.uid, !uid.isEmpty {
1147 let muted = model.mutedPeople.contains(uid)
1148 actions.senderMuted = muted
1149 actions.senderLabel = model.senderName(m)
1150 actions.onMutePerson = {
1151 Task { await store.setPersonMuted(uid: uid, muted: !muted) }
1152 }
1153 // Report to the space's admins (#578) — same gate as the mute.
1154 actions.onReport = {
1155 reportTarget = ReportTarget(uid: uid, evidence: ReportEvidence(message: m))
1156 }
1157 }
1158 return actions
1159 }
1160
1161 // Reply/Edit on a bubble INSIDE the open thread OVERLAY (the Mac pane's
1162 // routing, paneMessageActions): Reply re-arms the thread's root reply on
1163 // the composer — flagged thread-only, like every send from the view — and
1164 // pops the keyboard (a reply intent, unlike a tally tap); "Reply in
1165 // thread" would resolve to the thread already open and do exactly that,
1166 // so the view's menus omit it. Edit stays the plain startEdit — cancelEdit
1167 // hands the composer back to the thread (stageThreadReply).
1168 private func threadMessageActions(_ m: ChatFeedModel.Message) -> MessageActions {
1169 var actions = messageActions(m)
1170 actions.onReply = {
1171 stageThreadReply()
1172 composerFocused = true
1173 }
1174 actions.onReplyInThread = nil
1175 // Nor "Convert to thread": a bubble in here is already in a thread, so
1176 // inlineChainIDs finds nothing under it anyway — except on the thread's
1177 // own ROOT, which can still have inline replies out in the feed. Tidying
1178 // those is a thing you do while looking at the feed, not from in here.
1179 actions.onConvertToThread = nil
1180 return actions
1181 }
1182
1183 // The same routing for a bubble rendered inside the DOCKED pane (spec
1184 // §10.5), aimed at the pane's own bar instead of the channel one: Reply
1185 // just puts the caret there (its fixed reply already targets this
1186 // thread's root, flagged thread-only at send), Edit stages on it, and
1187 // "Reply in thread" stays omitted — inherited from the thread catalogue.
1188 private func paneMessageActions(_ m: ChatFeedModel.Message) -> MessageActions {
1189 var actions = threadMessageActions(m)
1190 actions.onReply = { paneFocused = true }
1191 actions.onEdit = { startPaneEdit(m) }
1192 return actions
1193 }
1194
1195 private func openThread(_ rootID: Int64) {
1196 guard rootID > 0, threadRootID != rootID else { return }
1197 // A fresh open lands at the newest reply — never on a stale jump
1198 // target from an earlier one (jumpIntoThread re-aims right after).
1199 threadJump = nil
1200 // Switching roots: the old thread's half-typed pane draft or
1201 // in-flight edit must not leak into the new one — in EITHER
1202 // presentation, since pane state parked by a compact-width resize
1203 // would otherwise resurface on the next dock with paneEditingID
1204 // still naming a message from the old thread.
1205 if threadRootID != nil { resetPaneComposer() }
1206 withAnimation(.easeOut(duration: 0.18)) { threadRootID = rootID }
1207 if threadDocked {
1208 // The channel bar stays on the channel — no staging; the pane's
1209 // composer derives its reply fresh at send. Focus moves into
1210 // the pane (spec §10) only for a hardware-keyboard reader: a
1211 // tally/quote tap is a reading intent, and on a touch-only iPad
1212 // the software keyboard would rise over the replies just opened
1213 // — the rule stageThreadReply keeps for the overlay. The reply
1214 // verbs (replyInThread, the pane bubbles' Reply) focus regardless.
1215 if hardwareKeyboardAttached { paneFocused = true }
1216 } else {
1217 stageThreadReply()
1218 }
1219 }
1220
1221 // "Reply in thread" (thread-pane spec §3.5): resolve the tapped message to
1222 // its thread's ROOT (threadTarget — a message already IN a thread
1223 // continues it, anything else roots a NEW thread on itself), open the
1224 // thread view pinned to it (a re-tap inside the open thread just re-arms
1225 // the root reply), then put the caret in the reply box — the pane's bar
1226 // when docked, the channel bar staged to the root under the overlay.
1227 // Unlike a tally/quote tap this IS a reply intent, so the keyboard pops
1228 // even on the overlay path, which otherwise never focuses on touch. This
1229 // verb is the ONLY way a thread ever begins; nothing is absorbed into one
1230 // by replying.
1231 private func replyInThread(_ m: ChatFeedModel.Message) {
1232 guard let rootID = model.threadTarget(for: m).id, rootID > 0 else { return }
1233 if threadRootID != rootID {
1234 openThread(rootID)
1235 } else if !threadDocked {
1236 stageThreadReply()
1237 }
1238 if threadDocked {
1239 paneFocused = true
1240 } else {
1241 composerFocused = true
1242 }
1243 }
1244
1245 // Stage the composer's reply on the thread's root — the OVERLAY view's
1246 // composer contract ("Replying to <root author>"). A reply displaces an
1247 // in-flight edit, but the keyboard is deliberately NOT popped: it would
1248 // cover the thread the reader just opened (the web skips focus on touch
1249 // too). Docked, the pane's own bar carries the thread — never called.
1250 // Flagged thread-only (ReplyRef.thread): a send from the open thread
1251 // view lives in the thread, Slack-style — the main feed shows only the
1252 // root's "N replies" tally. The compact analogue of the Mac/web pane
1253 // composer's send (sendPaneDraft flags its own); a plain Reply/swipe/
1254 // quote reply from the feed itself never carries the flag.
1255 private func stageThreadReply() {
1256 guard let rootID = threadRootID else { return }
1257 editingID = nil
1258 replyTo = model.threadReplyRef(root: rootID).inThread()
1259 }
1260
1261 private func closeThread() {
1262 guard let rootID = threadRootID else { return }
1263 withAnimation(.easeOut(duration: 0.15)) { threadRootID = nil }
1264 threadJump = nil
1265 // The pane's composer dies with the thread (the web's docked pane
1266 // block is destroyed on close) — in EITHER presentation: pane state
1267 // a compact-width resize parked must not outlive the thread it was
1268 // typed for (a later dock would revive a draft, or an edit id, from
1269 // a thread no longer on screen).
1270 resetPaneComposer()
1271 if threadDocked {
1272 // Focus returns to the channel bar only if the pane's field HAD
1273 // it — a keyboard that's up moves over, a keyboard that's down
1274 // stays down (touch never pops one as a side effect of closing).
1275 let hadFocus = paneFocused
1276 paneFocused = false
1277 if hadFocus { composerFocused = true }
1278 } else {
1279 // Leaving the overlay drops its staged reply — never an unrelated
1280 // one staged since (an edit clears replyTo itself, so that
1281 // survives).
1282 if replyTo?.id == rootID { replyTo = nil }
1283 }
1284 }
1285
1286 // ---- the docked pane (thread-pane spec §6, regular widths) -----------
1287
1288 // ReplyThreadView's panel content — header/✕, paging note, the root and
1289 // its replies as one list — as a flush right-hand column with its OWN
1290 // composer at the bottom, the web's .chatapp-thread-panel--docked.
1291 // Root-paging and the
1292 // empty-thread auto-close live in ReplyThreadView and ride along
1293 // unchanged. No onRootPaged: there's no staged banner to refresh — the
1294 // pane's reply target derives fresh at send.
1295 private func threadPane(_ rootID: Int64) -> some View {
1296 VStack(spacing: 0) {
1297 ReplyThreadView(model: model, rootID: rootID, isGroup: isGroup, docked: true,
1298 // A jump into a thread lands on THAT reply here
1299 // too (#1475): jumpIntoThread opens this pane and
1300 // re-aims with the tick, the rows wave via
1301 // threadDanceID below.
1302 jumpTarget: threadJump, jumpTick: threadJumpTick,
1303 onClose: closeThread,
1304 row: { m, showSender in
1305 threadBubble(m, showSender: showSender, pane: true)
1306 })
1307 // An ended DM refuses new messages — the pane goes composer-less,
1308 // like the channel bar giving way to the closed banner; a
1309 // read-only app chat never had one.
1310 if !model.dmClosed, !readOnlyChat {
1311 Divider()
1312 paneComposer
1313 }
1314 }
1315 // The web's min(340px, 40%): fixed at 340 on a full-width iPad, a
1316 // 40% share when the chat surface is narrower (Split View 2/3,
1317 // Stage Manager) so the feed keeps the larger column.
1318 .frame(width: paneWidth)
1319 // The web dock's background: var(--panel), not the overlay's frosted
1320 // material — a flush column sits on the window, not over the feed.
1321 .background(ChatTheme.named(themeName).panel.resolved(scheme))
1322 // The web dock is a complementary landmark ("Reply thread"); the
1323 // panel content inside carries the header/controls.
1324 .accessibilityElement(children: .contain)
1325 .accessibilityLabel("Reply thread")
1326 }
1327
1328 // The pane's composer — a SECOND MessageComposerBar over its own state
1329 // set. fixedReply marks it a pane bar (no reply banner, no ✕ — the
1330 // target is the thread's root, derived fresh at send in sendPaneDraft);
1331 // commands stay off, so "/" text sends verbatim (spec risk 3). The edit
1332 // banner DOES render here (§10.5): editing a thread reply from the pane
1333 // is legitimate, with its ✕ cancelling back to the plain reply state.
1334 private var paneComposer: some View {
1335 MessageComposerBar(
1336 fixedReply: { threadRootID.map { model.threadReplyRef(root: $0).inThread() } },
1337 allowCommands: false,
1338 placeholder: "Reply…",
1339 formatAsYouType: formatAsYouType,
1340 roster: store.roster,
1341 myUserId: store.myUserId,
1342 hasBot: hasBot,
1343 appCommands: [],
1344 displayNames: store.displayLabelMap(),
1345 hostRows: [],
1346 draft: $paneDraft,
1347 composerFocused: $paneFocused,
1348 controller: paneController,
1349 staged: paneStaged,
1350 replyTo: nil,
1351 editingID: paneEditingID,
1352 routeNotice: nil,
1353 onSend: sendPaneDraft,
1354 attach: defaultCommand == nil ? attachHooks(into: .pane) : nil,
1355 onUnstage: { s in paneStaged.removeAll { $0.id == s.id } },
1356 onCancelReply: {}, // never rendered — fixedReply is non-nil
1357 onCancelEdit: cancelPaneEdit,
1358 onDraftChanged: { text in
1359 // Same thread, same typing beats; no "/" host pre-warm — the
1360 // pane runs no commands.
1361 model.typingBeat(active: !text.isEmpty && paneEditingID == nil)
1362 },
1363 onEscapeFallback: {
1364 // The pane's ladder, innermost first (after the bar's own
1365 // typeahead): an armed edit cancels — the same peel as its
1366 // banner ✕ — then typed content makes Escape a no-op
1367 // (keyboard Escape is never destructive of a draft or staged
1368 // files; a deliberate close with content is the ✕), and only
1369 // an EMPTY pane composer closes the thread.
1370 if paneEditingID != nil {
1371 cancelPaneEdit()
1372 return true
1373 }
1374 if paneHasContent { return false }
1375 closeThread()
1376 return true
1377 },
1378 onEditLatest: {
1379 // ↑ edits the latest own message IN THIS THREAD (root
1380 // included — it's pinned right here), not the channel's.
1381 guard let rootID = threadRootID,
1382 let last = ChatFeedModel.threadItems(in: model.messages, root: rootID)
1383 .last(where: { $0.mine && !$0.action })
1384 else { return }
1385 startPaneEdit(last)
1386 },
1387 panel: EmptyView())
1388 }
1389
1390 private var paneHasContent: Bool {
1391 !paneDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !paneStaged.isEmpty
1392 }
1393
1394 private var paneWireDraft: String {
1395 let wire = paneController.serializedMarkdown(escapeLiterals: !formatAsYouType)
1396 return wire.isEmpty ? paneDraft : wire
1397 }
1398
1399 // The pane's send: an ORDINARY replyTo reply — no command routing, no
1400 // default-command detour (matching the web pane composer). The reply
1401 // target derives FRESH here (ChatFeedModel.threadReplyRef): the root may
1402 // have paged in since the pane opened, and a loaded root snapshots better
1403 // than the frozen quote.
1404 private func sendPaneDraft() {
1405 // An edit replaces the message in place — same rule as the channel bar.
1406 if let id = paneEditingID {
1407 if model.edit(id, text: paneWireDraft) { cancelPaneEdit() }
1408 return
1409 }
1410 guard Self.canSend(draft: paneDraft, staged: paneStaged),
1411 let rootID = threadRootID else { return }
1412 // Only clear once the send reaches the socket — a down socket keeps
1413 // the draft to resend, never silently drops it (the web's rule).
1414 // Flagged thread-only (ReplyRef.thread, #1475): a send from the pane
1415 // lives in the thread — the transcript shows the root's tally only.
1416 if model.send(paneWireDraft, attachments: paneStaged.compactMap(\.attachment),
1417 replyTo: model.threadReplyRef(root: rootID).inThread()) {
1418 paneDraft = ""
1419 paneStaged = []
1420 paneFocused = true // the next reply continues the thread (web parity)
1421 }
1422 }
1423
1424 private func startPaneEdit(_ m: ChatFeedModel.Message) {
1425 paneEditingID = m.id
1426 paneDraft = m.raw.text ?? m.text
1427 paneFocused = true
1428 }
1429
1430 private func cancelPaneEdit() {
1431 paneEditingID = nil
1432 paneDraft = ""
1433 }
1434
1435 private func resetPaneComposer() {
1436 paneDraft = ""
1437 paneStaged = []
1438 paneEditingID = nil
1439 }
1440
1441 // Scroll to a message and wave it — quote-taps and search hits share this.
1442 // The near/far animation split lives in the container's scrollTo.
1443 private func jumpAndWave(_ id: Int64) {
1444 guard let target = model.messages.first(where: { $0.id == id }) else {
1445 // Not loaded at all — older than the page we hold. Page back for
1446 // it rather than doing nothing: this is the ordinary case for the
1447 // quote card above a plain reply on a cold-opened chat.
1448 reveal(id)
1449 return
1450 }
1451 // Loaded but not in the transcript = a thread-only reply whose root
1452 // is loaded (mainFeedItems): its bubble is in the thread view, so
1453 // land there. (With the root unloaded it degrades to an inline bubble
1454 // and the ordinary scroll below finds it.)
1455 if !model.feedMessages.contains(where: { $0.id == id }) {
1456 jumpIntoThread(target)
1457 return
1458 }
1459 feedController.scrollTo(id: id)
1460 DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
1461 interaction.danceID = id
1462 interaction.danceTick += 1
1463 }
1464 }
1465
1466 // Page history back for a jump target that isn't loaded, then land on it —
1467 // the web's revealMessage, the Mac's reveal. Never a silent no-op: the
1468 // wait says so inline, and a walk that can't reach the message says which
1469 // kind of can't (deleted vs. further back than one jump goes). One walk at
1470 // a time.
1471 //
1472 // Cannot recurse: model.pageBack returning .found means the message IS in
1473 // model.messages, so jumpAndWave below takes a loaded path and can't come
1474 // back here.
1475 private func reveal(_ id: Int64) {
1476 guard revealing == nil, id > 0 else { return }
1477 revealing = id
1478 showFeedNote("Looking for that message…", sticky: true) // the outcome replaces it
1479 Task {
1480 let reach = await model.pageBack(to: id)
1481 revealing = nil
1482 showFeedNote(ChatFeedModel.jumpReachNote(reach))
1483 guard reach == .found, model.messages.contains(where: { $0.id == id }) else { return }
1484 try? await Task.sleep(nanoseconds: 150_000_000) // let the prepended rows land
1485 jumpAndWave(id)
1486 }
1487 }
1488
1489 // The note answers something the reader just did, so it clears itself
1490 // rather than going stale behind a thread switch — the transient-status
1491 // convention the busy captions already use here. A note that's still true
1492 // while work is in flight ("Looking for that message…") is `sticky`: it's
1493 // replaced by the outcome, never timed out from under a walk that runs
1494 // long. The generation counter keeps every clear cancel-safe.
1495 private static let feedNoteSeconds: TimeInterval = 6
1496 private func showFeedNote(_ text: String?, sticky: Bool = false) {
1497 feedNote = text
1498 feedNoteGen += 1
1499 guard text != nil, !sticky else { return }
1500 let gen = feedNoteGen
1501 DispatchQueue.main.asyncAfter(deadline: .now() + Self.feedNoteSeconds) {
1502 if feedNoteGen == gen { feedNote = nil }
1503 }
1504 }
1505
1506 private func playVideo(_ att: Attachment, from frame: CGRect) {
1507 guard interaction.blobDownload == nil else { return }
1508 interaction.blobDownload = (att.id, 0)
1509 Task {
1510 let data = await model.blobData(att) { p in interaction.blobDownload = (att.id, p) }
1511 interaction.blobDownload = nil
1512 guard let data else { return }
1513 let loader = MemoryVideoAsset(data: data, mimeType: att.type)
1514 let player = AVPlayer(playerItem: AVPlayerItem(asset: loader.makeAsset()))
1515 // The frame is the card's at TAP time — good enough even though the
1516 // download ran in between (the feed can't scroll under the cover).
1517 coverWithoutSlide {
1518 playingVideo = PlayingVideo(id: att.id, player: player, loader: loader,
1519 sourceFrame: frame)
1520 }
1521 player.play()
1522 }
1523 }
1524
1525 // A file chip tap: fetch + decrypt the blob (the chip grows a progress ring
1526 // meanwhile), stage it to a temp file, and open Quick Look — whose share
1527 // button gives Save to Files / AirDrop / open-in. Writing plaintext to a
1528 // temp dir is fine here: it's the member's deliberate act (like the web's
1529 // file-chip download), and the dir is burned when the preview closes.
1530 private func openFile(_ att: Attachment) {
1531 guard interaction.blobDownload == nil else { return }
1532 interaction.blobDownload = (att.id, 0)
1533 // The web's 150ms grace: a fetch that finishes instantly never flashes
1534 // a ring — only one that outlives the grace grows it.
1535 let started = Date()
1536 Task {
1537 let data = await model.blobData(att) { p in
1538 if Date().timeIntervalSince(started) > 0.15 { interaction.blobDownload = (att.id, p) }
1539 }
1540 interaction.blobDownload = nil
1541 guard let data,
1542 let dest = stagePreviewFile(
1543 data, name: AttachmentDownload.safeFilename(name: att.name, mime: att.type))
1544 else {
1545 setRouteNotice("Couldn’t open that file.")
1546 return
1547 }
1548 filePreview = FilePreviewItem(url: dest)
1549 }
1550 }
1551
1552 // Write decrypted bytes into a fresh temp subdirectory so the file keeps its
1553 // real name (Quick Look titles + previews by filename). Returns the file URL
1554 // and records the directory for cleanup.
1555 private func stagePreviewFile(_ data: Data, name: String) -> URL? {
1556 let dir = FileManager.default.temporaryDirectory
1557 .appendingPathComponent("preview-\(UUID().uuidString)", isDirectory: true)
1558 do {
1559 try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
1560 let dest = dir.appendingPathComponent(name)
1561 try data.write(to: dest, options: .atomic)
1562 previewCleanupDir = dir
1563 return dest
1564 } catch {
1565 return nil
1566 }
1567 }
1568
1569 private func cleanupPreview() {
1570 guard let dir = previewCleanupDir else { return }
1571 try? FileManager.default.removeItem(at: dir)
1572 previewCleanupDir = nil
1573 }
1574
1575 // ---- composer ------------------------------------------------------------
1576
1577 // The ended-DM banner that takes the composer's place (#578): inline in
1578 // the composer's layout slot — never floating over the feed. A refused
1579 // reopen swaps the line for its reason (their DM door / a blip), the
1580 // web's dm-closed-bar behaviour.
1581 private var dmClosedBanner: some View {
1582 HStack(spacing: 12) {
1583 Text(reopenNote ?? "This conversation is closed.")
1584 .font(.subheadline)
1585 .foregroundStyle(.secondary)
1586 .frame(maxWidth: .infinity, alignment: .leading)
1587 Button("Reopen") {
1588 reopenNote = nil
1589 reopenBusy = true
1590 Task {
1591 switch await store.reopenDM(thread.id) {
1592 case .ok:
1593 break // the flag flips (store + model); the composer returns
1594 case .dmsClosed:
1595 reopenNote = "They're not accepting direct messages right now."
1596 case .failed:
1597 reopenNote = "Couldn't reopen — try again."
1598 }
1599 reopenBusy = false
1600 }
1601 }
1602 .buttonStyle(.borderedProminent)
1603 .disabled(reopenBusy)
1604 .accessibilityHint("Reopens this conversation for both of you")
1605 }
1606 .padding(.horizontal, 16)
1607 .padding(.vertical, 12)
1608 .background(.bar)
1609 }
1610
1611 // The native composer — the bar itself is MessageComposerBar (file
1612 // scope, below): one instantiable view, so the docked thread pane can
1613 // mount a second one with its own draft. This is the MAIN mount:
1614 // user-armed reply/edit banners (fixedReply nil), commands on, the
1615 // command-host panel in the slot, and the send/staging/host machinery
1616 // below supplied as closures.
1617 private var composer: some View {
1618 MessageComposerBar(
1619 fixedReply: nil,
1620 allowCommands: true,
1621 placeholder: defaultCommand?.placeholder ?? "Message",
1622 formatAsYouType: formatAsYouType,
1623 roster: store.roster,
1624 myUserId: store.myUserId,
1625 hasBot: hasBot,
1626 // App-local verbs (/add in To-do) lead the strip, bot or no bot —
1627 // the web's commandCatalog(threadId).
1628 appCommands: AppDefaultCommand.localCommands(appID: thread.appId),
1629 displayNames: store.displayLabelMap(),
1630 hostRows: hostRows,
1631 draft: $draft,
1632 composerFocused: $composerFocused,
1633 controller: composerController,
1634 staged: staged,
1635 replyTo: replyTo,
1636 editingID: editingID,
1637 routeNotice: routeNotice,
1638 onSend: sendDraft,
1639 // A default-command thread is text-only — with no one to read a
1640 // file, an attachment is a dead end, so the "+" and paste/drop
1641 // staging go (the web composer hides its attach button the same
1642 // way).
1643 attach: defaultCommand == nil ? attachHooks(into: .main) : nil,
1644 onUnstage: { s in staged.removeAll { $0.id == s.id } },
1645 onCancelReply: cancelReply,
1646 onCancelEdit: cancelEdit,
1647 onDraftChanged: { text in
1648 model.typingBeat(active: !text.isEmpty && editingID == nil)
1649 // Pre-warm the command host the moment a command might be
1650 // coming — its page load happens during typing, and its rows
1651 // upgrade the "/" typeahead (Mac parity).
1652 if text.first == "/", commandHost == nil { ensureCommandHost() }
1653 },
1654 onEscapeFallback: {
1655 // An open command panel is the most recently summoned layer —
1656 // Escape peels it (after the bar's own typeahead) before the
1657 // bar's own armed edit/reply, exactly what the banner ✕ does.
1658 // The channel bar never closes the docked pane — that's the
1659 // pane bar's Escape, or its ✕ — so a reflex Escape here can't
1660 // take the thread away.
1661 if panelVisible {
1662 commandHost?.dismissPanel()
1663 return true
1664 }
1665 if editingID != nil {
1666 cancelEdit()
1667 return true
1668 }
1669 if replyTo != nil {
1670 // Under the overlay, cancelling the staged root reply IS
1671 // closing the thread (cancelReply) — and the draft would
1672 // silently stop being a thread reply. The pane bar's
1673 // rule holds here too: keyboard Escape is never
1674 // destructive, so typed/staged content makes it a no-op
1675 // (the banner ✕ closes with content); only an empty bar
1676 // peels the reply. A plain (non-thread) reply always
1677 // cancels — the draft stays, only the banner goes.
1678 if threadRootID != nil, !threadDocked, hasContent { return false }
1679 cancelReply()
1680 return true
1681 }
1682 return false
1683 },
1684 onEditLatest: {
1685 // ↑ edits the latest own message on the surface in front of
1686 // the reader: the OVERLAY thread's (root included) while it
1687 // is up, else the FEED's — a thread-only reply of mine isn't
1688 // in the transcript. Docked, this bar IS the channel's, so
1689 // the open pane never claims it (the pane bar edits within
1690 // its own thread).
1691 let pool = threadRootID.flatMap { root in
1692 threadDocked ? nil : ChatFeedModel.threadItems(in: model.messages, root: root)
1693 } ?? model.feedMessages
1694 if let last = pool.last(where: { $0.mine && !$0.action }) {
1695 startEdit(last)
1696 }
1697 },
1698 panel: commandPanel)
1699 }
1700
1701 // The command panel — a command's transient interactive surface
1702 // (/emoji, /gif), the webview revealed above the composer while a
1703 // panel is showing. Mounted permanently once a host exists (height
1704 // 0 when closed) so the page loads and keeps its state — the Mac
1705 // composer's exact shape. The main bar's panel slot, between the
1706 // typeahead and the banners; the pane bar passes an EmptyView.
1707 @ViewBuilder private var commandPanel: some View {
1708 if let commandHost {
1709 CommandPanelWebView(host: commandHost)
1710 .frame(height: panelVisible ? panelHeight : 0)
1711 .clipShape(RoundedRectangle(cornerRadius: panelVisible ? 12 : 0))
1712 .padding(.horizontal, panelVisible ? 10 : 0)
1713 .accessibilityHidden(!panelVisible)
1714 }
1715 }
1716
1717 // Which composer a staged file lands on: the channel bar or the docked
1718 // thread pane's. Threaded through the one staging/upload pipeline below
1719 // so both bars share it — each keeps its own chip list.
1720 private enum ComposerTarget { case main, pane }
1721
1722 private func stagedBinding(_ target: ComposerTarget) -> Binding<[StagedUpload]> {
1723 target == .main ? $staged : $paneStaged
1724 }
1725
1726 // A bar's attachment sources, all landing on ONE target's chip list: the
1727 // "+" menu's camera/photo/file picks, an image paste, a drop.
1728 private func attachHooks(into target: ComposerTarget) -> ComposerAttachHooks {
1729 ComposerAttachHooks(
1730 onPickerItem: { stagePickerItem($0, into: target) },
1731 onFile: { stageFile($0, into: target) },
1732 onImageData: { stageImageData($0, name: $1, into: target) },
1733 onDrop: { stageDroppedProviders($0, into: target) })
1734 }
1735
1736 // One provider pipeline for drops: prefer a real file (name + full
1737 // quality), else raw image/movie data with the provider's suggested name.
1738 private func stageDroppedProviders(_ providers: [NSItemProvider],
1739 into target: ComposerTarget) {
1740 for provider in providers {
1741 if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
1742 _ = provider.loadObject(ofClass: URL.self) { url, _ in
1743 if let url { DispatchQueue.main.async { stageFile(url, into: target) } }
1744 }
1745 continue
1746 }
1747 if provider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
1748 provider.loadDataRepresentation(forTypeIdentifier: UTType.movie.identifier) { data, _ in
1749 guard let data else { return }
1750 let name = provider.suggestedName ?? "video"
1751 DispatchQueue.main.async { stageVideoData(data, name: name, into: target) }
1752 }
1753 continue
1754 }
1755 if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
1756 provider.loadDataRepresentation(forTypeIdentifier: UTType.image.identifier) { data, _ in
1757 guard let data else { return }
1758 let name = provider.suggestedName ?? "photo.jpg"
1759 DispatchQueue.main.async { stageImageData(data, name: name, into: target) }
1760 }
1761 }
1762 }
1763 }
1764
1765 // Sendable = something typed or something uploaded, nothing still in
1766 // flight. Static so the bar's send button and both send paths (channel,
1767 // pane) read the one rule.
1768 static func canSend(draft: String, staged: [StagedUpload]) -> Bool {
1769 let hasText = !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
1770 let uploading = staged.contains { $0.attachment == nil }
1771 return !uploading && (hasText || staged.contains { $0.attachment != nil })
1772 }
1773
1774 private var canSend: Bool { Self.canSend(draft: draft, staged: staged) }
1775
1776 // Anything at all on the channel bar — typed or staged, uploaded or not
1777 // (the size-class handoff only ever moves a draft into an EMPTY bar).
1778 private var hasContent: Bool {
1779 !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !staged.isEmpty
1780 }
1781
1782 // An upload still in flight on a bar's chip list — its task will write
1783 // back through the binding it started with, so the list must stay put.
1784 private func uploading(_ staged: [StagedUpload]) -> Bool {
1785 staged.contains { $0.attachment == nil }
1786 }
1787
1788 // Show (or clear, with nil) the default-command push-back. Every showing
1789 // bumps the generation, so an in-flight clear timer from an EARLIER
1790 // showing — even of the identical string — can never wipe this one early.
1791 private func setRouteNotice(_ text: String?) {
1792 routeNoticeGen += 1
1793 routeNotice = text
1794 guard text != nil else { return }
1795 let gen = routeNoticeGen
1796 DispatchQueue.main.asyncAfter(deadline: .now() + 8) {
1797 if routeNoticeGen == gen { routeNotice = nil }
1798 }
1799 }
1800
1801 // The wire form of the draft: serialized markdown when the field is
1802 // mounted (styled runs become markers — the vector-pinned twin), the raw
1803 // draft otherwise. Commands and routing above always read `draft` (the
1804 // display text), mirroring the web's plain/markdown split.
1805 private var wireDraft: String {
1806 // Opted out of format-as-you-type = typed markers are literal and
1807 // must render literally: escape them; only the WYSIWYG formats.
1808 let wire = composerController.serializedMarkdown(escapeLiterals: !formatAsYouType)
1809 return wire.isEmpty ? draft : wire
1810 }
1811
1812 private func sendDraft() {
1813 if let id = editingID {
1814 if model.edit(id, text: wireDraft) { cancelEdit() }
1815 return
1816 }
1817 guard canSend else { return }
1818 // Slash commands (bare text only): /me broadcasts an action line,
1819 // /theme applies a theme locally, /themes opens Settings; anything
1820 // else falls through to a normal send so a bot answers it.
1821 if staged.isEmpty, replyTo == nil, draft.first == "/" {
1822 switch SlashCommand.parse(draft, meName: model.myScreenName) {
1823 case .me(let line):
1824 if model.sendAction(line) { draft = "" }
1825 return
1826 case .setTheme(let id):
1827 themeName = id
1828 draft = ""
1829 return
1830 case .openThemes:
1831 draft = ""
1832 showThemeSettings = true
1833 return
1834 case .integrity:
1835 draft = ""
1836 integrityReport = (model.integrity?.reportLines()).map { $0.joined(separator: "\n") }
1837 ?? "Integrity monitoring isn't active for this space."
1838 return
1839 case .help, .sendAsMessage:
1840 // Not the native floor. The current app-thread's own verbs
1841 // stay native (the default-command block below); everything
1842 // else consults the thread's commandItems() rows: a
1843 // bot-answered command rides to the conversation as a
1844 // message, and every framework-owned command — /emoji, /gif,
1845 // /todo, a custom app's verb, even a typo (the web engine
1846 // answers "Unknown command — try /help" as a private notice
1847 // instead of posting the literal) — forwards to the command
1848 // host. Cleared immediately, like the web composer — a
1849 // booting page queues the text.
1850 let ownVerb = defaultCommand.map {
1851 AppDefaultCommand.routing(draft, spec: $0) != .passthrough
1852 } ?? false
1853 if !ownVerb {
1854 let host = ensureCommandHost()
1855 if CommandRouting.route(draft, rows: hostRows.isEmpty ? host.commandRows : hostRows) == .host {
1856 cmdLog.notice("forwarding to the command host: \(draft, privacy: .public)")
1857 host.run(draft)
1858 draft = ""
1859 return
1860 }
1861 }
1862 break
1863 }
1864 }
1865 // Default-command thread (To-do, Reminders): plain text — or one of the
1866 // app's own verbs — becomes the app event instead of a chat message
1867 // nobody reads; a bare verb is consumed, never posted. Async because
1868 // Reminders schedules server-side first; the sync routing() pre-check
1869 // decides whether the draft is ours at all (passthrough → normal send).
1870 // The routing contract is shared with the Mac via routeDefaultCommand.
1871 if staged.isEmpty, replyTo == nil, let dc = defaultCommand,
1872 AppDefaultCommand.routing(draft, spec: dc) != .passthrough {
1873 // One routing in flight at a time: the draft deliberately stays
1874 // visible during the schedule round-trip, so a second Enter must
1875 // not double-create the reminder.
1876 guard !dcRouting else { return }
1877 dcRouting = true
1878 let text = draft
1879 Task { @MainActor in
1880 defer { dcRouting = false }
1881 switch await model.routeDefaultCommand(text, spec: dc) {
1882 case .sent:
1883 // Clear only what we sent — typing during the schedule
1884 // round-trip must never be wiped by a stale completion.
1885 if draft == text { draft = "" }
1886 setRouteNotice(nil) // a success retires any stale push-back
1887 case .rejected(let prompt):
1888 // The draft stays put to fix; this line is the instruction.
1889 setRouteNotice(prompt)
1890 case .kept, .notHandled:
1891 break // draft stays put (down socket)
1892 }
1893 }
1894 return
1895 }
1896 if model.send(wireDraft, attachments: staged.compactMap(\.attachment), replyTo: replyTo) {
1897 draft = ""
1898 staged = []
1899 // Overlay model only: sending from an open thread view keeps it
1900 // open and re-stages the reply to its root — the new reply folds
1901 // in live and the next send continues the same thread, like
1902 // iMessage (web parity). Docked, the channel bar stays on the
1903 // channel — the pane's own composer carries the thread.
1904 if threadRootID != nil, !threadDocked { stageThreadReply() } else { replyTo = nil }
1905 }
1906 }
1907
1908 @State private var showThemeSettings = false
1909 @State private var integrityReport: String? // /integrity read-out (tamper evidence)
1910
1911 // The thread's command host (CommandHostCache keeps it across thread
1912 // switches); every call re-points the sink at THIS on-screen pane — the
1913 // model-cache re-point pattern, same as the Mac composer.
1914 @discardableResult
1915 private func ensureCommandHost() -> CommandHost {
1916 let host = commandHost ?? CommandHostCache.shared.host(
1917 for: instance, thread: thread, myUserId: store.myUserId,
1918 instanceKeyB64: store.instanceKey())
1919 if commandHost == nil { commandHost = host }
1920 host.sink = { event in
1921 switch event {
1922 case .panel(_, let height, let animate): // the web panel draws its own header
1923 panelAnimates = animate
1924 withAnimation(animate ? .easeOut(duration: 0.22) : nil) {
1925 panelHeight = min(height, 340)
1926 panelVisible = true
1927 }
1928 case .panelClosed:
1929 withAnimation(panelAnimates ? .easeIn(duration: 0.2) : nil) {
1930 panelVisible = false
1931 }
1932 case .panelHeight(let h):
1933 if panelVisible {
1934 withAnimation(panelAnimates ? .easeInOut(duration: 0.18) : nil) {
1935 panelHeight = min(h, 340)
1936 }
1937 }
1938 case .composerInsert(let text):
1939 // A panel acting on the composer (an /emoji tap) — inserted at
1940 // the caret, like the web's insertAtCaret.
1941 composerController.insert(text, replacingUTF16: nil)
1942 composerFocused = true
1943 case .notice(let from, let text):
1944 // A command's private "only visible to you" reply — the same
1945 // inline slot the default-command push-back uses.
1946 setRouteNotice(from.isEmpty ? text : "\(from) — \(text)")
1947 case .commands(let rows):
1948 // The thread's live command catalogue landed — the forwarding
1949 // decision follows it from here, and the composer bar re-ranks
1950 // its typeahead on the rows change (onChange(of: hostRows)).
1951 hostRows = rows
1952 }
1953 }
1954 // Cross-thread command routing (docs/native-apps.md phase 3): the page
1955 // asks for one narrow thread key ({needKey}) or an app's personal
1956 // thread ({needAppThread}); answer from what THIS member already
1957 // holds — never the private key.
1958 let origin = instance.origin
1959 host.keyProvider = { tid in
1960 guard let e = SharedThreadKeys.lookup(threadID: tid), e.origin == origin else { return nil }
1961 return e.key.base64urlEncodedString()
1962 }
1963 host.appThreadProvider = { [weak store] app, title in
1964 guard let store, let id = await store.openOrCreateAppThread(app: app, title: title)
1965 else { return nil }
1966 if let key = store.threads.first(where: { $0.id == id })?.threadKeyB64, !key.isEmpty {
1967 return (id, key)
1968 }
1969 return nil
1970 }
1971 if !host.commandRows.isEmpty, hostRows.isEmpty { hostRows = host.commandRows }
1972 return host
1973 }
1974
1975 private func startReply(_ m: ChatFeedModel.Message) {
1976 editingID = nil
1977 // A second guard alongside the container menus: the overlay's own
1978 // bubbles already route Reply through threadMessageActions, which
1979 // stages the flagged root, so this catches any OTHER path into
1980 // startReply while the overlay owns the composer (a hotkey, a swipe,
1981 // a future call site). Under the overlay the channel bar IS the open
1982 // thread's reply box — without this the plain verb would quote that
1983 // bubble and land the reply in the transcript, out of the thread the
1984 // reader is standing in.
1985 if let rootID = threadRootID, !threadDocked, model.threadTarget(for: m).id == rootID {
1986 stageThreadReply()
1987 composerFocused = true
1988 return
1989 }
1990 // Plain Reply quotes the EXACT message tapped — never a root walk.
1991 // Replying to a reply stacks another quote in the feed; joining a
1992 // thread is the other verb's job ("Reply in thread"), and only that
1993 // verb's.
1994 replyTo = model.quoteTarget(for: m)
1995 composerFocused = true
1996 }
1997
1998 // While the OVERLAY thread view is open its staged reply IS the view's
1999 // composer state — cancelling it (the banner's ✕, or a hardware Escape)
2000 // means leaving the thread (web parity), not stranding an open thread
2001 // whose sends would land outside it. Docked, the channel bar's banner is
2002 // always the reader's own staging: cancel just cancels, and the pane
2003 // closes through its own Escape/✕.
2004 private func cancelReply() {
2005 if threadRootID != nil, !threadDocked { closeThread() } else { replyTo = nil }
2006 }
2007
2008 private func startEdit(_ m: ChatFeedModel.Message) {
2009 replyTo = nil
2010 editingID = m.id
2011 draft = m.raw.text ?? m.text
2012 composerFocused = true
2013 }
2014
2015 private func cancelEdit() {
2016 editingID = nil
2017 draft = ""
2018 // Overlay model: an edit inside an open thread view borrowed the
2019 // composer (startEdit clears replyTo); hand it back so the next send
2020 // still continues the thread (web cancelEdit). Docked edits ride the
2021 // pane's own bar (cancelPaneEdit), so nothing to hand back.
2022 if threadRootID != nil, !threadDocked { stageThreadReply() }
2023 }
2024
2025 // ---- uploads -------------------------------------------------------------
2026
2027 // The encrypt+upload pipeline behind both bars' chips: each stage* call
2028 // lands on ONE target's list (stagedBinding), the chip flips from
2029 // progress to ready when its blob id comes back.
2030
2031 private func stagePickerItem(_ item: PhotosPickerItem, into target: ComposerTarget) {
2032 let staged = stagedBinding(target)
2033 let isVideo = item.supportedContentTypes.contains { $0.conforms(to: .movie) }
2034 let name = isVideo ? "video.mp4" : "photo.jpg"
2035 let entry = StagedUpload(name: name)
2036 staged.wrappedValue.append(entry)
2037 let onProgress: @MainActor (Double) -> Void = { p in
2038 if let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) {
2039 staged.wrappedValue[i].progress = p
2040 }
2041 }
2042 Task {
2043 guard let data = try? await item.loadTransferable(type: Data.self) else {
2044 staged.wrappedValue.removeAll { $0.id == entry.id }
2045 return
2046 }
2047 // The composer-chip preview downscale is a full decode — detached,
2048 // like the upload-side processImage in ChatFeedModel.
2049 if !isVideo,
2050 let thumb = await Task.detached(operation: { processImage(data, maxDim: 288) }).value,
2051 let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) {
2052 staged.wrappedValue[i].preview = UIImage(data: thumb.bytes)
2053 }
2054 let att = isVideo
2055 ? await model.uploadVideo(data, name: name, mimeType: "video/mp4", onProgress: onProgress)
2056 : await model.uploadImage(data, name: name, onProgress: onProgress)
2057 if let att, let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) {
2058 staged.wrappedValue[i].attachment = att
2059 } else {
2060 staged.wrappedValue.removeAll { $0.id == entry.id }
2061 }
2062 }
2063 }
2064
2065 // Stage raw image bytes (paste / camera / share-sheet handoff) as an upload.
2066 private func stageImageData(_ data: Data, name: String = "pasted.jpg",
2067 into target: ComposerTarget = .main) {
2068 let staged = stagedBinding(target)
2069 let entry = StagedUpload(name: name)
2070 staged.wrappedValue.append(entry)
2071 let onProgress: @MainActor (Double) -> Void = { p in
2072 if let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) {
2073 staged.wrappedValue[i].progress = p
2074 }
2075 }
2076 Task {
2077 // The chip shows name-only for a beat while the preview downscale
2078 // (a full decode of the pasted image) runs detached.
2079 if let thumb = await Task.detached(operation: { processImage(data, maxDim: 288) }).value,
2080 let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) {
2081 staged.wrappedValue[i].preview = UIImage(data: thumb.bytes)
2082 }
2083 let att = await model.uploadImage(data, name: name, onProgress: onProgress)
2084 if let att, let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) {
2085 staged.wrappedValue[i].attachment = att
2086 } else {
2087 staged.wrappedValue.removeAll { $0.id == entry.id }
2088 }
2089 }
2090 }
2091
2092 private func stageVideoData(_ data: Data, name: String, into target: ComposerTarget = .main) {
2093 let staged = stagedBinding(target)
2094 let entry = StagedUpload(name: name)
2095 staged.wrappedValue.append(entry)
2096 let onProgress: @MainActor (Double) -> Void = { p in
2097 if let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) {
2098 staged.wrappedValue[i].progress = p
2099 }
2100 }
2101 Task {
2102 let att = await model.uploadVideo(data, name: name, mimeType: "video/mp4",
2103 onProgress: onProgress)
2104 if let att, let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) {
2105 staged.wrappedValue[i].attachment = att
2106 } else {
2107 staged.wrappedValue.removeAll { $0.id == entry.id }
2108 }
2109 }
2110 }
2111
2112 private func stageFile(_ url: URL, into target: ComposerTarget) {
2113 let staged = stagedBinding(target)
2114 let name = url.lastPathComponent
2115 let entry = StagedUpload(name: name)
2116 staged.wrappedValue.append(entry)
2117 let type = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType
2118 let onProgress: @MainActor (Double) -> Void = { p in
2119 if let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) {
2120 staged.wrappedValue[i].progress = p
2121 }
2122 }
2123 Task {
2124 // Whole-file read detached — a picked file can be tens of MB, and
2125 // the security scope only needs to bracket the read itself.
2126 let data = await Task.detached { () -> Data? in
2127 guard url.startAccessingSecurityScopedResource() else { return nil }
2128 defer { url.stopAccessingSecurityScopedResource() }
2129 return try? Data(contentsOf: url)
2130 }.value
2131 guard let data else {
2132 staged.wrappedValue.removeAll { $0.id == entry.id }
2133 return
2134 }
2135 let att = await model.uploadFile(data, name: name, mimeType: type, onProgress: onProgress)
2136 if let att, let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) {
2137 staged.wrappedValue[i].attachment = att
2138 } else {
2139 staged.wrappedValue.removeAll { $0.id == entry.id }
2140 }
2141 }
2142 }
2143}
2144
2145// Attachments staged on the draft: picked → uploading (progress) → ready.
2146// Several can ride one message, like the web's pending list. File scope:
2147// NativeChatScreen owns the encrypt+upload pipeline that fills these;
2148// MessageComposerBar renders them as chips.
2149struct StagedUpload: Identifiable {
2150 let id = UUID()
2151 let name: String
2152 var preview: UIImage? // image picks/pastes show a real thumbnail
2153 var progress = 0.0
2154 var attachment: Attachment?
2155}
2156
2157// A bar's attachment sources — the "+" menu's camera / photo / file picks,
2158// an image paste, a drop — each handed to the container's staging pipeline
2159// for ONE composer. nil = no "+", no paste/drop staging (a default-command
2160// thread's text-only surface).
2161struct ComposerAttachHooks {
2162 let onPickerItem: (PhotosPickerItem) -> Void
2163 let onFile: (URL) -> Void
2164 let onImageData: (Data, String) -> Void // bytes + a filename (camera, paste)
2165 let onDrop: ([NSItemProvider]) -> Void
2166}
2167
2168// The chat composer as ONE instantiable view — extracted from
2169// NativeChatScreen so the docked reply-thread pane (regular widths) can
2170// mount a SECOND composer, with its own draft and typeahead, beside the
2171// channel bar. The Mac's MessageComposerBar boundary, phone-shaped: the bar
2172// owns only what is genuinely composer-internal (the caret, the typeahead,
2173// the picker/camera sheet flags); everything code outside the composer
2174// reaches — the draft (startEdit writes it), focus (thread open/close
2175// claims and releases it), reply/edit staging, the staged uploads (the
2176// share-sheet handoff feeds them), the command host — stays with the
2177// container and arrives as bindings/values/closures, so a second instance
2178// is just a second container state set.
2179//
2180// The two presentation knobs (thread-pane spec §6):
2181// - fixedReply: nil = the MAIN bar — user-armed reply/edit banners. Non-nil =
2182// a pane bar: the reply target derives fresh at send time (inside the
2183// container's onSend), so no reply banner and no cancel-✕ render here.
2184// - allowCommands: false = no slash-command typeahead — "/" text sends
2185// verbatim (the container's onSend skips command routing and passes no
2186// panel). @mention and :emoji: completion still work.
2187struct MessageComposerBar<Panel: View>: View {
2188 let fixedReply: (() -> ReplyRef?)?
2189 let allowCommands: Bool
2190 let placeholder: String
2191 let formatAsYouType: Bool
2192 // Typeahead inputs (the web shell.js typeaheadItems' arguments) — plain
2193 // values, read point-in-time by the container like its other store reads.
2194 let roster: [RosterEntry]
2195 let myUserId: String
2196 let hasBot: Bool
2197 let appCommands: [(name: String, desc: String)]
2198 let displayNames: [String: String]
2199 let hostRows: [CommandRow]
2200 // Container-owned composer state (written from outside the bar — see the
2201 // header comment).
2202 @Binding var draft: String
2203 @Binding var composerFocused: Bool
2204 // Programmatic edits (typeahead splices, panel inserts) act on the field
2205 // via the controller, so styling and caret survive; the wire markdown is
2206 // read from it at send. Container-held: the command host's sink inserts
2207 // through it.
2208 let controller: ComposerFieldController
2209 let staged: [StagedUpload]
2210 let replyTo: ReplyRef?
2211 let editingID: Int64?
2212 let routeNotice: String?
2213 // Actions the container supplies — the send path, the staging pipeline
2214 // and the command host stay with it (they reach thread/store state).
2215 let onSend: () -> Void
2216 let attach: ComposerAttachHooks? // nil hides the "+" and paste/drop
2217 let onUnstage: (StagedUpload) -> Void
2218 let onCancelReply: () -> Void
2219 let onCancelEdit: () -> Void
2220 let onDraftChanged: (String) -> Void // typing beat + "/" host pre-warm
2221 let onEscapeFallback: () -> Bool // the container's deeper Escape layers
2222 // Hardware ↑ on an empty draft edits the latest editable message in THIS
2223 // bar's SCOPE — the channel feed, or the docked pane's thread.
2224 let onEditLatest: () -> Void
2225 // The container's command-panel slot, between the typeahead and the
2226 // banners (the main bar mounts the command-host webview here; a
2227 // commands-off bar passes an EmptyView).
2228 let panel: Panel
2229
2230 // ---- bar-owned state ----------------------------------------------------
2231 // The caret as a UTF-16 offset into `draft` — the typeahead needs to know
2232 // which token the caret sits in.
2233 @State private var caret = 0
2234 @State private var nativeTypeahead: (token: ComposerToken, items: [TypeaheadItem])?
2235 // The "+" menu's sheets.
2236 @State private var pickerItem: PhotosPickerItem?
2237 @State private var showPhotoPicker = false
2238 @State private var showCamera = false
2239 @State private var showFilePicker = false
2240
2241 var body: some View {
2242 VStack(spacing: 6) {
2243 if let ta = nativeTypeahead {
2244 typeaheadStrip(ta.items)
2245 }
2246 panel
2247 // A default command pushing back ("when should I remind you?") —
2248 // inline above the composer, occupying layout, never floating.
2249 if let routeNotice {
2250 Text(routeNotice)
2251 .font(.footnote)
2252 .foregroundStyle(.secondary)
2253 .frame(maxWidth: .infinity, alignment: .leading)
2254 .padding(.horizontal, 16)
2255 }
2256 if fixedReply == nil, let replyTo {
2257 banner("Replying to \(replyTo.name ?? "")", detail: replyTo.snippet,
2258 cancelLabel: "Cancel reply", onCancel: onCancelReply)
2259 }
2260 if editingID != nil {
2261 banner("Editing message", detail: nil,
2262 cancelLabel: "Cancel edit", onCancel: onCancelEdit)
2263 }
2264 if !staged.isEmpty {
2265 stagedChips
2266 }
2267 composerRow
2268 }
2269 .padding(.top, 4)
2270 }
2271
2272 // The floating-glass composer: the field self-sizes
2273 // (GrowingTextView), and it shares one glass capsule with the send button.
2274 private var composerRow: some View {
2275 HStack(alignment: .bottom, spacing: 8) {
2276 // The container passes nil in a default-command thread (To-do) —
2277 // text-only surface, matching the web and Mac composers.
2278 if attach != nil {
2279 Menu {
2280 // Simulators (and some iPads) have no camera — the row
2281 // just doesn't exist there rather than failing on tap.
2282 if UIImagePickerController.isSourceTypeAvailable(.camera) {
2283 Button { showCamera = true } label: {
2284 Label("Camera", systemImage: "camera")
2285 }
2286 }
2287 Button { showPhotoPicker = true } label: {
2288 Label("Photo or Video", systemImage: "photo")
2289 }
2290 Button { showFilePicker = true } label: {
2291 Label("File", systemImage: "doc")
2292 }
2293 } label: {
2294 Image(systemName: "plus")
2295 .font(.system(size: 18, weight: .medium))
2296 .frame(width: 36, height: 36)
2297 .floatingGlass(in: Circle())
2298 }
2299 .buttonStyle(.plain)
2300 .accessibilityLabel("Attach")
2301 }
2302
2303 HStack(alignment: .bottom, spacing: 6) {
2304 // sendOnReturn: false — touch Enter newlines, the send button
2305 // sends (the shouldSend rule).
2306 ComposerField(text: $draft, focused: $composerFocused, caret: $caret,
2307 placeholder: placeholder,
2308 sendOnReturn: false,
2309 formatAsYouType: formatAsYouType,
2310 controller: controller,
2311 onSend: onSend,
2312 onPasteImage: onPasteImage,
2313 onEditLast: onEditLatest,
2314 onEscape: {
2315 if nativeTypeahead != nil {
2316 nativeTypeahead = nil
2317 return true
2318 }
2319 // Deeper Escape layers belong to the
2320 // container (command panel, armed
2321 // edit/reply, the docked pane).
2322 return onEscapeFallback()
2323 })
2324 .padding(.leading, 14)
2325 .padding(.vertical, 8)
2326 .onChange(of: draft) { _, text in
2327 refreshTypeahead()
2328 onDraftChanged(text)
2329 }
2330 .onChange(of: caret) { _, _ in refreshTypeahead() }
2331 // The thread's live command rows landing re-ranks an open
2332 // "/" menu — this stands in for the direct refresh the
2333 // container's command-host sink used to make when the
2334 // state all lived together.
2335 .onChange(of: hostRows) { _, _ in refreshTypeahead() }
2336
2337 Button(action: onSend) {
2338 Image(systemName: "arrow.up.circle.fill")
2339 .font(.system(size: 28))
2340 .foregroundStyle(canSend ? Color.accentColor : Color(.tertiaryLabel))
2341 // 44pt target — a near-miss must send, not close the keyboard.
2342 .frame(width: 44, height: 40)
2343 .contentShape(Rectangle())
2344 }
2345 .buttonStyle(.plain)
2346 .disabled(!canSend)
2347 .accessibilityLabel("Send")
2348 }
2349 .floatingGlass(in: RoundedRectangle(cornerRadius: 22))
2350 }
2351 .padding(.horizontal, 10)
2352 .padding(.vertical, 8)
2353 .photosPicker(isPresented: $showPhotoPicker, selection: $pickerItem, matching: .any(of: [.images, .videos]))
2354 .onChange(of: pickerItem) { _, item in
2355 guard let item else { return }
2356 pickerItem = nil
2357 attach?.onPickerItem(item)
2358 }
2359 .fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.item]) { result in
2360 if case .success(let url) = result { attach?.onFile(url) }
2361 }
2362 // A fresh capture stages exactly like a pasted image — the upload
2363 // path's processImage downscales and strips EXIF/location before
2364 // encryption, same as every other photo source.
2365 .fullScreenCover(isPresented: $showCamera) {
2366 CameraCapture { data in attach?.onImageData(data, "photo.jpg") }
2367 .ignoresSafeArea()
2368 }
2369 // Drop-to-attach (iPad split view, iPhone app-to-app drags): images,
2370 // movies and files stage exactly like the pickers' choices. The system
2371 // drag badge is the whole affordance — no overlay (matching the Mac).
2372 .onDrop(of: attach != nil ? [.image, .movie, .fileURL] : [],
2373 isTargeted: nil) { providers in
2374 attach?.onDrop(providers)
2375 return true
2376 }
2377 }
2378
2379 // A pasted image stages as an upload (the composer's "paste to upload");
2380 // nil when the bar has no attachment sources, so a text paste there stays
2381 // a plain text paste.
2382 private var onPasteImage: ((Data) -> Void)? {
2383 guard let attach else { return nil }
2384 return { attach.onImageData($0, "pasted.jpg") }
2385 }
2386
2387 // The container's send paths keep the same rule (NativeChatScreen.canSend)
2388 // — both read the same draft + staged, so they can't disagree.
2389 private var canSend: Bool { NativeChatScreen.canSend(draft: draft, staged: staged) }
2390
2391 private func banner(_ title: String, detail: String?, cancelLabel: String,
2392 onCancel: @escaping () -> Void) -> some View {
2393 HStack(spacing: 8) {
2394 VStack(alignment: .leading, spacing: 1) {
2395 Text(title).font(.system(size: 12, weight: .semibold))
2396 if let detail, !detail.isEmpty {
2397 Text(detail).font(.system(size: 12)).foregroundStyle(.secondary).lineLimit(1)
2398 }
2399 }
2400 Spacer()
2401 // A full 44pt target — the bare ~20pt glyph needed several tries.
2402 Button(action: onCancel) {
2403 Image(systemName: "xmark.circle.fill")
2404 .font(.system(size: 18))
2405 .foregroundStyle(.tertiary)
2406 .frame(width: 44, height: 44)
2407 .contentShape(Rectangle())
2408 }
2409 .buttonStyle(.plain)
2410 .accessibilityLabel(cancelLabel)
2411 }
2412 .padding(.leading, 12)
2413 .padding(.trailing, 2)
2414 .padding(.vertical, 2)
2415 .floatingGlass(in: RoundedRectangle(cornerRadius: 16))
2416 .padding(.horizontal, 10)
2417 }
2418
2419 // ---- typeahead (same native brain as the Mac) --------------------------
2420
2421 private func refreshTypeahead() {
2422 let utf16 = min(max(caret, 0), draft.utf16.count)
2423 let idx = String.Index(utf16Offset: utf16, in: draft)
2424 let charCaret = draft.distance(from: draft.startIndex, to: idx)
2425 // A commands-off bar never menus a "/" draft — the text sends
2426 // verbatim, matching the web pane composer (cfg.commands:false).
2427 guard let token = composerToken(draft, caret: charCaret),
2428 allowCommands || token.kind != .command else {
2429 nativeTypeahead = nil
2430 return
2431 }
2432 let items = typeaheadItems(token, roster: roster,
2433 myUserId: myUserId, hasBot: hasBot,
2434 appCommands: appCommands,
2435 displayNames: displayNames,
2436 rows: hostRows)
2437 nativeTypeahead = items.isEmpty ? nil : (token, items)
2438 }
2439
2440 private func typeaheadStrip(_ items: [TypeaheadItem]) -> some View {
2441 ScrollView(.horizontal, showsIndicators: false) {
2442 HStack(spacing: 6) {
2443 ForEach(items) { item in
2444 Button { pick(item) } label: {
2445 Text(chipLabel(item))
2446 .font(.subheadline)
2447 .lineLimit(1)
2448 .padding(.horizontal, 12)
2449 .padding(.vertical, 7)
2450 .floatingGlass(in: Capsule())
2451 }
2452 .buttonStyle(.plain)
2453 }
2454 }
2455 .padding(.horizontal, 10)
2456 }
2457 }
2458
2459 private func chipLabel(_ item: TypeaheadItem) -> String {
2460 switch item {
2461 case .command(let name, _): return name
2462 case .emoji(let glyph, let code): return "\(glyph) :\(code):"
2463 // The chip shows the name I use for them (#577); the pick still
2464 // inserts the published @name (item.insert).
2465 case .mention(_, _, let display): return "@" + display
2466 }
2467 }
2468
2469 private func pick(_ item: TypeaheadItem) {
2470 guard let ta = nativeTypeahead else { return }
2471 // Splice trigger → caret through the controller: a binding rewrite
2472 // would clobber styled runs and park the caret at the end.
2473 let chars = Array(draft)
2474 let utf16 = min(max(caret, 0), draft.utf16.count)
2475 let charCaret = draft.distance(from: draft.startIndex,
2476 to: String.Index(utf16Offset: utf16, in: draft))
2477 let start = max(0, min(ta.token.start, chars.count))
2478 let end = max(start, min(charCaret, chars.count))
2479 let head = String(chars[0..<start])
2480 let span = String(chars[start..<end])
2481 controller.insert(item.insert, replacingUTF16: NSRange(
2482 location: (head as NSString).length, length: (span as NSString).length))
2483 nativeTypeahead = nil
2484 composerFocused = true
2485 }
2486
2487 // ---- staged chips ------------------------------------------------------
2488
2489 // Staged pictures read as a real preview — a thumbnail tray, WhatsApp/
2490 // Signal proportions. No filename on an image (a synthetic "pasted.jpg"
2491 // is pure noise); non-image files keep the compact name chip, because
2492 // there the filename IS the content.
2493 private var stagedChips: some View {
2494 ScrollView(.horizontal, showsIndicators: false) {
2495 HStack(alignment: .bottom, spacing: 8) {
2496 ForEach(staged) { s in
2497 if let preview = s.preview {
2498 stagedThumb(s, preview: preview)
2499 } else {
2500 stagedFileChip(s)
2501 }
2502 }
2503 }
2504 .padding(.horizontal, 14)
2505 }
2506 }
2507
2508 private func stagedThumb(_ s: StagedUpload, preview: UIImage) -> some View {
2509 // Fixed height, width follows the image's aspect (clamped so a
2510 // panorama can't swallow the tray and a tall shot stays readable).
2511 let h: CGFloat = 96
2512 let aspect = preview.size.height > 0 ? preview.size.width / preview.size.height : 1
2513 let w = min(max(h * aspect, 64), 160)
2514 return Image(uiImage: preview)
2515 .resizable().scaledToFill()
2516 .frame(width: w, height: h)
2517 .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
2518 .opacity(s.attachment == nil ? 0.55 : 1)
2519 .overlay(alignment: .bottom) {
2520 if s.attachment == nil {
2521 ProgressView(value: s.progress)
2522 .tint(.white)
2523 .padding(.horizontal, 10)
2524 .padding(.bottom, 8)
2525 }
2526 }
2527 .overlay(alignment: .topTrailing) {
2528 // Dark scrim keeps the ✕ legible over any photo, either theme.
2529 Button {
2530 onUnstage(s)
2531 } label: {
2532 Image(systemName: "xmark")
2533 .font(.system(size: 10, weight: .bold))
2534 .foregroundStyle(.white)
2535 .frame(width: 22, height: 22)
2536 .background(Circle().fill(.black.opacity(0.55)))
2537 .contentShape(Circle())
2538 }
2539 .buttonStyle(.plain)
2540 .padding(5)
2541 .accessibilityLabel("Remove attachment")
2542 }
2543 }
2544
2545 private func stagedFileChip(_ s: StagedUpload) -> some View {
2546 HStack(spacing: 5) {
2547 if s.attachment == nil {
2548 ProgressView(value: s.progress).frame(width: 40)
2549 } else {
2550 Text(fileIcon(s.name)).font(.system(size: 13))
2551 }
2552 Text(s.name).font(.system(size: 12)).lineLimit(1)
2553 Button {
2554 onUnstage(s)
2555 } label: {
2556 Image(systemName: "xmark.circle.fill")
2557 .font(.system(size: 12))
2558 .foregroundStyle(.tertiary)
2559 }
2560 .accessibilityLabel("Remove attachment")
2561 }
2562 .padding(.horizontal, 8)
2563 .padding(.vertical, 4)
2564 .background(Capsule().fill(.primary.opacity(0.06)))
2565 }
2566}
2567
2568// The system camera (UIImagePickerController — SwiftUI has no native capture
2569// view). Stills only, on purpose: movie capture records audio, which would
2570// demand a microphone-permission prompt for a flow nobody asked for. The
2571// shot comes back as JPEG bytes and rides the ordinary image-upload path.
2572private struct CameraCapture: UIViewControllerRepresentable {
2573 let onCapture: (Data) -> Void
2574 @Environment(\.dismiss) private var dismiss
2575
2576 func makeUIViewController(context: Context) -> UIImagePickerController {
2577 let picker = UIImagePickerController()
2578 picker.sourceType = .camera
2579 picker.delegate = context.coordinator
2580 return picker
2581 }
2582
2583 func updateUIViewController(_ picker: UIImagePickerController, context: Context) {}
2584
2585 func makeCoordinator() -> Coordinator { Coordinator(self) }
2586
2587 final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
2588 let parent: CameraCapture
2589 init(_ parent: CameraCapture) { self.parent = parent }
2590
2591 func imagePickerController(_ picker: UIImagePickerController,
2592 didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
2593 if let image = info[.originalImage] as? UIImage,
2594 let data = image.jpegData(compressionQuality: 0.9) {
2595 parent.onCapture(data)
2596 }
2597 parent.dismiss()
2598 }
2599
2600 func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
2601 parent.dismiss()
2602 }
2603 }
2604}
2605
2606// Full-screen encrypted-video playback — an AVPlayerViewController wrapped so
2607// its letterbox is TRANSPARENT (PlayerSurface): the backdrop is our own layer,
2608// so the dismiss swipe translates only the video surface (scaling down as it
2609// goes) while the black backdrop stays put and fades with the drag — the
2610// Photos grammar the photo viewer already follows. (SwiftUI's VideoPlayer
2611// painted its whole full-screen frame opaque black, so the swipe visibly
2612// dragged the entire lightbox, dim and all.) Bytes still come from memory only.
2613//
2614// Like the photo viewer, the surface ZOOMS OUT OF ITS CARD to full-bleed (the
2615// cover presents with no transition — this view is the whole choreography)
2616// and zooms back into place on close, tap-X and swipe-down alike.
2617//
2618// Rotating the phone rotates the player into landscape fullscreen even though
2619// the app is portrait-locked (VideoOrientation gates the mask while this view
2620// is up), and a YouTube-style expand button covers phones whose rotation lock
2621// blocks auto-rotation (RotationLockSensor infers the lock).
2622private struct VideoViewer: View {
2623 let player: AVPlayer
2624 let sourceFrame: CGRect // the video card, in window coordinates
2625 let onClose: () -> Void
2626
2627 // Drives the scrim and the card↔fullscreen geometry: sprung on in onAppear,
2628 // off in close() with delayed teardown.
2629 @State private var shown = false
2630 @State private var closing = false
2631 @State private var dismissOffset: CGFloat = 0
2632 // Our overlay buttons shadow the system chrome's visibility: any touch on
2633 // the player shows them + restarts the hide timer. We can't SEE the real
2634 // chrome's state (no public API), so we never tap-to-hide — erring visible
2635 // keeps the fullscreen path reachable at the cost of occasionally outliving
2636 // the system fade by a moment.
2637 @State private var controlsVisible = true
2638 @State private var hideGen = 0
2639 @StateObject private var lockSensor = RotationLockSensor()
2640
2641 private var dismissProgress: CGFloat { min(1, abs(dismissOffset) / 300) }
2642 private var chromeShown: Bool { controlsVisible && shown && !closing && dismissOffset == 0 }
2643
2644 var body: some View {
2645 ZStack {
2646 GeometryReader { geo in
2647 stage(in: geo.size)
2648 }
2649 // Full-bleed stage; local coordinates == window coordinates, the
2650 // space sourceFrame is in.
2651 .ignoresSafeArea()
2652 // The chrome keeps its own reader INSIDE the safe area.
2653 GeometryReader { geo in
2654 chrome(landscape: geo.size.width > geo.size.height)
2655 }
2656 .animation(.easeInOut(duration: 0.25), value: chromeShown)
2657 }
2658 .onAppear {
2659 withAnimation(.spring(response: 0.36, dampingFraction: 0.86)) { shown = true }
2660 VideoOrientation.setAllowsLandscape(true)
2661 pokeChrome()
2662 }
2663 // Belt-and-braces: close() already re-locks, but a cover torn down any
2664 // other way must still return the app to portrait-only.
2665 .onDisappear { VideoOrientation.setAllowsLandscape(false) }
2666 }
2667
2668 private func stage(in viewport: CGSize) -> some View {
2669 let full = CGRect(origin: .zero, size: viewport)
2670 // A frame that never got captured (shouldn't happen) degrades to a
2671 // fade-up from center rather than a zoom from nowhere.
2672 let src = sourceFrame.isEmpty
2673 ? CGRect(x: viewport.width / 2, y: viewport.height / 2, width: 0, height: 0)
2674 : sourceFrame
2675 return ZStack {
2676 // The backdrop FADES (the chat shows through the clear cover);
2677 // only the video surface rides the swipe.
2678 Color.black.opacity(shown ? Double(1 - dismissProgress * 0.9) : 0)
2679 // Full-bleed once open (the letterbox is transparent and the
2680 // system chrome insets itself); the zoom interpolates the surface
2681 // from the card's frame, so the video grows out of its bubble.
2682 PlayerSurface(player: player, onTouch: pokeChrome)
2683 .frame(width: shown ? full.width : src.width,
2684 height: shown ? full.height : src.height)
2685 .clipShape(RoundedRectangle(cornerRadius: shown ? 0 : 18, style: .continuous))
2686 .scaleEffect(1 - dismissProgress * 0.15)
2687 .position(x: shown ? full.midX : src.midX,
2688 y: (shown ? full.midY : src.midY) + dismissOffset)
2689 .simultaneousGesture(
2690 DragGesture(minimumDistance: 25)
2691 .onChanged { v in
2692 guard abs(v.translation.height) > abs(v.translation.width) * 1.4
2693 else { return }
2694 dismissOffset = v.translation.height
2695 }
2696 .onEnded { v in
2697 if abs(dismissOffset) > 130 || abs(v.predictedEndTranslation.height) > 450 {
2698 close()
2699 } else {
2700 withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
2701 dismissOffset = 0
2702 }
2703 }
2704 }
2705 )
2706 }
2707 }
2708
2709 @ViewBuilder
2710 private func chrome(landscape: Bool) -> some View {
2711 Button(action: close) {
2712 Image(systemName: "xmark.circle.fill")
2713 .font(.system(size: 28))
2714 .foregroundStyle(.white.opacity(0.8))
2715 .padding()
2716 }
2717 .opacity(chromeShown ? 1 : 0)
2718 .accessibilityLabel("Close")
2719 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
2720 // The YouTube-style fullscreen toggle, tucked bottom-right above
2721 // the transport bar. Portrait shows expand only while rotation
2722 // looks blocked (locked or no signal yet — .unknown defaults to
2723 // showing: a spare button beats no path to fullscreen);
2724 // landscape always shows collapse, because the lock could have
2725 // been flipped on mid-video, stranding auto-rotation back.
2726 if landscape || lockSensor.verdict != .unlocked {
2727 Button {
2728 lockSensor.noteManualRotation()
2729 VideoOrientation.rotate(to: landscape ? .portrait : .landscapeRight)
2730 } label: {
2731 Image(systemName: landscape
2732 ? "arrow.down.right.and.arrow.up.left"
2733 : "arrow.up.left.and.arrow.down.right")
2734 .font(.system(size: 17, weight: .semibold))
2735 .foregroundStyle(.white)
2736 .frame(width: 44, height: 44)
2737 .background(.black.opacity(0.55), in: Circle())
2738 }
2739 .frame(maxWidth: .infinity, maxHeight: .infinity,
2740 alignment: .bottomTrailing)
2741 .padding(.trailing, 16)
2742 .padding(.bottom, 84) // clear the system transport bar
2743 .opacity(chromeShown ? 1 : 0)
2744 .accessibilityLabel(landscape ? "Exit fullscreen" : "Fullscreen")
2745 }
2746 }
2747
2748 // Show the overlay buttons and (while playing) fade them after the same
2749 // few seconds the system chrome lingers; paused keeps them up, as it does.
2750 private func pokeChrome() {
2751 controlsVisible = true
2752 hideGen += 1
2753 let gen = hideGen
2754 DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
2755 guard gen == hideGen, player.timeControlStatus == .playing else { return }
2756 controlsVisible = false
2757 }
2758 }
2759
2760 // Everything springs home together — the surface back into its card, the
2761 // scrim out — then the animation-less cover clears once the zoom-back has
2762 // played. Sound stops up front: a video still talking through its own
2763 // exit is wrong (the YouTube lightbox set that rule).
2764 private func close() {
2765 guard !closing else { return }
2766 closing = true
2767 player.pause()
2768 // Re-lock portrait BEFORE the cover starts leaving, or the chat behind
2769 // gets caught (or stuck) sideways.
2770 VideoOrientation.setAllowsLandscape(false)
2771 if VideoOrientation.interfaceIsLandscape { VideoOrientation.rotate(to: .portrait) }
2772 withAnimation(.spring(response: 0.32, dampingFraction: 0.88)) {
2773 shown = false
2774 dismissOffset = 0
2775 }
2776 DispatchQueue.main.asyncAfter(deadline: .now() + 0.32, execute: onClose)
2777 }
2778}
2779
2780// The AVPlayerViewController host. Two jobs SwiftUI's VideoPlayer can't do:
2781// a CLEAR view background, so the letterbox doesn't paint the whole screen
2782// black (the opaque full-screen surface is what made the dismiss swipe look
2783// like it dragged the entire lightbox); and a non-stealing touch recognizer
2784// that mirrors "the user touched the player" up to SwiftUI, so our overlay
2785// buttons can fade roughly in step with the system chrome.
2786private struct PlayerSurface: UIViewControllerRepresentable {
2787 let player: AVPlayer
2788 let onTouch: () -> Void
2789
2790 func makeCoordinator() -> Coordinator { Coordinator(onTouch: onTouch) }
2791
2792 func makeUIViewController(context: Context) -> AVPlayerViewController {
2793 let vc = AVPlayerViewController()
2794 vc.player = player
2795 vc.showsPlaybackControls = true
2796 vc.updatesNowPlayingInfoCenter = false
2797 vc.view.backgroundColor = .clear
2798 let tap = UITapGestureRecognizer(target: context.coordinator,
2799 action: #selector(Coordinator.tapped))
2800 tap.cancelsTouchesInView = false
2801 tap.delegate = context.coordinator
2802 vc.view.addGestureRecognizer(tap)
2803 return vc
2804 }
2805
2806 func updateUIViewController(_ vc: AVPlayerViewController, context: Context) {
2807 vc.view.backgroundColor = .clear // AVKit relayouts can repaint it black
2808 }
2809
2810 final class Coordinator: NSObject, UIGestureRecognizerDelegate {
2811 let onTouch: () -> Void
2812 init(onTouch: @escaping () -> Void) { self.onTouch = onTouch }
2813 @objc func tapped() { onTouch() }
2814 // Recognise alongside the player's own recognisers — never instead of.
2815 func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
2816 shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool {
2817 true
2818 }
2819 }
2820}
2821
2822// 🤖 The "Convert to thread" confirm, as its own modifier: the chat screen's
2823// body is a long modifier chain and an inline alert pushed the Swift
2824// type-checker past its budget. It confirms at all because a conversion
2825// reorganises the chat for EVERYONE and, in v1, can't be undone — and the
2826// wording is ChatFeedModel.convertConfirm, so web, Mac and iOS ask the same
2827// question.
2828private struct ConvertToThreadConfirm: ViewModifier {
2829 @Binding var target: NativeChatScreen.ConvertTarget?
2830 /// The count, held by the PARENT and set in the same update as `target` —
2831 /// so it is already right when SwiftUI first evaluates the message closure,
2832 /// and it survives the dismiss (by which time `target` is nil). Reading
2833 /// through the optional instead flashed "these 0 replies" at one end or the
2834 /// other.
2835 let count: Int
2836 let convert: (Int64, [Int64]) -> Void
2837
2838 func body(content: Content) -> some View {
2839 content.alert("Convert to Thread", isPresented: Binding(
2840 get: { target != nil }, set: { if !$0 { target = nil } })
2841 ) {
2842 Button("Cancel", role: .cancel) { target = nil }
2843 Button("Move") {
2844 if let t = target { convert(t.root, t.ids) }
2845 target = nil
2846 }
2847 } message: {
2848 Text(ChatFeedModel.convertConfirm(count))
2849 }
2850 }
2851}
2852