eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1import LChatCore
2import SwiftUI
3import AVKit
4import PhotosUI
5import UniformTypeIdentifiers
6
7// The iOS native feed (docs/native-chat.md, iOS adoption): the same shared
8// pieces the Mac takeover renders — ChatFeedModel (parked via
9// NativeFeedModelCache), MessageBubble and friends — in a phone-shaped
10// container. THE chat surface, full stop: plain chats mount it directly
11// (ThreadDestination) and app-thread Chat views mount it through
12// SchemaAppScreen/AppThreadScreen — chat never renders in a webview.
13//
14// Phone-appropriate divergences from the Mac container: actions ride the
15// long-press context menu MessageBubble already provides on iOS (no hover, no
16// NSMenu); photos open a full-screen viewer; encrypted videos play in a
17// full-screen native player (VideoViewer — an AVPlayerViewController; the
18// macOS crash was in the Mac overlay, and SwiftUI's VideoPlayer couldn't do
19// the layered dismiss); attachments come from PhotosPicker + the document
20// picker. Not yet
21// here: Show Times, file-chip export, scroll-back wave.
22struct NativeChatScreen: View {
23 let instance: SavedInstance
24 let thread: ChatThread
25 // Deliberately NOT @ObservedObject: the hitch log showed every ThreadStore
26 // publish (mark-reads, refreshes, any bot activity) re-rendering the whole
27 // feed — 34-88ms stalls mid-scroll. This screen only needs point-in-time
28 // reads (roster for the typeahead, thread state for the ⋮ menu) and calls
29 // methods; the feed itself renders from its own ChatFeedModel.
30 let store: ThreadStore
31 // The app posts into this chat, the member doesn't (the manifest's
32 // chatReadOnly — Admin's update stream): no composer at all, the web's
33 // .chatapp-root.readonly rule.
34 let readOnlyChat: Bool
35 // Non-nil = this is an app thread's Chat view with an Interface to switch
36 // to: the ⋮ menu leads with the Chat ↔ Interface picker rows (the parent
37 // AppThreadScreen/SchemaAppScreen owns the mode).
38 let appView: Binding<String>?
39 @ObservedObject private var model: ChatFeedModel
40 // The navigation path this screen sits on — for surfaces that hop
41 // threads (a profile card's "Message them", like ElevenChatScreen's
42 // "Say hi" chip).
43 @Binding private var path: [Route]
44
45 init(instance: SavedInstance, thread: ChatThread, store: ThreadStore,
46 path: Binding<[Route]>,
47 readOnlyChat: Bool = false, appView: Binding<String>? = nil) {
48 self.instance = instance
49 self.thread = thread
50 self.store = store
51 self._path = path
52 self.readOnlyChat = readOnlyChat
53 self.appView = appView
54 self.model = NativeFeedModelCache.shared.model(for: instance, thread: thread,
55 myUserId: store.myUserId)
56 // Opened from a notification tap: capture the read watermark NOW —
57 // init runs before ThreadDestination's markRead bumps it — so the
58 // settle pass can land the FIRST UNREAD at the top of the view
59 // instead of pinning the bottom.
60 if PushManager.forceScrollThreadID == thread.id {
61 PushManager.forceScrollThreadID = nil
62 let seen = ReadState.lastSeen(instance: instance.id, thread: thread.id)
63 if seen > 0 { _unreadCutoff = State(initialValue: seen) }
64 }
65 // Reopen on the view this group was last showing (chat or a shared app).
66 _groupAppID = State(initialValue: GroupAppMemory.selection[thread.id])
67 // The app-host projection queries (docs/native-apps.md phase 5): the
68 // model asks the thread's command host to project app events it has
69 // no built-in projection for. Lazy — the host webview only spins up
70 // when a thread actually contains such an event.
71 let projInstance = instance, projThread = thread
72 let projUserId = store.myUserId, projIK = store.instanceKey()
73 model.projector = { eventJSON, group in
74 let host = CommandHostCache.shared.host(for: projInstance, thread: projThread,
75 myUserId: projUserId, instanceKeyB64: projIK)
76 return await host.projectEvent(eventJSON, group: group)
77 }
78 }
79
80 // The pre-tap last-seen time; non-nil = this open should anchor unreads.
81 @State private var unreadCutoff: Int64?
82
83 // A search hit's "open the chat ON this message" request, set by the
84 // chat list before pushing this screen.
85 static var pendingJump: (thread: String, message: Int64)?
86 // Shared files (share-sheet handoff) to stage into this chat's composer.
87 static var pendingShare: (thread: String, files: [URL])?
88
89 @State private var draft = ""
90 @State private var caret = 0
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 @State private var nativeTypeahead: (token: ComposerToken, items: [TypeaheadItem])?
104 // A default command's push-back line, generation-tokened so a re-shown
105 // identical prompt is never wiped by an earlier showing's clear timer;
106 // dcRouting keeps one async routing in flight (no double-sent reminders).
107 @State private var routeNotice: String?
108 @State private var routeNoticeGen = 0
109 @State private var dcRouting = false
110 @State private var replyTo: ReplyRef?
111 @State private var editingID: Int64?
112 // Programmatic composer edits (typeahead splices) act on the field via
113 // the controller; the wire markdown is read from it at send (#890).
114 @State private var composerController = ComposerFieldController()
115 @AppStorage("formatAsYouType") private var formatAsYouType = true
116 // The open reply-thread view's root id (nil = closed) and the header
117 // stand-in while that root isn't loaded — frozen at open, like the web's
118 // threadFallback (chat.js openThread's ref argument).
119 @State private var threadRootID: Int64?
120 @State private var threadFallback: ReplyRef?
121 @State private var pickerItem: PhotosPickerItem?
122 @State private var showFilePicker = false
123 @State private var staged: [StagedUpload] = []
124 @State private var sendQueued = false
125 @State private var quickLookItem: QuickLookItem?
126 @State private var playingVideo: PlayingVideo?
127 // A downloaded file staged on disk for Quick Look, plus the temp directory
128 // to burn once the preview closes (decrypted plaintext never lingers).
129 @State private var filePreview: FilePreviewItem?
130 @State private var previewCleanupDir: URL?
131
132 struct FilePreviewItem: Identifiable {
133 let id = UUID()
134 let url: URL
135 }
136
137 struct StagedUpload: Identifiable {
138 let id = UUID()
139 let name: String
140 var preview: UIImage? // image picks/pastes show a real thumbnail
141 var progress = 0.0
142 var attachment: Attachment?
143 }
144
145 struct PlayingVideo: Identifiable {
146 let id: String
147 let player: AVPlayer
148 let loader: MemoryVideoAsset
149 let sourceFrame: CGRect // the video card's window frame — where the zoom starts
150 }
151
152 // Belt-and-braces: any thread with 3+ members shows sender names,
153 // whatever kind it reports.
154 private var isGroup: Bool { thread.kind == "group" || thread.members.count > 2 }
155 private var hasBot: Bool {
156 let members = Set(thread.members)
157 return store.roster.contains { members.contains($0.user_id) && $0.isAgent }
158 }
159
160 // The app-thread's default command (To-do: plain text adds a to-do) — the
161 // web's defaultCommandFor, so both composers route identically. Nil for a
162 // normal chat, an app with no default, or once a bot member is present.
163 private var defaultCommand: AppDefaultCommand.Spec? {
164 AppDefaultCommand.spec(appID: thread.appId, hasBot: hasBot)
165 }
166
167 // "2 members, 1 bot, 2 AI agents · 1 online" — the web group header's
168 // subtitle; the online count is people only (bots' always-on sockets would
169 // pin every room at its bot count).
170 private var memberStats: String? {
171 guard isGroup else { return nil }
172 var bots = 0, agents = 0
173 for id in thread.members {
174 guard let e = store.roster.first(where: { $0.user_id == id }), e.isAgent else { continue }
175 if e.isAI { agents += 1 } else { bots += 1 }
176 }
177 let label = memberCountLabel(people: thread.members.count - bots - agents, bots: bots, agents: agents)
178 let online = store.onlinePeopleCount(of: thread)
179 return online > 0 ? label + " · \(online) online" : label
180 }
181
182 // The DM partner's live status — read from the store (kept current by
183 // presence frames), falling back to the snapshot this screen was built with.
184 private var dmPresence: String? {
185 store.threads.first { $0.id == thread.id }?.presence ?? thread.presence
186 }
187
188 @Environment(\.dismiss) private var dismiss
189 @Environment(\.scenePhase) private var scenePhase
190 @AppStorage("nativeTheme") private var themeName = "classic"
191 // Dynamic Type: read at the system body size (17 at the default setting —
192 // also the web's touch size), so Settings → Text Size scales the feed.
193 @ScaledMetric(relativeTo: .body) private var chatFontSize: CGFloat = 17
194 @State private var avatarPop = false
195 // The DM partner's profile card (rename them for yourself there — #577).
196 @State private var headerProfile: HeaderProfileTarget?
197 struct HeaderProfileTarget: Identifiable { let id: String }
198 // A message's Report action (#578): the reported sender + the message as
199 // evidence, feeding the shared ReportSheet.
200 @State private var reportTarget: ReportTarget?
201 struct ReportTarget: Identifiable {
202 let uid: String
203 let evidence: ReportEvidence
204 var id: Int64 { evidence.sid }
205 }
206 // The header/composer overlay heights (probes above) — the feed extends
207 // under both and rests content between them via content insets.
208 @State private var headerHeight: CGFloat = 0
209 @State private var composerHeight: CGFloat = 0
210
211 @State private var showMembers = false
212 @State private var showRename = false
213 @State private var showGroupApps = false
214 // End conversation (#578): the destructive confirm, and the closed
215 // banner's in-flight / refusal state (inline copy, never an alert).
216 @State private var showEndConfirm = false
217 @State private var reopenBusy = false
218 @State private var reopenNote: String?
219
220 // The group's chosen view for this session: nil = the conversation, an app
221 // id = that shared app's interface (GroupAppMemory keeps it across pushes,
222 // like the web's groupAppView).
223 @State private var groupAppID: String?
224
225 // The apps this group has added (the model's groupapps fold, already
226 // catalog-filtered). Empty for anything that isn't a group.
227 private var groupApps: [GroupApps.Item] { model.groupApps }
228
229 // The selection, validated against the live fold — an app someone removed
230 // while we were looking at it falls back to the conversation.
231 private var activeGroupAppID: String? {
232 guard let id = groupAppID, groupApps.contains(where: { $0.id == id }) else { return nil }
233 return id
234 }
235
236 // The header menu's view picker ("" = chat) — writes through to the
237 // session memory so reopening the chat restores the same view.
238 private var groupViewSelection: Binding<String> {
239 Binding(
240 get: { activeGroupAppID ?? "" },
241 set: { picked in
242 groupAppID = picked.isEmpty ? nil : picked
243 GroupAppMemory.selection[thread.id] = picked.isEmpty ? nil : picked
244 })
245 }
246
247 private var isPinned: Bool {
248 store.threads.first { $0.id == thread.id }?.pinned ?? thread.pinned
249 }
250 private var isUnread: Bool {
251 store.threads.first { $0.id == thread.id }?.unread ?? false
252 }
253 private var isMuted: Bool {
254 store.threads.first { $0.id == thread.id }?.isMuted ?? thread.isMuted
255 }
256
257 // The DM partner's uid — the header opens their profile card (rename them
258 // for yourself there, #577), like tapping the web header title.
259 private var dmPartner: String? {
260 guard thread.kind == "dm" else { return nil }
261 return thread.members.first { $0 != store.myUserId }
262 }
263
264 // The Messages-style header: big avatar level with the
265 // bar buttons, the name in a glass pill tucked under it.
266 private var chatHeader: some View {
267 VStack(spacing: -5) {
268 // Presence on mobile is the avatar ring, not a status line: online
269 // wears a green halo; away/offline/unknown look like any other
270 // header. (Web + Mac keep the dot+word line — they have the room.)
271 // zIndex(1) keeps the ring above the glass capsule below.
272 // Decorative here — the title/pill below carries the name to
273 // VoiceOver, so the avatar needn't be its own element.
274 Avatar(name: thread.title, size: 54, source: thread.avatar,
275 onlineRing: thread.kind == "dm" && dmPresence == "online")
276 .scaleEffect(avatarPop ? 1 : 0.7)
277 .zIndex(1)
278 .accessibilityHidden(true)
279 // In a group the pill is a fast path to the members list — the
280 // counts *read* as tappable, so make them so (View Members also
281 // stays in the ⋯ menu as the labelled/fallback path). The pill is
282 // already ~44pt tall, so no hit-area hacks, and it becomes its own
283 // actionable a11y element instead of a flattened, action-less one.
284 // (Groups own their tap here; the DM whole-header tap below never
285 // fires for them, since memberStats and dmPartner are exclusive.)
286 if memberStats != nil {
287 Button { showMembers = true } label: { headerPill }
288 .buttonStyle(.plain)
289 .accessibilityElement()
290 .accessibilityLabel(headerAccessibilityLabel)
291 .accessibilityHint("View members")
292 .accessibilityAddTraits(.isButton)
293 } else {
294 headerPill
295 }
296 }
297 .frame(maxWidth: .infinity)
298 .padding(.bottom, 6)
299 .offset(y: -52) // rise level with the back button / ⋯ menu
300 // DMs: the whole header taps through to the partner's profile (#577).
301 // Groups handle their own tap on the pill above, so keep them separate
302 // a11y elements (.contain) rather than flattening the pill button away.
303 .accessibilityElement(children: dmPartner != nil ? .combine : .contain)
304 .contentShape(Rectangle())
305 .onTapGesture { if let uid = dmPartner { headerProfile = HeaderProfileTarget(id: uid) } }
306 .accessibilityAddTraits(dmPartner != nil ? .isButton : [])
307 .accessibilityHint(dmPartner != nil ? "Shows their profile" : "")
308 }
309
310 // The name + optional member-stats capsule; extracted so the group case can
311 // wrap it in a Button without duplicating the layout.
312 private var headerPill: some View {
313 VStack(spacing: 0) {
314 Text(thread.title)
315 .font(.headline)
316 .lineLimit(1)
317 .foregroundStyle(.primary)
318 if let stats = memberStats {
319 Text(stats)
320 .font(.system(size: 11))
321 .foregroundStyle(.secondary)
322 .lineLimit(1)
323 }
324 }
325 .padding(.horizontal, 12)
326 .padding(.vertical, 4)
327 // Layering step 1 (2026-07-21): liquid glass back on the pill — the
328 // suspect being tested is the lens's per-frame backdrop sampling
329 // over a scrolling feed.
330 .floatingGlass(in: Capsule())
331 }
332
333 // "Standup, 5 members, 2 agents, 3 online" — the pill's counts spoken
334 // cleanly (" · " reads awkwardly); the "View members" action rides the hint.
335 private var headerAccessibilityLabel: String {
336 guard let stats = memberStats else { return thread.title }
337 return thread.title + ", " + stats.replacingOccurrences(of: " · ", with: ", ")
338 }
339
340 // A group's shared-app surface: schema-described apps (To-do, Reminders)
341 // render the shared instance NATIVE, folded from the group's log; the
342 // rest keep the webview embed.
343 @ViewBuilder private func groupAppSurface(_ appID: String) -> some View {
344 if AppSchemaRegistry.hasSchema(for: appID), !thread.threadKeyB64.isEmpty,
345 let schemaStore = SchemaAppStoreCache.shared.store(
346 for: instance, thread: thread, myUserId: store.myUserId, appId: appID) {
347 SchemaAppView(store: schemaStore)
348 .id(thread.id + "|" + thread.threadKeyB64 + "|" + appID + "|schema")
349 } else {
350 GroupAppPane(instance: instance, thread: thread, myUserId: store.myUserId,
351 instanceKeyB64: store.instanceKey(), app: appID)
352 }
353 }
354
355 var body: some View {
356 Group {
357 // A picked shared app takes the feed's place (iOS has no split —
358 // like the web on a narrow layout); Chat brings the feed back.
359 if let appID = activeGroupAppID {
360 groupAppSurface(appID)
361 } else {
362 feed
363 }
364 }
365 // The thread view lifts over the FEED only (the web's
366 // .chatapp-feedwrap overlay) — applied before the safe-area
367 // insets, so the header pill stays above it and the real composer
368 // below stays live, staged to the thread's root.
369 .overlay {
370 if let rootID = threadRootID {
371 threadOverlay(rootID)
372 .transition(.opacity.combined(with: .offset(y: 12)))
373 }
374 }
375 .navigationTitle("")
376 .navigationBarTitleDisplayMode(.inline)
377 .toolbarBackground(.hidden, for: .navigationBar)
378 .safeAreaInset(edge: .top, spacing: 0) {
379 VStack(spacing: 0) {
380 chatHeader
381 // A DM partner renamed themself past my name for them
382 // (#577): the keep/adopt choice, inline under the header.
383 RenameBanner(store: store, thread: thread)
384 }
385 // Measured for the feed's rest inset (the feed extends under
386 // the header; the probe never shapes layout).
387 .background(GeometryReader { g in
388 Color.clear
389 .onAppear { headerHeight = g.size.height }
390 .onChange(of: g.size.height) { _, h in headerHeight = h }
391 })
392 }
393 .sheet(item: $headerProfile) { target in
394 // Message them hops to (or creates) their DM — hidden when
395 // this chat already IS their DM (the web card's gate).
396 MemberProfileSheet(store: store, uid: target.id,
397 onOpenThread: dmPartner == target.id ? nil : { id in
398 headerProfile = nil
399 path.append(.thread(id))
400 }) { e in
401 Avatar(name: store.displayName(for: e), size: 72,
402 source: store.avatarSource(for: e))
403 }
404 .presentationDetents([.medium])
405 }
406 // Report a message to the admins (#578) — the shared sheet, with
407 // the message as evidence and this thread's client-side name.
408 .sheet(item: $reportTarget) { target in
409 ReportSheet(store: store, uid: target.uid, threadID: thread.id,
410 threadName: thread.title, evidence: target.evidence)
411 .presentationDetents([.medium, .large])
412 }
413 .safeAreaInset(edge: .bottom) {
414 // An app surface owns the whole pane, inputs included — the
415 // chat composer steps aside; a read-only app chat never has
416 // one.
417 if activeGroupAppID == nil, !readOnlyChat {
418 // An ended DM (#578): the server refuses new messages, so
419 // the composer gives way to the closed banner — the one
420 // honest action left is Reopen.
421 Group {
422 if model.dmClosed {
423 dmClosedBanner
424 } else {
425 composerCluster
426 }
427 }
428 // Measured for the feed's rest inset — the feed extends
429 // under the glass; content must rest above it.
430 .background(GeometryReader { g in
431 Color.clear
432 .onAppear { composerHeight = g.size.height }
433 .onChange(of: g.size.height) { _, h in composerHeight = h }
434 })
435 }
436 }
437 .toolbar {
438 ToolbarItem(placement: .primaryAction) {
439 Menu {
440 // An app thread's Chat ↔ Interface switch — dropdown
441 // picker rows, never bar tabs (the parent screen owns
442 // the mode; picking Interface swaps this screen out).
443 if let appView {
444 AppViewPicker(store: store, thread: thread, view: appView)
445 }
446 // The group view picker — the same shape, grown to
447 // list every app this group has added.
448 if !groupApps.isEmpty {
449 Picker("View", selection: groupViewSelection) {
450 Label("Chat", systemImage: "bubble.left").tag("")
451 ForEach(groupApps) { app in
452 // Every row a Label with an SF-symbol
453 // icon: iOS menus align Labels on one
454 // grid but silently DROP non-image icons
455 // (the emoji glyphs never rendered).
456 Label(model.registry.title(app.id),
457 systemImage: AppIdentity.symbol(for: app.id) ?? "square.grid.2x2")
458 .tag(app.id)
459 }
460 }
461 .pickerStyle(.inline)
462 }
463 // Group management (Mac parity: View Members / Rename).
464 if isGroup {
465 Button {
466 showMembers = true
467 } label: {
468 Label("View Members", systemImage: "person.2")
469 }
470 Button {
471 showRename = true
472 } label: {
473 Label("Rename Group", systemImage: "pencil")
474 }
475 Button {
476 showGroupApps = true
477 } label: {
478 Label("Manage Apps", systemImage: "square.grid.2x2")
479 }
480 }
481 Button {
482 store.setPinned(thread, !isPinned)
483 } label: {
484 Label(isPinned ? "Unpin chat" : "Pin chat",
485 systemImage: isPinned ? "pin.slash" : "pin")
486 }
487 Button {
488 store.setMuted(thread, !isMuted)
489 } label: {
490 Label(isMuted ? "Unmute chat" : "Mute chat",
491 systemImage: isMuted ? "bell" : "bell.slash")
492 }
493 Button {
494 isUnread ? store.markRead(thread) : store.markUnread(thread)
495 } label: {
496 Label(isUnread ? "Mark as Read" : "Mark as Unread",
497 systemImage: isUnread ? "envelope.open" : "envelope.badge")
498 }
499 Button(role: .destructive) {
500 store.setHidden(thread, true)
501 dismiss()
502 } label: {
503 Label("Close chat", systemImage: "xmark.circle")
504 }
505 // End conversation (#578): DM-only, stronger than Close
506 // chat — mutual and server-enforced, hence the confirm.
507 // Once ended, the banner's Reopen is the way back.
508 if thread.kind == "dm", !model.dmClosed {
509 Button(role: .destructive) {
510 showEndConfirm = true
511 } label: {
512 Label("End conversation", systemImage: "nosign")
513 }
514 }
515 } label: {
516 Image(systemName: "ellipsis.circle")
517 }
518 .accessibilityLabel("Chat options")
519 }
520 }
521 .confirmationDialog("End this conversation?",
522 isPresented: $showEndConfirm, titleVisibility: .visible) {
523 Button("End conversation", role: .destructive) {
524 Task {
525 // Optimistic in the store; a refused POST reverts and
526 // we simply stay in the chat, unchanged.
527 if await store.endConversation(thread) { dismiss() }
528 }
529 }
530 } message: {
531 Text("It closes for both of you, and no new messages can be sent unless someone reopens it.")
532 }
533 .sheet(isPresented: $showMembers) {
534 MembersSheet(store: store, thread: thread,
535 onOpenThread: { id in
536 showMembers = false
537 path.append(.thread(id))
538 })
539 }
540 .sheet(isPresented: $showRename) { RenameGroupSheet(store: store, thread: thread) }
541 .sheet(isPresented: $showGroupApps) { GroupAppsSheet(model: model) }
542 .onAppear {
543 model.start()
544 LastOpenedChat.note(origin: instance.origin, thread: thread.id,
545 title: thread.title)
546 // Share-sheet handoff: stage each shared file as an upload
547 // (encrypt + upload run exactly like a picked photo), then
548 // burn the inbox copies. The reads run detached — a shared
549 // video is whole-file IO, and this fires during the chat-open
550 // layout crunch.
551 if let share = Self.pendingShare, share.thread == thread.id {
552 Self.pendingShare = nil
553 Task {
554 for url in share.files {
555 guard let data = await Task.detached(operation: {
556 try? Data(contentsOf: url)
557 }).value else { continue }
558 let ext = url.pathExtension.lowercased()
559 if ["mp4", "mov", "m4v"].contains(ext) {
560 stageVideoData(data, name: url.lastPathComponent)
561 } else {
562 stageImageData(data, name: url.lastPathComponent)
563 }
564 }
565 ShareInbox.clear(share.files)
566 }
567 }
568 DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
569 withAnimation(.spring(response: 0.42, dampingFraction: 0.45)) { avatarPop = true }
570 }
571 }
572 // Tapping a photo opens it in Quick Look — which zooms out of the
573 // tapped bubble (and back on dismiss), and carries Apple's own
574 // chrome plus the system Share / Save to Photos / Markup buttons.
575 // An invisible presenter drives QLPreviewController imperatively from
576 // the thumbnail's frame; the decrypted temp file is burned on
577 // dismiss (cleanupPreview), so plaintext never lingers.
578 .background(ImageQuickLookPresenter(item: $quickLookItem) {
579 cleanupPreview()
580 quickLookItem = nil
581 })
582 .fullScreenCover(item: $playingVideo) { item in
583 VideoViewer(player: item.player, sourceFrame: item.sourceFrame) {
584 coverWithoutSlide { playingVideo = nil }
585 }
586 .presentationBackground(.clear)
587 }
588 // Tapping a file chip previews it in Quick Look (with the system
589 // share button) once its bytes are decrypted to a temp file.
590 .sheet(item: $filePreview, onDismiss: cleanupPreview) { item in
591 QuickLookPreview(url: item.url) { filePreview = nil }
592 .ignoresSafeArea()
593 }
594 // /themes opens the per-server settings, where the theme picker lives.
595 .sheet(isPresented: $showThemeSettings) {
596 ServerSettingsSheet(store: store)
597 }
598 // /integrity read-out (tamper evidence). A private, in-place answer,
599 // like the web command's notice.
600 .alert("Integrity check", isPresented: .init(
601 get: { integrityReport != nil }, set: { if !$0 { integrityReport = nil } })
602 ) {
603 Button("OK", role: .cancel) { integrityReport = nil }
604 } message: {
605 Text(integrityReport ?? "")
606 }
607 }
608
609 // The viewers own their whole choreography — the photo zooms out of (and
610 // back into) its bubble, the video flies off in its swipe direction — so
611 // the cover must both APPEAR and VANISH without its default slide, or the
612 // system transition visibly fights the viewer's own animation.
613 private func coverWithoutSlide(_ change: @escaping () -> Void) {
614 var t = Transaction()
615 t.disablesAnimations = true
616 withTransaction(t, change)
617 }
618
619 // A tapped photo: decrypt the ORIGINAL blob to a temp file (Quick Look reads
620 // by URL and renders the full-fidelity image itself), then open the preview
621 // zooming out of the tapped thumbnail. `thumbnail` is the zoom-transition
622 // anchor; `sourceFrame` is where it zooms from / back to. Reuses the same
623 // temp-file staging + cleanup the file preview uses (a photo and a file tap
624 // are mutually exclusive, so they share previewCleanupDir safely).
625 private func openQuickLook(_ att: Attachment, thumbnail: PlatformImage, sourceFrame: CGRect) {
626 guard interaction.blobDownload == nil else { return }
627 interaction.blobDownload = (att.id, 0)
628 let started = Date()
629 Task {
630 let data = await model.blobData(att) { p in
631 if Date().timeIntervalSince(started) > 0.15 { interaction.blobDownload = (att.id, p) }
632 }
633 interaction.blobDownload = nil
634 guard let data,
635 let url = stagePreviewFile(
636 data, name: AttachmentDownload.safeFilename(name: att.name, mime: att.type))
637 else {
638 setRouteNotice("Couldn’t open that photo.")
639 return
640 }
641 // Returning from Quick Look re-fires the feed's onAppear, but
642 // nothing re-pins on appear any more — the scroll position simply
643 // survives the modal, so the viewed photo stays on screen.
644 quickLookItem = QuickLookItem(id: att.id, url: url,
645 thumbnail: thumbnail, sourceFrame: sourceFrame)
646 }
647 }
648
649 // ---- feed --------------------------------------------------------------
650
651 // The cross-row interaction state (swipe drags, times peek, jump wave,
652 // download progress). Plain @State, NOT @StateObject — only the row views
653 // observe it, so a 60Hz drag re-renders the visible cells and never this
654 // whole screen.
655 @State private var interaction = FeedInteraction()
656 // The screen's handle on the feed's scroll position (jumps, the button).
657 @State private var feedController = FeedScrollController()
658 // The open's "tap → usable feed" measurement (#1210): begun when the feed
659 // first appears, ended by the container's first-layout callback — the
660 // frame the reader actually sees, cold fetch included.
661 @State private var mountSpan: Perf.Span?
662 @State private var mountEnded = false
663
664 // A search hit lands ON its message: page history until the target is
665 // loaded, then jump-and-wave. (The open still lands at the bottom first —
666 // the walk is bounded so a hit deeper than ~8 pages stays un-jumped
667 // rather than risking a watchdog-length stall.)
668 private func runPendingJump() {
669 guard let j = Self.pendingJump, j.thread == thread.id, model.ready else { return }
670 Self.pendingJump = nil
671 Task {
672 var tries = 0
673 while !model.messages.contains(where: { $0.id == j.message }),
674 !model.exhausted, tries < 8 {
675 tries += 1
676 await model.loadOlder()
677 try? await Task.sleep(nanoseconds: 550_000_000)
678 }
679 guard model.messages.contains(where: { $0.id == j.message }) else { return }
680 try? await Task.sleep(nanoseconds: 150_000_000)
681 jumpAndWave(j.message)
682 }
683 }
684
685 // The feed is a STOCK, unflipped UICollectionView (StockChatFeed —
686 // promoted from the /freshpane experiment, which scrolled silky on
687 // device where the flipped container's dressing didn't). Standard safe
688 // areas: SwiftUI sizes it between the header and the composer/keyboard,
689 // and the container keeps the bottom anchored across those resizes.
690 // This screen only supplies the chrome around it and the row hooks.
691 private var feed: some View {
692 ZStack {
693 StockChatFeed(model: model,
694 interaction: interaction,
695 controller: feedController,
696 hooks: feedHooks,
697 topOverlay: headerHeight,
698 bottomOverlay: composerHeight,
699 composerFocused: composerFocused,
700 unreadCutoff: unreadCutoff,
701 onFirstLayout: { count in
702 mountEnded = true
703 guard let span = mountSpan else { return }
704 mountSpan = nil
705 span.end("\(count) messages")
706 })
707 // Full-bleed both ways, the Messages way: the container is a
708 // view CONTROLLER declaring the glass overlays as REAL safe
709 // areas (additionalSafeAreaInsets + automatic adjustment), so
710 // the system's top-edge treatment gets honest geometry and
711 // degrades to the subtle status-bar fade — every manual-inset
712 // variant fed it garbage and it squashed rows over half the
713 // screen. The keyboard safe area stays live (SwiftUI shrinks
714 // the feed for it; the container keeps the bottom anchored
715 // through the resize).
716 .ignoresSafeArea(.container, edges: .vertical)
717 // Cold loads show the loader; the first layout already lands at
718 // the bottom, so there is no settle pass left to hide.
719 if !model.ready {
720 ProgressView()
721 }
722 }
723 // The open, foregrounded chat is being read — arrivals here must not
724 // accrue an unread badge.
725 .onChange(of: model.messages.last?.id) { _, _ in
726 if scenePhase == .active,
727 let t = store.threads.first(where: { $0.id == thread.id }), t.unread {
728 store.markRead(t)
729 }
730 }
731 // A search hit can arrive before or after history folds — cover both.
732 .onChange(of: model.ready) { _, ready in
733 if ready { runPendingJump() }
734 }
735 // Foregrounding with this chat on screen: merge whatever the NSE
736 // filed while we were suspended straight from disk — the pushed
737 // message shows before the socket reconnects and re-pulls.
738 .onChange(of: scenePhase) { _, phase in
739 guard phase == .active else { return }
740 Task { await model.catchUpFromStore() }
741 }
742 .onAppear {
743 // The mount clock starts at the OPENING appear only — modal
744 // returns (Quick Look, the video viewer) re-fire onAppear on an
745 // already-landed feed and must not restart it.
746 if mountSpan == nil, !mountEnded {
747 mountSpan = Perf.begin("feed.mount", slowMs: 500)
748 }
749 runPendingJump()
750 }
751 }
752
753 // Everything a feed row needs from this screen, resolved at cell-configure
754 // time (rows never observe the model — see FeedHooks).
755 private var feedHooks: FeedHooks {
756 FeedHooks(isGroup: isGroup,
757 themeName: themeName,
758 chatFontSize: chatFontSize,
759 senderName: { model.senderName($0) },
760 foldsAsMuted: { model.foldsAsMuted($0) },
761 revealMuted: { model.revealMuted($0) },
762 actions: { messageActions($0) },
763 onJump: { jumpAndWave($0) },
764 onOpenThread: { openThread($0) },
765 blobSource: { model.blobSource($0) },
766 onImageTap: { att, image, frame in
767 openQuickLook(att, thumbnail: image, sourceFrame: frame)
768 },
769 onFileTap: { openFile($0) },
770 onVideoTap: { att, frame in playVideo(att, from: frame) },
771 unfurl: { await model.unfurl($0) },
772 peekUnfurl: { model.cachedUnfurl($0) },
773 startReply: { startReply($0) },
774 mentionIndex: { model.mentionIndex },
775 // A tapped @mention opens the same profile card the DM
776 // header does — Message them, rename, mute, block (#577/8).
777 onMentionTap: { headerProfile = HeaderProfileTarget(id: $0) })
778 }
779
780 // ---- reply-thread view (chat.js openThread/closeThread/stageThreadReply) --
781
782 // The lifted thread panel over the dimmed feed. Rows reuse the exact
783 // MessageBubble wiring the feed uses, so reactions, attachments and the
784 // long-press menu keep working inside the view; reply actions in here
785 // resolve to the thread's ROOT via the same startReply path.
786 private func threadOverlay(_ rootID: Int64) -> some View {
787 ReplyThreadView(model: model, rootID: rootID, fallback: threadFallback,
788 theme: .named(themeName), isGroup: isGroup,
789 swipeDownCloses: true,
790 onClose: closeThread,
791 onRootPaged: {
792 // The root just paged in — refresh the banner to its
793 // live snapshot, unless an edit borrowed the composer.
794 if editingID == nil, replyTo?.id == rootID { stageThreadReply() }
795 },
796 row: { m, showSender in threadBubble(m, showSender: showSender) })
797 }
798
799 @ViewBuilder
800 private func threadBubble(_ m: ChatFeedModel.Message, showSender: Bool) -> some View {
801 // The muted fold holds inside the thread view too (#578).
802 if model.foldsAsMuted(m) {
803 MutedMessageStub { model.revealMuted(m.id) }
804 } else {
805 threadMessageBubble(m, showSender: showSender)
806 }
807 }
808
809 private func threadMessageBubble(_ m: ChatFeedModel.Message, showSender: Bool) -> some View {
810 // Wrapped in the interaction reader: the screen deliberately doesn't
811 // observe FeedInteraction, but the overlay's download rings still need
812 // live progress.
813 FeedInteractionReader(interaction: interaction) { inter in
814 MessageBubble(message: m, showSender: showSender,
815 senderName: model.senderName(m),
816 theme: .named(themeName),
817 blobSource: { model.blobSource($0) },
818 onImageTap: { att, image, frame in
819 openQuickLook(att, thumbnail: image, sourceFrame: frame)
820 },
821 onFileTap: { openFile($0) },
822 onVideoTap: { att, frame in playVideo(att, from: frame) },
823 downloading: inter.blobDownload,
824 actions: messageActions(m),
825 unfurl: { await model.unfurl($0) })
826 .environment(\.chatFontSize, chatFontSize)
827 .environment(\.mentionIndex, model.mentionIndex)
828 .environment(\.openURL, OpenURLAction { url in
829 guard let uid = MentionLink.uid(from: url) else { return .systemAction }
830 headerProfile = HeaderProfileTarget(id: uid)
831 return .handled
832 })
833 }
834 }
835
836 // One catalog for the feed and the thread view — reply/edit/delete/copy/
837 // react, plus Mute/Unmute-sender (#578) on others' uid-carrying messages
838 // (uid-less legacy messages have nothing to key a mute on).
839 private func messageActions(_ m: ChatFeedModel.Message) -> MessageActions {
840 var actions = MessageActions(
841 canEdit: model.canEdit(m),
842 onReply: { startReply(m) },
843 onEdit: { startEdit(m) },
844 onDelete: { model.deleteMessage(m.id) },
845 onCopy: { UIPasteboard.general.string = m.text },
846 onReact: { model.toggleReaction(m.id, emoji: $0) })
847 if !m.mine, let uid = m.raw.uid, !uid.isEmpty {
848 let muted = model.mutedPeople.contains(uid)
849 actions.senderMuted = muted
850 actions.senderLabel = model.senderName(m)
851 actions.onMutePerson = {
852 Task { await store.setPersonMuted(uid: uid, muted: !muted) }
853 }
854 // Report to the space's admins (#578) — same gate as the mute.
855 actions.onReport = {
856 reportTarget = ReportTarget(uid: uid, evidence: ReportEvidence(message: m))
857 }
858 }
859 return actions
860 }
861
862 private func openThread(_ rootID: Int64) {
863 guard rootID > 0, threadRootID != rootID else { return }
864 // Freeze the header stand-in NOW (web threadFallback) — the deepest
865 // quoted snapshot in loaded state, in case the root never pages in.
866 threadFallback = model.threadReplyRef(root: rootID)
867 withAnimation(.easeOut(duration: 0.18)) { threadRootID = rootID }
868 stageThreadReply()
869 }
870
871 // Stage the composer's reply on the thread's root — the view's composer
872 // contract ("Replying to <root author>"). A reply displaces an in-flight
873 // edit, but the keyboard is deliberately NOT popped: it would cover the
874 // thread the reader just opened (the web skips focus on touch too).
875 private func stageThreadReply() {
876 guard let rootID = threadRootID else { return }
877 editingID = nil
878 replyTo = model.threadReplyRef(root: rootID)
879 }
880
881 private func closeThread() {
882 guard let rootID = threadRootID else { return }
883 withAnimation(.easeOut(duration: 0.15)) { threadRootID = nil }
884 threadFallback = nil
885 // Leaving the thread drops its staged reply — never an unrelated one
886 // staged since (an edit clears replyTo itself, so that survives).
887 if replyTo?.id == rootID { replyTo = nil }
888 }
889
890 // Scroll to a message and wave it — quote-taps and search hits share this.
891 // The near/far animation split lives in the container's scrollTo.
892 private func jumpAndWave(_ id: Int64) {
893 feedController.scrollTo(id: id)
894 DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
895 interaction.danceID = id
896 interaction.danceTick += 1
897 }
898 }
899
900 private func playVideo(_ att: Attachment, from frame: CGRect) {
901 guard interaction.blobDownload == nil else { return }
902 interaction.blobDownload = (att.id, 0)
903 Task {
904 let data = await model.blobData(att) { p in interaction.blobDownload = (att.id, p) }
905 interaction.blobDownload = nil
906 guard let data else { return }
907 let loader = MemoryVideoAsset(data: data, mimeType: att.type)
908 let player = AVPlayer(playerItem: AVPlayerItem(asset: loader.makeAsset()))
909 // The frame is the card's at TAP time — good enough even though the
910 // download ran in between (the feed can't scroll under the cover).
911 coverWithoutSlide {
912 playingVideo = PlayingVideo(id: att.id, player: player, loader: loader,
913 sourceFrame: frame)
914 }
915 player.play()
916 }
917 }
918
919 // A file chip tap: fetch + decrypt the blob (the chip grows a progress ring
920 // meanwhile), stage it to a temp file, and open Quick Look — whose share
921 // button gives Save to Files / AirDrop / open-in. Writing plaintext to a
922 // temp dir is fine here: it's the member's deliberate act (like the web's
923 // file-chip download), and the dir is burned when the preview closes.
924 private func openFile(_ att: Attachment) {
925 guard interaction.blobDownload == nil else { return }
926 interaction.blobDownload = (att.id, 0)
927 // The web's 150ms grace: a fetch that finishes instantly never flashes
928 // a ring — only one that outlives the grace grows it.
929 let started = Date()
930 Task {
931 let data = await model.blobData(att) { p in
932 if Date().timeIntervalSince(started) > 0.15 { interaction.blobDownload = (att.id, p) }
933 }
934 interaction.blobDownload = nil
935 guard let data,
936 let dest = stagePreviewFile(
937 data, name: AttachmentDownload.safeFilename(name: att.name, mime: att.type))
938 else {
939 setRouteNotice("Couldn’t open that file.")
940 return
941 }
942 filePreview = FilePreviewItem(url: dest)
943 }
944 }
945
946 // Write decrypted bytes into a fresh temp subdirectory so the file keeps its
947 // real name (Quick Look titles + previews by filename). Returns the file URL
948 // and records the directory for cleanup.
949 private func stagePreviewFile(_ data: Data, name: String) -> URL? {
950 let dir = FileManager.default.temporaryDirectory
951 .appendingPathComponent("preview-\(UUID().uuidString)", isDirectory: true)
952 do {
953 try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
954 let dest = dir.appendingPathComponent(name)
955 try data.write(to: dest, options: .atomic)
956 previewCleanupDir = dir
957 return dest
958 } catch {
959 return nil
960 }
961 }
962
963 private func cleanupPreview() {
964 guard let dir = previewCleanupDir else { return }
965 try? FileManager.default.removeItem(at: dir)
966 previewCleanupDir = nil
967 }
968
969 // ---- composer ------------------------------------------------------------
970
971 // The ended-DM banner that takes the composer's place (#578): inline in
972 // the composer's layout slot — never floating over the feed. A refused
973 // reopen swaps the line for its reason (their DM door / a blip), the
974 // web's dm-closed-bar behaviour.
975 private var dmClosedBanner: some View {
976 HStack(spacing: 12) {
977 Text(reopenNote ?? "This conversation is closed.")
978 .font(.subheadline)
979 .foregroundStyle(.secondary)
980 .frame(maxWidth: .infinity, alignment: .leading)
981 Button("Reopen") {
982 reopenNote = nil
983 reopenBusy = true
984 Task {
985 switch await store.reopenDM(thread.id) {
986 case .ok:
987 break // the flag flips (store + model); the composer returns
988 case .dmsClosed:
989 reopenNote = "They're not accepting direct messages right now."
990 case .failed:
991 reopenNote = "Couldn't reopen — try again."
992 }
993 reopenBusy = false
994 }
995 }
996 .buttonStyle(.borderedProminent)
997 .disabled(reopenBusy)
998 .accessibilityHint("Reopens this conversation for both of you")
999 }
1000 .padding(.horizontal, 16)
1001 .padding(.vertical, 12)
1002 .background(.bar)
1003 }
1004
1005 private var composerCluster: some View {
1006 VStack(spacing: 6) {
1007 if let ta = nativeTypeahead {
1008 typeaheadStrip(ta.items)
1009 }
1010 // The command panel — a command's transient interactive surface
1011 // (/emoji, /gif), the webview revealed above the composer while a
1012 // panel is showing. Mounted permanently once a host exists (height
1013 // 0 when closed) so the page loads and keeps its state — the Mac
1014 // composer's exact shape.
1015 if let commandHost {
1016 CommandPanelWebView(host: commandHost)
1017 .frame(height: panelVisible ? panelHeight : 0)
1018 .clipShape(RoundedRectangle(cornerRadius: panelVisible ? 12 : 0))
1019 .padding(.horizontal, panelVisible ? 10 : 0)
1020 .accessibilityHidden(!panelVisible)
1021 }
1022 // A default command pushing back ("when should I remind you?") —
1023 // inline above the composer, occupying layout, never floating.
1024 if let routeNotice {
1025 Text(routeNotice)
1026 .font(.footnote)
1027 .foregroundStyle(.secondary)
1028 .frame(maxWidth: .infinity, alignment: .leading)
1029 .padding(.horizontal, 16)
1030 }
1031 if let replyTo {
1032 banner("Replying to \(replyTo.name ?? "")", detail: replyTo.snippet) {
1033 // While the thread view is open its staged reply IS the
1034 // view's composer state — cancelling it means leaving the
1035 // thread (web parity), not stranding an open thread whose
1036 // sends would land outside it.
1037 if threadRootID != nil { closeThread() } else { self.replyTo = nil }
1038 }
1039 }
1040 if editingID != nil {
1041 banner("Editing message", detail: nil) { cancelEdit() }
1042 }
1043 if !staged.isEmpty {
1044 stagedChips
1045 }
1046 composerRow
1047 }
1048 .padding(.top, 4)
1049 }
1050
1051 // The floating-glass composer: the field self-sizes
1052 // (GrowingTextView), and it shares one glass capsule with the send button.
1053 private var composerRow: some View {
1054 HStack(alignment: .bottom, spacing: 8) {
1055 // A default-command thread is text-only — with no one to read a
1056 // file, an attachment is a dead end, so the affordance goes (the
1057 // web composer hides its attach button the same way).
1058 if defaultCommand == nil {
1059 Menu {
1060 // Simulators (and some iPads) have no camera — the row
1061 // just doesn't exist there rather than failing on tap.
1062 if UIImagePickerController.isSourceTypeAvailable(.camera) {
1063 Button { showCamera = true } label: {
1064 Label("Camera", systemImage: "camera")
1065 }
1066 }
1067 Button { showPhotoPicker = true } label: {
1068 Label("Photo or Video", systemImage: "photo")
1069 }
1070 Button { showFilePicker = true } label: {
1071 Label("File", systemImage: "doc")
1072 }
1073 } label: {
1074 Image(systemName: "plus")
1075 .font(.system(size: 18, weight: .medium))
1076 .frame(width: 36, height: 36)
1077 .floatingGlass(in: Circle())
1078 }
1079 .buttonStyle(.plain)
1080 }
1081
1082 HStack(alignment: .bottom, spacing: 6) {
1083 // sendOnReturn: false — touch Enter newlines, the send button
1084 // sends (the shouldSend rule).
1085 ComposerField(text: $draft, focused: $composerFocused, caret: $caret,
1086 placeholder: defaultCommand?.placeholder ?? "Message",
1087 sendOnReturn: false,
1088 formatAsYouType: formatAsYouType,
1089 controller: composerController,
1090 onSend: sendDraft,
1091 onPasteImage: { if defaultCommand == nil { stageImageData($0) } },
1092 onEditLast: {
1093 if let last = model.messages.last(where: { $0.mine && !$0.action }) {
1094 startEdit(last)
1095 }
1096 })
1097 .padding(.leading, 14)
1098 .padding(.vertical, 8)
1099 .onChange(of: draft) { _, _ in
1100 refreshTypeahead()
1101 model.typingBeat(active: !draft.isEmpty && editingID == nil)
1102 // Pre-warm the command host the moment a command might
1103 // be coming — its page load happens during typing, and
1104 // its rows upgrade the "/" typeahead (Mac parity).
1105 if draft.first == "/", commandHost == nil { ensureCommandHost() }
1106 }
1107 .onChange(of: caret) { _, _ in refreshTypeahead() }
1108
1109 Button(action: sendDraft) {
1110 Image(systemName: "arrow.up.circle.fill")
1111 .font(.system(size: 28))
1112 .foregroundStyle(canSend ? Color.accentColor : Color(.tertiaryLabel))
1113 // 44pt target — a near-miss must send, not close the keyboard.
1114 .frame(width: 44, height: 40)
1115 .contentShape(Rectangle())
1116 }
1117 .buttonStyle(.plain)
1118 .disabled(!canSend)
1119 .accessibilityLabel("Send")
1120 }
1121 .floatingGlass(in: RoundedRectangle(cornerRadius: 22))
1122 }
1123 .padding(.horizontal, 10)
1124 .padding(.vertical, 8)
1125 .photosPicker(isPresented: $showPhotoPicker, selection: $pickerItem, matching: .any(of: [.images, .videos]))
1126 .onChange(of: pickerItem) { _, item in
1127 guard let item else { return }
1128 pickerItem = nil
1129 stagePickerItem(item)
1130 }
1131 .fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.item]) { result in
1132 if case .success(let url) = result { stageFile(url) }
1133 }
1134 // A fresh capture stages exactly like a pasted image — the upload
1135 // path's processImage downscales and strips EXIF/location before
1136 // encryption, same as every other photo source.
1137 .fullScreenCover(isPresented: $showCamera) {
1138 CameraCapture { data in stageImageData(data, name: "photo.jpg") }
1139 .ignoresSafeArea()
1140 }
1141 // Drop-to-attach (iPad split view, iPhone app-to-app drags): images,
1142 // movies and files stage exactly like the pickers' choices. The system
1143 // drag badge is the whole affordance — no overlay (matching the Mac).
1144 .onDrop(of: defaultCommand == nil ? [.image, .movie, .fileURL] : [],
1145 isTargeted: nil) { providers in
1146 stageDroppedProviders(providers)
1147 return true
1148 }
1149 }
1150
1151 // One provider pipeline for drops: prefer a real file (name + full
1152 // quality), else raw image/movie data with the provider's suggested name.
1153 private func stageDroppedProviders(_ providers: [NSItemProvider]) {
1154 for provider in providers {
1155 if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
1156 _ = provider.loadObject(ofClass: URL.self) { url, _ in
1157 if let url { DispatchQueue.main.async { stageFile(url) } }
1158 }
1159 continue
1160 }
1161 if provider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
1162 provider.loadDataRepresentation(forTypeIdentifier: UTType.movie.identifier) { data, _ in
1163 guard let data else { return }
1164 let name = provider.suggestedName ?? "video"
1165 DispatchQueue.main.async { stageVideoData(data, name: name) }
1166 }
1167 continue
1168 }
1169 if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
1170 provider.loadDataRepresentation(forTypeIdentifier: UTType.image.identifier) { data, _ in
1171 guard let data else { return }
1172 let name = provider.suggestedName ?? "photo.jpg"
1173 DispatchQueue.main.async { stageImageData(data, name: name) }
1174 }
1175 }
1176 }
1177 }
1178
1179 @State private var showPhotoPicker = false
1180 @State private var showCamera = false
1181
1182 private var canSend: Bool {
1183 let hasText = !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
1184 let uploading = staged.contains { $0.attachment == nil }
1185 return !uploading && (hasText || staged.contains { $0.attachment != nil })
1186 }
1187
1188 // Show (or clear, with nil) the default-command push-back. Every showing
1189 // bumps the generation, so an in-flight clear timer from an EARLIER
1190 // showing — even of the identical string — can never wipe this one early.
1191 private func setRouteNotice(_ text: String?) {
1192 routeNoticeGen += 1
1193 routeNotice = text
1194 guard text != nil else { return }
1195 let gen = routeNoticeGen
1196 DispatchQueue.main.asyncAfter(deadline: .now() + 8) {
1197 if routeNoticeGen == gen { routeNotice = nil }
1198 }
1199 }
1200
1201 // The wire form of the draft: serialized markdown when the field is
1202 // mounted (styled runs become markers — the vector-pinned twin), the raw
1203 // draft otherwise. Commands and routing above always read `draft` (the
1204 // display text), mirroring the web's plain/markdown split.
1205 private var wireDraft: String {
1206 // Opted out of format-as-you-type = typed markers are literal and
1207 // must render literally: escape them; only the WYSIWYG formats.
1208 let wire = composerController.serializedMarkdown(escapeLiterals: !formatAsYouType)
1209 return wire.isEmpty ? draft : wire
1210 }
1211
1212 private func sendDraft() {
1213 if let id = editingID {
1214 if model.edit(id, text: wireDraft) { cancelEdit() }
1215 return
1216 }
1217 guard canSend else { return }
1218 // Slash commands (bare text only): /me broadcasts an action line,
1219 // /theme applies a theme locally, /themes opens Settings; anything
1220 // else falls through to a normal send so a bot answers it.
1221 if staged.isEmpty, replyTo == nil, draft.first == "/" {
1222 switch SlashCommand.parse(draft, meName: model.myScreenName) {
1223 case .me(let line):
1224 if model.sendAction(line) { draft = "" }
1225 return
1226 case .setTheme(let id):
1227 themeName = id
1228 draft = ""
1229 return
1230 case .openThemes:
1231 draft = ""
1232 showThemeSettings = true
1233 return
1234 case .integrity:
1235 draft = ""
1236 integrityReport = (model.integrity?.reportLines()).map { $0.joined(separator: "\n") }
1237 ?? "Integrity monitoring isn't active for this space."
1238 return
1239 case .help, .sendAsMessage:
1240 // Not the native floor. The current app-thread's own verbs
1241 // stay native (the default-command block below); everything
1242 // else consults the thread's commandItems() rows: a
1243 // bot-answered command rides to the conversation as a
1244 // message, and every framework-owned command — /emoji, /gif,
1245 // /todo, a custom app's verb, even a typo (the web engine
1246 // answers "Unknown command — try /help" as a private notice
1247 // instead of posting the literal) — forwards to the command
1248 // host. Cleared immediately, like the web composer — a
1249 // booting page queues the text.
1250 let ownVerb = defaultCommand.map {
1251 AppDefaultCommand.routing(draft, spec: $0) != .passthrough
1252 } ?? false
1253 if !ownVerb {
1254 let host = ensureCommandHost()
1255 if CommandRouting.route(draft, rows: hostRows.isEmpty ? host.commandRows : hostRows) == .host {
1256 cmdLog.notice("forwarding to the command host: \(draft, privacy: .public)")
1257 host.run(draft)
1258 draft = ""
1259 return
1260 }
1261 }
1262 break
1263 }
1264 }
1265 // Default-command thread (To-do, Reminders): plain text — or one of the
1266 // app's own verbs — becomes the app event instead of a chat message
1267 // nobody reads; a bare verb is consumed, never posted. Async because
1268 // Reminders schedules server-side first; the sync routing() pre-check
1269 // decides whether the draft is ours at all (passthrough → normal send).
1270 // The routing contract is shared with the Mac via routeDefaultCommand.
1271 if staged.isEmpty, replyTo == nil, let dc = defaultCommand,
1272 AppDefaultCommand.routing(draft, spec: dc) != .passthrough {
1273 // One routing in flight at a time: the draft deliberately stays
1274 // visible during the schedule round-trip, so a second Enter must
1275 // not double-create the reminder.
1276 guard !dcRouting else { return }
1277 dcRouting = true
1278 let text = draft
1279 Task { @MainActor in
1280 defer { dcRouting = false }
1281 switch await model.routeDefaultCommand(text, spec: dc) {
1282 case .sent:
1283 // Clear only what we sent — typing during the schedule
1284 // round-trip must never be wiped by a stale completion.
1285 if draft == text { draft = "" }
1286 setRouteNotice(nil) // a success retires any stale push-back
1287 case .rejected(let prompt):
1288 // The draft stays put to fix; this line is the instruction.
1289 setRouteNotice(prompt)
1290 case .kept, .notHandled:
1291 break // draft stays put (down socket)
1292 }
1293 }
1294 return
1295 }
1296 if model.send(wireDraft, attachments: staged.compactMap(\.attachment), replyTo: replyTo) {
1297 draft = ""
1298 staged = []
1299 // Sending from an open thread view keeps it open and re-stages the
1300 // reply to its root — the new reply folds in live and the next
1301 // send continues the same thread, like iMessage (web parity).
1302 if threadRootID != nil { stageThreadReply() } else { replyTo = nil }
1303 }
1304 }
1305
1306 @State private var showThemeSettings = false
1307 @State private var integrityReport: String? // /integrity read-out (tamper evidence)
1308
1309 // The thread's command host (CommandHostCache keeps it across thread
1310 // switches); every call re-points the sink at THIS on-screen pane — the
1311 // model-cache re-point pattern, same as the Mac composer.
1312 @discardableResult
1313 private func ensureCommandHost() -> CommandHost {
1314 let host = commandHost ?? CommandHostCache.shared.host(
1315 for: instance, thread: thread, myUserId: store.myUserId,
1316 instanceKeyB64: store.instanceKey())
1317 if commandHost == nil { commandHost = host }
1318 host.sink = { event in
1319 switch event {
1320 case .panel(_, let height, let animate): // the web panel draws its own header
1321 panelAnimates = animate
1322 withAnimation(animate ? .easeOut(duration: 0.22) : nil) {
1323 panelHeight = min(height, 340)
1324 panelVisible = true
1325 }
1326 case .panelClosed:
1327 withAnimation(panelAnimates ? .easeIn(duration: 0.2) : nil) {
1328 panelVisible = false
1329 }
1330 case .panelHeight(let h):
1331 if panelVisible {
1332 withAnimation(panelAnimates ? .easeInOut(duration: 0.18) : nil) {
1333 panelHeight = min(h, 340)
1334 }
1335 }
1336 case .composerInsert(let text):
1337 // A panel acting on the composer (an /emoji tap) — inserted at
1338 // the caret, like the web's insertAtCaret.
1339 composerController.insert(text, replacingUTF16: nil)
1340 composerFocused = true
1341 case .notice(let from, let text):
1342 // A command's private "only visible to you" reply — the same
1343 // inline slot the default-command push-back uses.
1344 setRouteNotice(from.isEmpty ? text : "\(from) — \(text)")
1345 case .commands(let rows):
1346 // The thread's live command catalogue landed — the typeahead
1347 // and the forwarding decision follow it from here.
1348 hostRows = rows
1349 refreshTypeahead()
1350 }
1351 }
1352 // Cross-thread command routing (docs/native-apps.md phase 3): the page
1353 // asks for one narrow thread key ({needKey}) or an app's personal
1354 // thread ({needAppThread}); answer from what THIS member already
1355 // holds — never the private key.
1356 let origin = instance.origin
1357 host.keyProvider = { tid in
1358 guard let e = SharedThreadKeys.lookup(threadID: tid), e.origin == origin else { return nil }
1359 return e.key.base64urlEncodedString()
1360 }
1361 host.appThreadProvider = { [weak store] app, title in
1362 guard let store, let id = await store.openOrCreateAppThread(app: app, title: title)
1363 else { return nil }
1364 if let key = store.threads.first(where: { $0.id == id })?.threadKeyB64, !key.isEmpty {
1365 return (id, key)
1366 }
1367 return nil
1368 }
1369 if !host.commandRows.isEmpty, hostRows.isEmpty { hostRows = host.commandRows }
1370 return host
1371 }
1372
1373 private func startReply(_ m: ChatFeedModel.Message) {
1374 editingID = nil
1375 // Replying to a reply continues its thread: target the TOP message.
1376 replyTo = model.replyTarget(for: m)
1377 composerFocused = true
1378 }
1379
1380 private func startEdit(_ m: ChatFeedModel.Message) {
1381 replyTo = nil
1382 editingID = m.id
1383 draft = m.raw.text ?? m.text
1384 composerFocused = true
1385 }
1386
1387 private func cancelEdit() {
1388 editingID = nil
1389 draft = ""
1390 // An edit inside an open thread view borrowed the composer (startEdit
1391 // clears replyTo); hand it back so the next send still continues the
1392 // thread (web cancelEdit).
1393 if threadRootID != nil { stageThreadReply() }
1394 }
1395
1396 private func banner(_ title: String, detail: String?, onCancel: @escaping () -> Void) -> some View {
1397 HStack(spacing: 8) {
1398 VStack(alignment: .leading, spacing: 1) {
1399 Text(title).font(.system(size: 12, weight: .semibold))
1400 if let detail, !detail.isEmpty {
1401 Text(detail).font(.system(size: 12)).foregroundStyle(.secondary).lineLimit(1)
1402 }
1403 }
1404 Spacer()
1405 // A full 44pt target — the bare ~20pt glyph needed several tries.
1406 Button(action: onCancel) {
1407 Image(systemName: "xmark.circle.fill")
1408 .font(.system(size: 18))
1409 .foregroundStyle(.tertiary)
1410 .frame(width: 44, height: 44)
1411 .contentShape(Rectangle())
1412 }
1413 .buttonStyle(.plain)
1414 }
1415 .padding(.leading, 12)
1416 .padding(.trailing, 2)
1417 .padding(.vertical, 2)
1418 .floatingGlass(in: RoundedRectangle(cornerRadius: 16))
1419 .padding(.horizontal, 10)
1420 }
1421
1422 // ---- typeahead (same native brain as the Mac) --------------------------
1423
1424 private func refreshTypeahead() {
1425 let utf16 = min(max(caret, 0), draft.utf16.count)
1426 let idx = String.Index(utf16Offset: utf16, in: draft)
1427 let charCaret = draft.distance(from: draft.startIndex, to: idx)
1428 guard let token = composerToken(draft, caret: charCaret) else {
1429 nativeTypeahead = nil
1430 return
1431 }
1432 // App-local verbs (/add in To-do) lead the strip, bot or no bot —
1433 // the web's commandCatalog(threadId).
1434 let items = typeaheadItems(token, roster: store.roster,
1435 myUserId: store.myUserId, hasBot: hasBot,
1436 appCommands: AppDefaultCommand.localCommands(appID: thread.appId),
1437 displayNames: store.displayLabelMap(),
1438 rows: hostRows)
1439 nativeTypeahead = items.isEmpty ? nil : (token, items)
1440 }
1441
1442 private func typeaheadStrip(_ items: [TypeaheadItem]) -> some View {
1443 ScrollView(.horizontal, showsIndicators: false) {
1444 HStack(spacing: 6) {
1445 ForEach(items) { item in
1446 Button { pick(item) } label: {
1447 Text(chipLabel(item))
1448 .font(.subheadline)
1449 .lineLimit(1)
1450 .padding(.horizontal, 12)
1451 .padding(.vertical, 7)
1452 .floatingGlass(in: Capsule())
1453 }
1454 .buttonStyle(.plain)
1455 }
1456 }
1457 .padding(.horizontal, 10)
1458 }
1459 }
1460
1461 private func chipLabel(_ item: TypeaheadItem) -> String {
1462 switch item {
1463 case .command(let name, _): return name
1464 case .emoji(let glyph, let code): return "\(glyph) :\(code):"
1465 // The chip shows the name I use for them (#577); the pick still
1466 // inserts the published @name (item.insert).
1467 case .mention(_, _, let display): return "@" + display
1468 }
1469 }
1470
1471 private func pick(_ item: TypeaheadItem) {
1472 guard let ta = nativeTypeahead else { return }
1473 // Splice trigger → caret through the controller: a binding rewrite
1474 // would clobber styled runs and park the caret at the end.
1475 let chars = Array(draft)
1476 let utf16 = min(max(caret, 0), draft.utf16.count)
1477 let charCaret = draft.distance(from: draft.startIndex,
1478 to: String.Index(utf16Offset: utf16, in: draft))
1479 let start = max(0, min(ta.token.start, chars.count))
1480 let end = max(start, min(charCaret, chars.count))
1481 let head = String(chars[0..<start])
1482 let span = String(chars[start..<end])
1483 composerController.insert(item.insert, replacingUTF16: NSRange(
1484 location: (head as NSString).length, length: (span as NSString).length))
1485 nativeTypeahead = nil
1486 composerFocused = true
1487 }
1488
1489 // ---- uploads -------------------------------------------------------------
1490
1491 // Staged pictures read as a real preview — a thumbnail tray, WhatsApp/
1492 // Signal proportions. No filename on an image (a synthetic "pasted.jpg"
1493 // is pure noise); non-image files keep the compact name chip, because
1494 // there the filename IS the content.
1495 private var stagedChips: some View {
1496 ScrollView(.horizontal, showsIndicators: false) {
1497 HStack(alignment: .bottom, spacing: 8) {
1498 ForEach(staged) { s in
1499 if let preview = s.preview {
1500 stagedThumb(s, preview: preview)
1501 } else {
1502 stagedFileChip(s)
1503 }
1504 }
1505 }
1506 .padding(.horizontal, 14)
1507 }
1508 }
1509
1510 private func stagedThumb(_ s: StagedUpload, preview: UIImage) -> some View {
1511 // Fixed height, width follows the image's aspect (clamped so a
1512 // panorama can't swallow the tray and a tall shot stays readable).
1513 let h: CGFloat = 96
1514 let aspect = preview.size.height > 0 ? preview.size.width / preview.size.height : 1
1515 let w = min(max(h * aspect, 64), 160)
1516 return Image(uiImage: preview)
1517 .resizable().scaledToFill()
1518 .frame(width: w, height: h)
1519 .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
1520 .opacity(s.attachment == nil ? 0.55 : 1)
1521 .overlay(alignment: .bottom) {
1522 if s.attachment == nil {
1523 ProgressView(value: s.progress)
1524 .tint(.white)
1525 .padding(.horizontal, 10)
1526 .padding(.bottom, 8)
1527 }
1528 }
1529 .overlay(alignment: .topTrailing) {
1530 // Dark scrim keeps the ✕ legible over any photo, either theme.
1531 Button {
1532 staged.removeAll { $0.id == s.id }
1533 } label: {
1534 Image(systemName: "xmark")
1535 .font(.system(size: 10, weight: .bold))
1536 .foregroundStyle(.white)
1537 .frame(width: 22, height: 22)
1538 .background(Circle().fill(.black.opacity(0.55)))
1539 .contentShape(Circle())
1540 }
1541 .buttonStyle(.plain)
1542 .padding(5)
1543 .accessibilityLabel("Remove attachment")
1544 }
1545 }
1546
1547 private func stagedFileChip(_ s: StagedUpload) -> some View {
1548 HStack(spacing: 5) {
1549 if s.attachment == nil {
1550 ProgressView(value: s.progress).frame(width: 40)
1551 } else {
1552 Text(fileIcon(s.name)).font(.system(size: 13))
1553 }
1554 Text(s.name).font(.system(size: 12)).lineLimit(1)
1555 Button {
1556 staged.removeAll { $0.id == s.id }
1557 } label: {
1558 Image(systemName: "xmark.circle.fill")
1559 .font(.system(size: 12))
1560 .foregroundStyle(.tertiary)
1561 }
1562 .accessibilityLabel("Remove attachment")
1563 }
1564 .padding(.horizontal, 8)
1565 .padding(.vertical, 4)
1566 .background(Capsule().fill(.primary.opacity(0.06)))
1567 }
1568
1569 private func stagePickerItem(_ item: PhotosPickerItem) {
1570 let isVideo = item.supportedContentTypes.contains { $0.conforms(to: .movie) }
1571 let name = isVideo ? "video.mp4" : "photo.jpg"
1572 let entry = StagedUpload(name: name)
1573 staged.append(entry)
1574 let onProgress: @MainActor (Double) -> Void = { p in
1575 if let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].progress = p }
1576 }
1577 Task {
1578 guard let data = try? await item.loadTransferable(type: Data.self) else {
1579 staged.removeAll { $0.id == entry.id }
1580 return
1581 }
1582 // The composer-chip preview downscale is a full decode — detached,
1583 // like the upload-side processImage in ChatFeedModel.
1584 if !isVideo,
1585 let thumb = await Task.detached(operation: { processImage(data, maxDim: 288) }).value,
1586 let i = staged.firstIndex(where: { $0.id == entry.id }) {
1587 staged[i].preview = UIImage(data: thumb.bytes)
1588 }
1589 let att = isVideo
1590 ? await model.uploadVideo(data, name: name, mimeType: "video/mp4", onProgress: onProgress)
1591 : await model.uploadImage(data, name: name, onProgress: onProgress)
1592 if let att, let i = staged.firstIndex(where: { $0.id == entry.id }) {
1593 staged[i].attachment = att
1594 } else {
1595 staged.removeAll { $0.id == entry.id }
1596 }
1597 }
1598 }
1599
1600 // Stage raw image bytes (paste / share-sheet handoff) as an upload.
1601 private func stageImageData(_ data: Data, name: String = "pasted.jpg") {
1602 let entry = StagedUpload(name: name)
1603 staged.append(entry)
1604 let onProgress: @MainActor (Double) -> Void = { p in
1605 if let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].progress = p }
1606 }
1607 Task {
1608 // The chip shows name-only for a beat while the preview downscale
1609 // (a full decode of the pasted image) runs detached.
1610 if let thumb = await Task.detached(operation: { processImage(data, maxDim: 288) }).value,
1611 let i = staged.firstIndex(where: { $0.id == entry.id }) {
1612 staged[i].preview = UIImage(data: thumb.bytes)
1613 }
1614 let att = await model.uploadImage(data, name: name, onProgress: onProgress)
1615 if let att, let i = staged.firstIndex(where: { $0.id == entry.id }) {
1616 staged[i].attachment = att
1617 } else {
1618 staged.removeAll { $0.id == entry.id }
1619 }
1620 }
1621 }
1622
1623 private func stageVideoData(_ data: Data, name: String) {
1624 let entry = StagedUpload(name: name)
1625 staged.append(entry)
1626 let onProgress: @MainActor (Double) -> Void = { p in
1627 if let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].progress = p }
1628 }
1629 Task {
1630 let att = await model.uploadVideo(data, name: name, mimeType: "video/mp4",
1631 onProgress: onProgress)
1632 if let att, let i = staged.firstIndex(where: { $0.id == entry.id }) {
1633 staged[i].attachment = att
1634 } else {
1635 staged.removeAll { $0.id == entry.id }
1636 }
1637 }
1638 }
1639
1640 private func stageFile(_ url: URL) {
1641 let name = url.lastPathComponent
1642 let entry = StagedUpload(name: name)
1643 staged.append(entry)
1644 let type = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType
1645 let onProgress: @MainActor (Double) -> Void = { p in
1646 if let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].progress = p }
1647 }
1648 Task {
1649 // Whole-file read detached — a picked file can be tens of MB, and
1650 // the security scope only needs to bracket the read itself.
1651 let data = await Task.detached { () -> Data? in
1652 guard url.startAccessingSecurityScopedResource() else { return nil }
1653 defer { url.stopAccessingSecurityScopedResource() }
1654 return try? Data(contentsOf: url)
1655 }.value
1656 guard let data else {
1657 staged.removeAll { $0.id == entry.id }
1658 return
1659 }
1660 let att = await model.uploadFile(data, name: name, mimeType: type, onProgress: onProgress)
1661 if let att, let i = staged.firstIndex(where: { $0.id == entry.id }) {
1662 staged[i].attachment = att
1663 } else {
1664 staged.removeAll { $0.id == entry.id }
1665 }
1666 }
1667 }
1668}
1669
1670// The system camera (UIImagePickerController — SwiftUI has no native capture
1671// view). Stills only, on purpose: movie capture records audio, which would
1672// demand a microphone-permission prompt for a flow nobody asked for. The
1673// shot comes back as JPEG bytes and rides the ordinary image-upload path.
1674private struct CameraCapture: UIViewControllerRepresentable {
1675 let onCapture: (Data) -> Void
1676 @Environment(\.dismiss) private var dismiss
1677
1678 func makeUIViewController(context: Context) -> UIImagePickerController {
1679 let picker = UIImagePickerController()
1680 picker.sourceType = .camera
1681 picker.delegate = context.coordinator
1682 return picker
1683 }
1684
1685 func updateUIViewController(_ picker: UIImagePickerController, context: Context) {}
1686
1687 func makeCoordinator() -> Coordinator { Coordinator(self) }
1688
1689 final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
1690 let parent: CameraCapture
1691 init(_ parent: CameraCapture) { self.parent = parent }
1692
1693 func imagePickerController(_ picker: UIImagePickerController,
1694 didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
1695 if let image = info[.originalImage] as? UIImage,
1696 let data = image.jpegData(compressionQuality: 0.9) {
1697 parent.onCapture(data)
1698 }
1699 parent.dismiss()
1700 }
1701
1702 func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
1703 parent.dismiss()
1704 }
1705 }
1706}
1707
1708// Full-screen encrypted-video playback — an AVPlayerViewController wrapped so
1709// its letterbox is TRANSPARENT (PlayerSurface): the backdrop is our own layer,
1710// so the dismiss swipe translates only the video surface (scaling down as it
1711// goes) while the black backdrop stays put and fades with the drag — the
1712// Photos grammar the photo viewer already follows. (SwiftUI's VideoPlayer
1713// painted its whole full-screen frame opaque black, so the swipe visibly
1714// dragged the entire lightbox, dim and all.) Bytes still come from memory only.
1715//
1716// Like the photo viewer, the surface ZOOMS OUT OF ITS CARD to full-bleed (the
1717// cover presents with no transition — this view is the whole choreography)
1718// and zooms back into place on close, tap-X and swipe-down alike.
1719//
1720// Rotating the phone rotates the player into landscape fullscreen even though
1721// the app is portrait-locked (VideoOrientation gates the mask while this view
1722// is up), and a YouTube-style expand button covers phones whose rotation lock
1723// blocks auto-rotation (RotationLockSensor infers the lock).
1724private struct VideoViewer: View {
1725 let player: AVPlayer
1726 let sourceFrame: CGRect // the video card, in window coordinates
1727 let onClose: () -> Void
1728
1729 // Drives the scrim and the card↔fullscreen geometry: sprung on in onAppear,
1730 // off in close() with delayed teardown.
1731 @State private var shown = false
1732 @State private var closing = false
1733 @State private var dismissOffset: CGFloat = 0
1734 // Our overlay buttons shadow the system chrome's visibility: any touch on
1735 // the player shows them + restarts the hide timer. We can't SEE the real
1736 // chrome's state (no public API), so we never tap-to-hide — erring visible
1737 // keeps the fullscreen path reachable at the cost of occasionally outliving
1738 // the system fade by a moment.
1739 @State private var controlsVisible = true
1740 @State private var hideGen = 0
1741 @StateObject private var lockSensor = RotationLockSensor()
1742
1743 private var dismissProgress: CGFloat { min(1, abs(dismissOffset) / 300) }
1744 private var chromeShown: Bool { controlsVisible && shown && !closing && dismissOffset == 0 }
1745
1746 var body: some View {
1747 ZStack {
1748 GeometryReader { geo in
1749 stage(in: geo.size)
1750 }
1751 // Full-bleed stage; local coordinates == window coordinates, the
1752 // space sourceFrame is in.
1753 .ignoresSafeArea()
1754 // The chrome keeps its own reader INSIDE the safe area.
1755 GeometryReader { geo in
1756 chrome(landscape: geo.size.width > geo.size.height)
1757 }
1758 .animation(.easeInOut(duration: 0.25), value: chromeShown)
1759 }
1760 .onAppear {
1761 withAnimation(.spring(response: 0.36, dampingFraction: 0.86)) { shown = true }
1762 VideoOrientation.setAllowsLandscape(true)
1763 pokeChrome()
1764 }
1765 // Belt-and-braces: close() already re-locks, but a cover torn down any
1766 // other way must still return the app to portrait-only.
1767 .onDisappear { VideoOrientation.setAllowsLandscape(false) }
1768 }
1769
1770 private func stage(in viewport: CGSize) -> some View {
1771 let full = CGRect(origin: .zero, size: viewport)
1772 // A frame that never got captured (shouldn't happen) degrades to a
1773 // fade-up from center rather than a zoom from nowhere.
1774 let src = sourceFrame.isEmpty
1775 ? CGRect(x: viewport.width / 2, y: viewport.height / 2, width: 0, height: 0)
1776 : sourceFrame
1777 return ZStack {
1778 // The backdrop FADES (the chat shows through the clear cover);
1779 // only the video surface rides the swipe.
1780 Color.black.opacity(shown ? Double(1 - dismissProgress * 0.9) : 0)
1781 // Full-bleed once open (the letterbox is transparent and the
1782 // system chrome insets itself); the zoom interpolates the surface
1783 // from the card's frame, so the video grows out of its bubble.
1784 PlayerSurface(player: player, onTouch: pokeChrome)
1785 .frame(width: shown ? full.width : src.width,
1786 height: shown ? full.height : src.height)
1787 .clipShape(RoundedRectangle(cornerRadius: shown ? 0 : 18, style: .continuous))
1788 .scaleEffect(1 - dismissProgress * 0.15)
1789 .position(x: shown ? full.midX : src.midX,
1790 y: (shown ? full.midY : src.midY) + dismissOffset)
1791 .simultaneousGesture(
1792 DragGesture(minimumDistance: 25)
1793 .onChanged { v in
1794 guard abs(v.translation.height) > abs(v.translation.width) * 1.4
1795 else { return }
1796 dismissOffset = v.translation.height
1797 }
1798 .onEnded { v in
1799 if abs(dismissOffset) > 130 || abs(v.predictedEndTranslation.height) > 450 {
1800 close()
1801 } else {
1802 withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
1803 dismissOffset = 0
1804 }
1805 }
1806 }
1807 )
1808 }
1809 }
1810
1811 @ViewBuilder
1812 private func chrome(landscape: Bool) -> some View {
1813 Button(action: close) {
1814 Image(systemName: "xmark.circle.fill")
1815 .font(.system(size: 28))
1816 .foregroundStyle(.white.opacity(0.8))
1817 .padding()
1818 }
1819 .opacity(chromeShown ? 1 : 0)
1820 .accessibilityLabel("Close")
1821 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
1822 // The YouTube-style fullscreen toggle, tucked bottom-right above
1823 // the transport bar. Portrait shows expand only while rotation
1824 // looks blocked (locked or no signal yet — .unknown defaults to
1825 // showing: a spare button beats no path to fullscreen);
1826 // landscape always shows collapse, because the lock could have
1827 // been flipped on mid-video, stranding auto-rotation back.
1828 if landscape || lockSensor.verdict != .unlocked {
1829 Button {
1830 lockSensor.noteManualRotation()
1831 VideoOrientation.rotate(to: landscape ? .portrait : .landscapeRight)
1832 } label: {
1833 Image(systemName: landscape
1834 ? "arrow.down.right.and.arrow.up.left"
1835 : "arrow.up.left.and.arrow.down.right")
1836 .font(.system(size: 17, weight: .semibold))
1837 .foregroundStyle(.white)
1838 .frame(width: 44, height: 44)
1839 .background(.black.opacity(0.55), in: Circle())
1840 }
1841 .frame(maxWidth: .infinity, maxHeight: .infinity,
1842 alignment: .bottomTrailing)
1843 .padding(.trailing, 16)
1844 .padding(.bottom, 84) // clear the system transport bar
1845 .opacity(chromeShown ? 1 : 0)
1846 .accessibilityLabel(landscape ? "Exit fullscreen" : "Fullscreen")
1847 }
1848 }
1849
1850 // Show the overlay buttons and (while playing) fade them after the same
1851 // few seconds the system chrome lingers; paused keeps them up, as it does.
1852 private func pokeChrome() {
1853 controlsVisible = true
1854 hideGen += 1
1855 let gen = hideGen
1856 DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
1857 guard gen == hideGen, player.timeControlStatus == .playing else { return }
1858 controlsVisible = false
1859 }
1860 }
1861
1862 // Everything springs home together — the surface back into its card, the
1863 // scrim out — then the animation-less cover clears once the zoom-back has
1864 // played. Sound stops up front: a video still talking through its own
1865 // exit is wrong (the YouTube lightbox set that rule).
1866 private func close() {
1867 guard !closing else { return }
1868 closing = true
1869 player.pause()
1870 // Re-lock portrait BEFORE the cover starts leaving, or the chat behind
1871 // gets caught (or stuck) sideways.
1872 VideoOrientation.setAllowsLandscape(false)
1873 if VideoOrientation.interfaceIsLandscape { VideoOrientation.rotate(to: .portrait) }
1874 withAnimation(.spring(response: 0.32, dampingFraction: 0.88)) {
1875 shown = false
1876 dismissOffset = 0
1877 }
1878 DispatchQueue.main.asyncAfter(deadline: .now() + 0.32, execute: onClose)
1879 }
1880}
1881
1882// The AVPlayerViewController host. Two jobs SwiftUI's VideoPlayer can't do:
1883// a CLEAR view background, so the letterbox doesn't paint the whole screen
1884// black (the opaque full-screen surface is what made the dismiss swipe look
1885// like it dragged the entire lightbox); and a non-stealing touch recognizer
1886// that mirrors "the user touched the player" up to SwiftUI, so our overlay
1887// buttons can fade roughly in step with the system chrome.
1888private struct PlayerSurface: UIViewControllerRepresentable {
1889 let player: AVPlayer
1890 let onTouch: () -> Void
1891
1892 func makeCoordinator() -> Coordinator { Coordinator(onTouch: onTouch) }
1893
1894 func makeUIViewController(context: Context) -> AVPlayerViewController {
1895 let vc = AVPlayerViewController()
1896 vc.player = player
1897 vc.showsPlaybackControls = true
1898 vc.updatesNowPlayingInfoCenter = false
1899 vc.view.backgroundColor = .clear
1900 let tap = UITapGestureRecognizer(target: context.coordinator,
1901 action: #selector(Coordinator.tapped))
1902 tap.cancelsTouchesInView = false
1903 tap.delegate = context.coordinator
1904 vc.view.addGestureRecognizer(tap)
1905 return vc
1906 }
1907
1908 func updateUIViewController(_ vc: AVPlayerViewController, context: Context) {
1909 vc.view.backgroundColor = .clear // AVKit relayouts can repaint it black
1910 }
1911
1912 final class Coordinator: NSObject, UIGestureRecognizerDelegate {
1913 let onTouch: () -> Void
1914 init(onTouch: @escaping () -> Void) { self.onTouch = onTouch }
1915 @objc func tapped() { onTouch() }
1916 // Recognise alongside the player's own recognisers — never instead of.
1917 func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
1918 shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool {
1919 true
1920 }
1921 }
1922}
1923