import LChatCore import SwiftUI import AVKit import GameController import PhotosUI import UniformTypeIdentifiers // The iOS native feed (docs/native-chat.md, iOS adoption): the same shared // pieces the Mac takeover renders — ChatFeedModel (parked via // NativeFeedModelCache), MessageBubble and friends — in a phone-shaped // container. THE chat surface, full stop: plain chats mount it directly // (ThreadDestination) and app-thread Chat views mount it through // SchemaAppScreen/AppThreadScreen — chat never renders in a webview. // // Phone-appropriate divergences from the Mac container: actions ride the // long-press context menu MessageBubble already provides on iOS (no hover, no // NSMenu); photos open a full-screen viewer; encrypted videos play in a // full-screen native player (VideoViewer — an AVPlayerViewController; the // macOS crash was in the Mac overlay, and SwiftUI's VideoPlayer couldn't do // the layered dismiss); attachments come from PhotosPicker + the document // picker. Not yet // here: Show Times, file-chip export, scroll-back wave. struct NativeChatScreen: View { let instance: SavedInstance let thread: ChatThread // Deliberately NOT @ObservedObject: the hitch log showed every ThreadStore // publish (mark-reads, refreshes, any bot activity) re-rendering the whole // feed — 34-88ms stalls mid-scroll. This screen only needs point-in-time // reads (roster for the typeahead, thread state for the ⋮ menu) and calls // methods; the feed itself renders from its own ChatFeedModel. let store: ThreadStore // The app posts into this chat, the member doesn't (the manifest's // chatReadOnly — Admin's update stream): no composer at all, the web's // .chatapp-root.readonly rule. let readOnlyChat: Bool // Non-nil = this is an app thread's Chat view with an Interface to switch // to: the ⋮ menu leads with the Chat ↔ Interface picker rows (the parent // AppThreadScreen/SchemaAppScreen owns the mode). let appView: Binding? @ObservedObject private var model: ChatFeedModel // The navigation path this screen sits on — for surfaces that hop // threads (a profile card's "Message them", like ElevenChatScreen's // "Say hi" chip). @Binding private var path: [Route] init(instance: SavedInstance, thread: ChatThread, store: ThreadStore, path: Binding<[Route]>, readOnlyChat: Bool = false, appView: Binding? = nil) { self.instance = instance self.thread = thread self.store = store self._path = path self.readOnlyChat = readOnlyChat self.appView = appView self.model = NativeFeedModelCache.shared.model(for: instance, thread: thread, myUserId: store.myUserId) // Opened from a notification tap: capture the read watermark NOW — // init runs before ThreadDestination's markRead bumps it — so the // settle pass can land the FIRST UNREAD at the top of the view // instead of pinning the bottom. if PushManager.forceScrollThreadID == thread.id { PushManager.forceScrollThreadID = nil let seen = ReadState.lastSeen(instance: instance.id, thread: thread.id) if seen > 0 { _unreadCutoff = State(initialValue: seen) } } // Reopen on the view this group was last showing (chat or a shared app). _groupAppID = State(initialValue: GroupAppMemory.selection[thread.id]) // The app-host projection queries (docs/native-apps.md phase 5): the // model asks the thread's command host to project app events it has // no built-in projection for. Lazy — the host webview only spins up // when a thread actually contains such an event. let projInstance = instance, projThread = thread let projUserId = store.myUserId, projIK = store.instanceKey() model.projector = { eventJSON, group in let host = CommandHostCache.shared.host(for: projInstance, thread: projThread, myUserId: projUserId, instanceKeyB64: projIK) return await host.projectEvent(eventJSON, group: group) } } // The pre-tap last-seen time; non-nil = this open should anchor unreads. @State private var unreadCutoff: Int64? // A search hit's "open the chat ON this message" request, set by the // chat list before pushing this screen. static var pendingJump: (thread: String, message: Int64)? // Shared files (share-sheet handoff) to stage into this chat's composer. static var pendingShare: (thread: String, files: [URL])? @State private var draft = "" // The per-thread command host (LChatCore CommandHost — the web command // engine, headless): slash commands the native floor doesn't own forward // here; replies come back as inline notices, panels as the webview above // the composer. Created lazily on the first "/" typed. @State private var commandHost: CommandHost? // This thread's commandItems() rows (the host reports them once ready) — // they feed the "/" typeahead and the forward-or-send routing decision. @State private var hostRows: [CommandRow] = [] @State private var panelVisible = false @State private var panelHeight: CGFloat = 240 @State private var panelAnimates = true // the open post's animate flag, reused on close @State private var composerFocused = false // ComposerField's own focus binding (UIKit-backed) // A default command's push-back line, generation-tokened so a re-shown // identical prompt is never wiped by an earlier showing's clear timer; // dcRouting keeps one async routing in flight (no double-sent reminders). @State private var routeNotice: String? @State private var routeNoticeGen = 0 @State private var dcRouting = false @State private var replyTo: ReplyRef? @State private var editingID: Int64? // Programmatic composer edits (typeahead splices) act on the field via // the controller; the wire markdown is read from it at send (#890). @State private var composerController = ComposerFieldController() @AppStorage("formatAsYouType") private var formatAsYouType = true // The open reply-thread view's root id (nil = closed). @State private var threadRootID: Int64? // A jump whose target lives only in its thread (search hit, media-sheet // jump, a notification's newest unread): the view's row to land on + its // wave — the feed's danceID/danceTick twins for the thread surface // (jumpIntoThread). @State private var threadJump: Int64? @State private var threadJumpTick = 0 @State private var threadDanceID: Int64? @State private var threadDanceTick = 0 // The notification-tap thread-reply check ran for this open (one-shot, // like the unread anchor it complements — landOnUnreadThreadReply). @State private var unreadThreadChecked = false // The feed's inline status line (FeedNoteBar, under the header): a jump // that has to page history back for its target says so, and says why when // it can't reach it. `revealing` is the target of the walk in flight — // one at a time, so a second tap can't stack walks. @State private var feedNote: String? @State private var feedNoteGen = 0 @State private var revealing: Int64? // A jump target was consumed for this open (search hit, media sheet, or a // notification tap carrying its message id) — the guess-work fallback // stands down when the tap said exactly where to land. @State private var jumpTargeted = false // Where the open thread presents (thread-pane spec §6, the iPad slice): // in a REGULAR horizontal size class it docks — a flush right-hand pane // beside the feed with its own composer, Slack-style; compact keeps the // lifted overlay byte-for-byte. Read live, so an iPad multitasking // resize mid-thread re-presents (the .onChange(of: sizeClass) handoff // carries the draft across). An iPhone can't reach regular: the app is // portrait-locked there. @Environment(\.horizontalSizeClass) private var sizeClass private var threadDocked: Bool { sizeClass == .regular } // The docked pane's OWN composer state — a second container state set, // exactly what the MessageComposerBar extraction was for. No replyTo // twin: the pane's reply target is fixed (the thread root, derived fresh // at send), so nothing user-armed lives here. @State private var paneDraft = "" @State private var paneFocused = false @State private var paneController = ComposerFieldController() @State private var paneStaged: [StagedUpload] = [] @State private var paneEditingID: Int64? // Attachments staged on the draft (StagedUpload is file-scope, shared // with MessageComposerBar, which renders the chips; the encrypt+upload // pipeline that fills them stays here — the share-sheet handoff feeds it). @State private var staged: [StagedUpload] = [] @State private var quickLookItem: QuickLookItem? @State private var playingVideo: PlayingVideo? // A downloaded file staged on disk for Quick Look, plus the temp directory // to burn once the preview closes (decrypted plaintext never lingers). @State private var filePreview: FilePreviewItem? @State private var previewCleanupDir: URL? struct FilePreviewItem: Identifiable { let id = UUID() let url: URL } struct PlayingVideo: Identifiable { let id: String let player: AVPlayer let loader: MemoryVideoAsset let sourceFrame: CGRect // the video card's window frame — where the zoom starts } // Belt-and-braces: any thread with 3+ members shows sender names, // whatever kind it reports. private var isGroup: Bool { thread.kind == "group" || thread.members.count > 2 } private var hasBot: Bool { let members = Set(thread.members) return store.roster.contains { members.contains($0.user_id) && $0.isAgent } } // The app-thread's default command (To-do: plain text adds a to-do) — the // web's defaultCommandFor, so both composers route identically. Nil for a // normal chat, an app with no default, or once a bot member is present. private var defaultCommand: AppDefaultCommand.Spec? { AppDefaultCommand.spec(appID: thread.appId, hasBot: hasBot) } // "2 members, 1 bot, 2 AI agents · 1 online" — the web group header's // subtitle; the online count is people only (bots' always-on sockets would // pin every room at its bot count). private var memberStats: String? { guard isGroup else { return nil } var bots = 0, agents = 0 for id in thread.members { guard let e = store.roster.first(where: { $0.user_id == id }), e.isAgent else { continue } if e.isAI { agents += 1 } else { bots += 1 } } let label = memberCountLabel(people: thread.members.count - bots - agents, bots: bots, agents: agents) let online = store.onlinePeopleCount(of: thread) return online > 0 ? label + " · \(online) online" : label } // The DM partner's live status — read from the store (kept current by // presence frames), falling back to the snapshot this screen was built with. private var dmPresence: String? { store.threads.first { $0.id == thread.id }?.presence ?? thread.presence } @Environment(\.dismiss) private var dismiss @Environment(\.scenePhase) private var scenePhase @Environment(\.colorScheme) private var scheme @AppStorage("nativeTheme") private var themeName = "classic" // Dynamic Type: read at the system body size (17 at the default setting — // also the web's touch size), so Settings → Text Size scales the feed. @ScaledMetric(relativeTo: .body) private var chatFontSize: CGFloat = 17 @State private var avatarPop = false // The DM partner's profile card (rename them for yourself there — #577). @State private var headerProfile: HeaderProfileTarget? struct HeaderProfileTarget: Identifiable { let id: String } // A message's Report action (#578): the reported sender + the message as // evidence, feeding the shared ReportSheet. @State private var reportTarget: ReportTarget? struct ReportTarget: Identifiable { let uid: String let evidence: ReportEvidence var id: Int64 { evidence.sid } } // A message's "Convert to thread" action: the chain to move, held while the // confirm is up. The ids are snapshotted at the tap, so what moves is what // the person was looking at — not a set re-derived after the fact. @State private var convertTarget: ConvertTarget? // Set in the SAME update as convertTarget: the alert's message closure is // evaluated before any onChange could seed it, and again as it dismisses // (target already nil), so the count can't be read through the optional. @State private var convertCount = 0 struct ConvertTarget: Identifiable { let root: Int64 let ids: [Int64] var id: Int64 { root } } // The header/composer overlay heights (probes above) — the feed extends // under both and rests content between them via content insets. @State private var headerHeight: CGFloat = 0 @State private var composerHeight: CGFloat = 0 // The chat surface's width (probe on the feed/pane HStack) — the docked // pane clamps to it: the web's min(340px, 40%), so a Split View 2/3 or a // Stage Manager window (~640pt) still leaves the feed the larger share. @State private var chatWidth: CGFloat = 0 private var paneWidth: CGFloat { chatWidth > 0 ? min(340, chatWidth * 0.4) : 340 } // Whether a hardware keyboard is attached (GameController's coalesced // keyboard is the standard signal). Gates the focus-on-open courtesy: a // keyboard-driven reader wants the caret to move into the pane; a // touch-only iPad would get the software keyboard raised over the // replies just opened — this screen's own rule against exactly that. private var hardwareKeyboardAttached: Bool { GCKeyboard.coalesced != nil } @State private var showMembers = false @State private var showRename = false @State private var showGroupApps = false // The per-thread shared-content inventory (spec: media-links-docs) — // DMs and groups alike, hidden on app threads (they have no chat feed // of shared content to inventory). @State private var showMediaView = false // The Threads list — this chat's threads, freshest first, so one you // missed is findable rather than lost behind a notification. @State private var showThreadList = false // End conversation (#578): the destructive confirm, and the closed // banner's in-flight / refusal state (inline copy, never an alert). @State private var showEndConfirm = false @State private var reopenBusy = false @State private var reopenNote: String? // The group's chosen view for this session: nil = the conversation, an app // id = that shared app's interface (GroupAppMemory keeps it across pushes, // like the web's groupAppView). @State private var groupAppID: String? // The apps this group has added (the model's groupapps fold, already // catalog-filtered). Empty for anything that isn't a group. private var groupApps: [GroupApps.Item] { model.groupApps } // The selection, validated against the live fold — an app someone removed // while we were looking at it falls back to the conversation. private var activeGroupAppID: String? { guard let id = groupAppID, groupApps.contains(where: { $0.id == id }) else { return nil } return id } // The header menu's view picker ("" = chat) — writes through to the // session memory so reopening the chat restores the same view. private var groupViewSelection: Binding { Binding( get: { activeGroupAppID ?? "" }, set: { picked in groupAppID = picked.isEmpty ? nil : picked GroupAppMemory.selection[thread.id] = picked.isEmpty ? nil : picked }) } private var isPinned: Bool { store.threads.first { $0.id == thread.id }?.pinned ?? thread.pinned } private var isUnread: Bool { store.threads.first { $0.id == thread.id }?.unread ?? false } private var isMuted: Bool { store.threads.first { $0.id == thread.id }?.isMuted ?? thread.isMuted } // The DM partner's uid — the header opens their profile card (rename them // for yourself there, #577), like tapping the web header title. private var dmPartner: String? { guard thread.kind == "dm" else { return nil } return thread.members.first { $0 != store.myUserId } } // The Messages-style header: big avatar level with the // bar buttons, the name in a glass pill tucked under it. private var chatHeader: some View { VStack(spacing: -5) { // Presence on mobile is the avatar ring, not a status line: online // wears a green halo; away/offline/unknown look like any other // header. (Web + Mac keep the dot+word line — they have the room.) // zIndex(1) keeps the ring above the glass capsule below. // Decorative here — the title/pill below carries the name to // VoiceOver, so the avatar needn't be its own element. Avatar(name: thread.title, size: 54, source: thread.avatar, onlineRing: thread.kind == "dm" && dmPresence == "online") .scaleEffect(avatarPop ? 1 : 0.7) .zIndex(1) .accessibilityHidden(true) // In a group the pill is a fast path to the members list — the // counts *read* as tappable, so make them so (View Members also // stays in the ⋯ menu as the labelled/fallback path). The pill is // already ~44pt tall, so no hit-area hacks, and it becomes its own // actionable a11y element instead of a flattened, action-less one. // (Groups own their tap here; the DM whole-header tap below never // fires for them, since memberStats and dmPartner are exclusive.) if memberStats != nil { Button { showMembers = true } label: { headerPill } .buttonStyle(.plain) .accessibilityElement() .accessibilityLabel(headerAccessibilityLabel) .accessibilityHint("View members") .accessibilityAddTraits(.isButton) } else { headerPill } } .frame(maxWidth: .infinity) .padding(.bottom, 6) .offset(y: -52) // rise level with the back button / ⋯ menu // DMs: the whole header taps through to the partner's profile (#577). // Groups handle their own tap on the pill above, so keep them separate // a11y elements (.contain) rather than flattening the pill button away. .accessibilityElement(children: dmPartner != nil ? .combine : .contain) .contentShape(Rectangle()) .onTapGesture { if let uid = dmPartner { headerProfile = HeaderProfileTarget(id: uid) } } .accessibilityAddTraits(dmPartner != nil ? .isButton : []) .accessibilityHint(dmPartner != nil ? "Shows their profile" : "") } // The name + optional member-stats capsule; extracted so the group case can // wrap it in a Button without duplicating the layout. private var headerPill: some View { VStack(spacing: 0) { Text(thread.title) .font(.headline) .lineLimit(1) .foregroundStyle(.primary) if let stats = memberStats { Text(stats) .font(.system(size: 11)) .foregroundStyle(.secondary) .lineLimit(1) } } .padding(.horizontal, 12) .padding(.vertical, 4) // Layering step 1 (2026-07-21): liquid glass back on the pill — the // suspect being tested is the lens's per-frame backdrop sampling // over a scrolling feed. .floatingGlass(in: Capsule()) } // "Standup, 5 members, 2 agents, 3 online" — the pill's counts spoken // cleanly (" · " reads awkwardly); the "View members" action rides the hint. private var headerAccessibilityLabel: String { guard let stats = memberStats else { return thread.title } return thread.title + ", " + stats.replacingOccurrences(of: " · ", with: ", ") } // A group's shared-app surface: schema-described apps (To-do, Reminders) // render the shared instance NATIVE, folded from the group's log; the // rest keep the webview embed. @ViewBuilder private func groupAppSurface(_ appID: String) -> some View { if AppSchemaRegistry.hasSchema(for: appID), !thread.threadKeyB64.isEmpty, let schemaStore = SchemaAppStoreCache.shared.store( for: instance, thread: thread, myUserId: store.myUserId, appId: appID) { SchemaAppView(store: schemaStore) .id(thread.id + "|" + thread.threadKeyB64 + "|" + appID + "|schema") } else { GroupAppPane(instance: instance, thread: thread, myUserId: store.myUserId, instanceKeyB64: store.instanceKey(), app: appID) } } var body: some View { Group { // A picked shared app takes the feed's place (iOS has no split — // like the web on a narrow layout); Chat brings the feed back. if let appID = activeGroupAppID { groupAppSurface(appID) } else { HStack(spacing: 0) { feedAndComposer // The docked thread pane (thread-pane spec §6): a flush // right column INSIDE the chat surface, beside the feed — // never over it. Regular width only; the lifted overlay // is the kept !threadDocked path inside feedAndComposer. if let rootID = threadRootID, threadDocked { Divider() threadPane(rootID) .transition(.move(edge: .trailing).combined(with: .opacity)) } } // Measured for the pane's width clamp (the web's min(340px, // 40%)) — the feed column flexes, so this is the chat // surface's full width whether or not the pane is up; the // probe never shapes layout (the header/composer pattern). .background(GeometryReader { g in Color.clear .onAppear { chatWidth = g.size.width } .onChange(of: g.size.width) { _, w in chatWidth = w } }) } } .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.hidden, for: .navigationBar) .safeAreaInset(edge: .top, spacing: 0) { VStack(spacing: 0) { chatHeader // A DM partner renamed themself past my name for them // (#577): the keep/adopt choice, inline under the header. RenameBanner(store: store, thread: thread) // The feed's inline status line — a jump that has to page // history back for its target, and why when it can't // reach it. Inside the measured stack on purpose: the // feed's rest inset grows with it, so the note takes real // layout space instead of floating over the transcript. FeedNoteBar(feedNote) } // Measured for the feed's rest inset (the feed extends under // the header; the probe never shapes layout). .background(GeometryReader { g in Color.clear .onAppear { headerHeight = g.size.height } .onChange(of: g.size.height) { _, h in headerHeight = h } }) } .sheet(item: $headerProfile) { target in // Message them hops to (or creates) their DM — hidden when // this chat already IS their DM (the web card's gate). MemberProfileSheet(store: store, uid: target.id, onOpenThread: dmPartner == target.id ? nil : { id in headerProfile = nil path.append(.thread(id)) }) { e in Avatar(name: store.displayName(for: e), size: 72, source: store.avatarSource(for: e)) } .presentationDetents([.medium]) } // Report a message to the admins (#578) — the shared sheet, with // the message as evidence and this thread's client-side name. .sheet(item: $reportTarget) { target in ReportSheet(store: store, uid: target.uid, threadID: thread.id, threadName: thread.title, evidence: target.evidence) .presentationDetents([.medium, .large]) } // "Convert to thread" confirms, because it reorganises the chat for // EVERYONE and, in v1, can't be undone. As its own modifier: this // body is a long chain, and the alert inline pushed the type-checker // over its budget. .modifier(ConvertToThreadConfirm(target: $convertTarget, count: convertCount) { root, ids in // A conversion that can't reach the server has to SAY so: // nothing about a failed one is visible — the chain stays // where it was, exactly as if nobody had converted anything — // so after confirming a one-way act the reader would be left // believing it happened. The inline note bar, never a toast. if !model.convertToThread(root: root, ids: ids) { showFeedNote("Those replies didn't move — you're offline.") } }) // An iPad multitasking resize can flip the size class with a // thread open — the presentation follows live (threadDocked is // derived), and this hands the thread's composer state across so // nothing typed is lost: overlay → dock moves the root-staged // main reply into the pane; dock → overlay folds the pane's draft // (or in-flight edit) back onto the main bar and re-stages the // root reply, the overlay's contract. A draft is only ever moved // into an EMPTY bar, and never while one of its uploads is still // in flight — the upload task writes its progress/blob id back // through the binding it was started with, so a chip moved // mid-upload would freeze on the other bar and wedge its send; // a mid-upload bar keeps its WHOLE draft (text included, so one // message never splits across two bars). Either refusal leaves // the pane's state parked, back on screen when the width // returns — the park survives a RESIZE only; a close or a root // switch resets it (openThread/closeThread). .onChange(of: sizeClass) { _, _ in guard let rootID = threadRootID else { return } if threadDocked { if replyTo?.id == rootID { replyTo = nil if editingID == nil, !paneHasContent, hasContent, !uploading(staged) { paneDraft = draft paneStaged = staged draft = "" staged = [] } } if composerFocused { composerFocused = false paneFocused = true } } else { if let id = paneEditingID { if editingID == nil, !hasContent, !uploading(paneStaged) { replyTo = nil editingID = id draft = paneDraft staged = paneStaged resetPaneComposer() } } else if paneHasContent, !hasContent, !uploading(paneStaged) { draft = paneDraft staged = paneStaged resetPaneComposer() } if editingID == nil { stageThreadReply() } if paneFocused { paneFocused = false composerFocused = true } } } // Coming back to the app with a thread pane open: the caret belongs // to the CHANNEL's composer, not the pane's (Paul, 2026-08-29) — // you return to a conversation, not to the reply you were parked // in. Only ever a HANDOFF, never a claim: a keyboard that was up // moves over, a keyboard that was down stays down (closeThread's // rule). Deliberate moves INTO the pane — opening a thread, a // pane bubble's Reply, a notification landing — are untouched. .onChange(of: scenePhase) { _, phase in guard phase == .active, threadDocked, threadRootID != nil, paneFocused else { return } paneFocused = false composerFocused = true } .toolbar { ToolbarItem(placement: .primaryAction) { Menu { // An app thread's Chat ↔ Interface switch — dropdown // picker rows, never bar tabs (the parent screen owns // the mode; picking Interface swaps this screen out). if let appView { AppViewPicker(store: store, thread: thread, view: appView) } // The group view picker — the same shape, grown to // list every app this group has added. if !groupApps.isEmpty { Picker("View", selection: groupViewSelection) { Label("Chat", systemImage: "bubble.left").tag("") ForEach(groupApps) { app in // Every row a Label with an SF-symbol // icon: iOS menus align Labels on one // grid but silently DROP non-image icons // (the emoji glyphs never rendered). Label(model.registry.title(app.id), systemImage: AppIdentity.symbol(for: app.id) ?? "square.grid.2x2") .tag(app.id) } } .pickerStyle(.inline) } // …then this chat's threads, listed. A view switch, so // it rides the kebab with the pickers above it and // ahead of the management verbs — the order the web's // ▤ menu uses. Present exactly while the chat HAS a // thread, so one someone else starts mid-conversation // surfaces the row (the model republishes) and a chat // where nobody has ever used "Reply in thread" is // exactly as it was. if !model.threadListRows.isEmpty { Button { showThreadList = true } label: { Label("Threads", systemImage: "bubble.left.and.bubble.right") } } // Group management (Mac parity: View Members / Rename). if isGroup { Button { showMembers = true } label: { Label("View Members", systemImage: "person.2") } Button { showRename = true } label: { Label("Rename Group", systemImage: "pencil") } Button { showGroupApps = true } label: { Label("Manage Apps", systemImage: "square.grid.2x2") } } // Media, Links & Docs — NOT group-gated (DMs too); // app threads have no shared-content feed, so it // hides there (the web's chatAppFor gate). if appView == nil, !thread.isApp { Button { showMediaView = true } label: { Label("Media, Links & Docs", systemImage: "photo.on.rectangle") } } Button { store.setPinned(thread, !isPinned) } label: { Label(isPinned ? "Unpin chat" : "Pin chat", systemImage: isPinned ? "pin.slash" : "pin") } Button { store.setMuted(thread, !isMuted) } label: { Label(isMuted ? "Unmute chat" : "Mute chat", systemImage: isMuted ? "bell" : "bell.slash") } Button { isUnread ? store.markRead(thread) : store.markUnread(thread) } label: { Label(isUnread ? "Mark as Read" : "Mark as Unread", systemImage: isUnread ? "envelope.open" : "envelope.badge") } Button(role: .destructive) { store.setHidden(thread, true) dismiss() } label: { Label("Close chat", systemImage: "xmark.circle") } // End conversation (#578): DM-only, stronger than Close // chat — mutual and server-enforced, hence the confirm. // Once ended, the banner's Reopen is the way back. if thread.kind == "dm", !model.dmClosed { Button(role: .destructive) { showEndConfirm = true } label: { Label("End conversation", systemImage: "nosign") } } } label: { Image(systemName: "ellipsis.circle") } .accessibilityLabel("Chat options") } } .confirmationDialog("End this conversation?", isPresented: $showEndConfirm, titleVisibility: .visible) { Button("End conversation", role: .destructive) { Task { // Optimistic in the store; a refused POST reverts and // we simply stay in the chat, unchanged. if await store.endConversation(thread) { dismiss() } } } } message: { Text("It closes for both of you, and no new messages can be sent unless someone reopens it.") } .sheet(isPresented: $showMembers) { MembersSheet(store: store, thread: thread, onOpenThread: { id in showMembers = false path.append(.thread(id)) }) } // The Threads list. A sheet, not a dock: iPhone has no room for a // second column, and the iPad's docked pane is where a tapped row // lands anyway — so the list is the transient index you come out // of, the same way Media, Links & Docs works. .sheet(isPresented: $showThreadList) { ThreadListSheet(model: model, onOpen: { root in showThreadList = false openThread(root) }) } .sheet(isPresented: $showRename) { RenameGroupSheet(store: store, thread: thread) } .sheet(isPresented: $showGroupApps) { GroupAppsSheet(model: model) } .sheet(isPresented: $showMediaView) { MediaLinksDocsSheet(instance: instance, thread: thread, store: store, model: model, onJump: { sid in showMediaView = false // The feed may not hold the message — // reuse the search hit's bounded // page-back walk to land on it. Self.pendingJump = (thread.id, sid) runPendingJump() }) } .onAppear { model.start() LastOpenedChat.note(origin: instance.origin, thread: thread.id, title: thread.title) // Share-sheet handoff: stage each shared file as an upload // (encrypt + upload run exactly like a picked photo), then // burn the inbox copies. The reads run detached — a shared // video is whole-file IO, and this fires during the chat-open // layout crunch. if let share = Self.pendingShare, share.thread == thread.id { Self.pendingShare = nil Task { for url in share.files { guard let data = await Task.detached(operation: { try? Data(contentsOf: url) }).value else { continue } let ext = url.pathExtension.lowercased() if ["mp4", "mov", "m4v"].contains(ext) { stageVideoData(data, name: url.lastPathComponent) } else { stageImageData(data, name: url.lastPathComponent) } } ShareInbox.clear(share.files) } } DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { withAnimation(.spring(response: 0.42, dampingFraction: 0.45)) { avatarPop = true } } } // Tapping a photo opens it in Quick Look — which zooms out of the // tapped bubble (and back on dismiss), and carries Apple's own // chrome plus the system Share / Save to Photos / Markup buttons. // An invisible presenter drives QLPreviewController imperatively from // the thumbnail's frame; the decrypted temp file is burned on // dismiss (cleanupPreview), so plaintext never lingers. .background(ImageQuickLookPresenter(item: $quickLookItem) { cleanupPreview() quickLookItem = nil }) .fullScreenCover(item: $playingVideo) { item in VideoViewer(player: item.player, sourceFrame: item.sourceFrame) { coverWithoutSlide { playingVideo = nil } } .presentationBackground(.clear) } // Tapping a file chip previews it in Quick Look (with the system // share button) once its bytes are decrypted to a temp file. .sheet(item: $filePreview, onDismiss: cleanupPreview) { item in QuickLookPreview(url: item.url) { filePreview = nil } .ignoresSafeArea() } // /themes opens the per-server settings, where the theme picker lives. .sheet(isPresented: $showThemeSettings) { ServerSettingsSheet(store: store) } // /integrity read-out (tamper evidence). A private, in-place answer, // like the web command's notice. .alert("Integrity check", isPresented: .init( get: { integrityReport != nil }, set: { if !$0 { integrityReport = nil } }) ) { Button("OK", role: .cancel) { integrityReport = nil } } message: { Text(integrityReport ?? "") } } // The viewers own their whole choreography — the photo zooms out of (and // back into) its bubble, the video flies off in its swipe direction — so // the cover must both APPEAR and VANISH without its default slide, or the // system transition visibly fights the viewer's own animation. private func coverWithoutSlide(_ change: @escaping () -> Void) { var t = Transaction() t.disablesAnimations = true withTransaction(t, change) } // A tapped photo: decrypt the ORIGINAL blob to a temp file (Quick Look reads // by URL and renders the full-fidelity image itself), then open the preview // zooming out of the tapped thumbnail. `thumbnail` is the zoom-transition // anchor; `sourceFrame` is where it zooms from / back to. Reuses the same // temp-file staging + cleanup the file preview uses (a photo and a file tap // are mutually exclusive, so they share previewCleanupDir safely). private func openQuickLook(_ att: Attachment, thumbnail: PlatformImage, sourceFrame: CGRect) { guard interaction.blobDownload == nil else { return } interaction.blobDownload = (att.id, 0) let started = Date() Task { let data = await model.blobData(att) { p in if Date().timeIntervalSince(started) > 0.15 { interaction.blobDownload = (att.id, p) } } interaction.blobDownload = nil guard let data, let url = stagePreviewFile( data, name: AttachmentDownload.safeFilename(name: att.name, mime: att.type)) else { setRouteNotice("Couldn’t open that photo.") return } // Returning from Quick Look re-fires the feed's onAppear, but // nothing re-pins on appear any more — the scroll position simply // survives the modal, so the viewed photo stays on screen. quickLookItem = QuickLookItem(id: att.id, url: url, thumbnail: thumbnail, sourceFrame: sourceFrame) } } // ---- feed -------------------------------------------------------------- // The cross-row interaction state (swipe drags, times peek, jump wave, // download progress). Plain @State, NOT @StateObject — only the row views // observe it, so a 60Hz drag re-renders the visible cells and never this // whole screen. @State private var interaction = FeedInteraction() // The screen's handle on the feed's scroll position (jumps, the button). @State private var feedController = FeedScrollController() // The open's "tap → usable feed" measurement (#1210): begun when the feed // first appears, ended by the container's first-layout callback — the // frame the reader actually sees, cold fetch included. @State private var mountSpan: Perf.Span? @State private var mountEnded = false // A search hit — or a notification tap's stamped message — lands ON its // message: jumpAndWave pages history back for it through reveal() when it // isn't loaded (the open still lands at the bottom first), and says so // inline when the walk can't reach it — a target deeper than the jump's // reach is explained rather than silently ignored. // (This used to carry its own hand-rolled walk ending in a bare `return`.) private func runPendingJump() { guard let j = Self.pendingJump, j.thread == thread.id, model.ready else { return } Self.pendingJump = nil // This open has a real target, so the notification tap's no-id // heuristic must stand down (landOnUnreadThreadReply). jumpTargeted = true jumpAndWave(j.message) } // The feed column — the scrolling transcript, the lifted thread overlay // (compact widths) and the composer inset — split out of body so the // docked thread pane can sit BESIDE it (thread-pane spec §6). The // composer inset rides this column, not the screen: docked, the pane // carries its own bar and the channel bar must stop at the divider. private var feedAndComposer: some View { feed // The thread view lifts over the FEED only (the web's // .chatapp-feedwrap overlay) — applied before the safe-area // insets, so the header pill stays above it and the real composer // below stays live, staged to the thread's root. Kept as the // !threadDocked path: regular widths dock instead. .overlay { if let rootID = threadRootID, !threadDocked { threadOverlay(rootID) .transition(.opacity.combined(with: .offset(y: 12))) } } .safeAreaInset(edge: .bottom) { // A read-only app chat never has a composer (an app surface // owning the whole pane never mounts this column at all). if !readOnlyChat { // An ended DM (#578): the server refuses new messages, so // the composer gives way to the closed banner — the one // honest action left is Reopen. Group { if model.dmClosed { dmClosedBanner } else { composer } } // Measured for the feed's rest inset — the feed extends // under the glass; content must rest above it. .background(GeometryReader { g in Color.clear .onAppear { composerHeight = g.size.height } .onChange(of: g.size.height) { _, h in composerHeight = h } }) } } } // The feed is a STOCK, unflipped UICollectionView (StockChatFeed — // promoted from the /freshpane experiment, which scrolled silky on // device where the flipped container's dressing didn't). Standard safe // areas: SwiftUI sizes it between the header and the composer/keyboard, // and the container keeps the bottom anchored across those resizes. // This screen only supplies the chrome around it and the row hooks. private var feed: some View { ZStack { StockChatFeed(model: model, interaction: interaction, controller: feedController, hooks: feedHooks, topOverlay: headerHeight, bottomOverlay: composerHeight, composerFocused: composerFocused, unreadCutoff: unreadCutoff, onFirstLayout: { count in mountEnded = true guard let span = mountSpan else { return } mountSpan = nil span.end("\(count) messages") }) // Full-bleed both ways, the Messages way: the container is a // view CONTROLLER declaring the glass overlays as REAL safe // areas (additionalSafeAreaInsets + automatic adjustment), so // the system's top-edge treatment gets honest geometry and // degrades to the subtle status-bar fade — every manual-inset // variant fed it garbage and it squashed rows over half the // screen. The keyboard safe area stays live (SwiftUI shrinks // the feed for it; the container keeps the bottom anchored // through the resize). .ignoresSafeArea(.container, edges: .vertical) // Cold loads show the loader; the first layout already lands at // the bottom, so there is no settle pass left to hide. if !model.ready { ProgressView() } } // The open, foregrounded chat is being read — arrivals here must not // accrue an unread badge. .onChange(of: model.messages.last?.id) { _, _ in if scenePhase == .active, let t = store.threads.first(where: { $0.id == thread.id }), t.unread { store.markRead(t) } } // A search hit can arrive before or after history folds — cover both. .onChange(of: model.ready) { _, ready in if ready { runPendingJump() landOnUnreadThreadReply() } } // A notification tap for the chat that's ALREADY on screen: nothing // re-appears and model.ready never flips, so the route posts and the // pending jump is consumed here (the store catch-up below folds the // NSE's row, and the walk waits for it). .onReceive(NotificationCenter.default.publisher(for: .lchatJumpToMessage)) { _ in runPendingJump() } // Foregrounding with this chat on screen: merge whatever the NSE // filed while we were suspended straight from disk — the pushed // message shows before the socket reconnects and re-pulls. .onChange(of: scenePhase) { _, phase in guard phase == .active else { return } Task { await model.catchUpFromStore() // A jump armed by a tap that beat this foreground (or landed // while the fold was still in flight) gets its second chance // once the pushed row is actually in hand. runPendingJump() } } .onAppear { // The mount clock starts at the OPENING appear only — modal // returns (Quick Look, the video viewer) re-fire onAppear on an // already-landed feed and must not restart it. if mountSpan == nil, !mountEnded { mountSpan = Perf.begin("feed.mount", slowMs: 500) } runPendingJump() landOnUnreadThreadReply() } } // The FALLBACK for a notification tap that couldn't say which message it // was about. A tap normally carries the NSE's stamped row id and lands on // it exactly (runPendingJump); a tap that doesn't — a push the extension // couldn't decrypt, a banner delivered before the stamp existed, an older // server that sends no id — still routes at THREAD granularity, and then // the feed's unread anchor (performInitialAnchor) searches its own rows // and never sees a reply hidden in its thread, while the open chat marks // itself read on arrival: the reader tapped a banner and saw an unchanged // transcript. When EVERY unread is one of those, open the newest one's // thread and land on it (jumpAndWave → jumpIntoThread). The all-or-nothing // gate lives in the pure unreadThreadJump: with even one unread visible in // the transcript the feed's own anchor is already right (it lands on the // OLDEST unread), and opening a thread over it would cover the unreads the // reader came for. Same trigger as that anchor (the tap's cutoff — set // only on a push tap), same one-shot per open. Routing stays where it is // (NotificationRoute / LChatApp): this only reads the cutoff it left. private func landOnUnreadThreadReply() { guard !jumpTargeted else { return } guard !unreadThreadChecked, let cutoff = unreadCutoff, model.ready else { return } unreadThreadChecked = true guard let id = ChatFeedModel.unreadThreadJump(in: model.messages, feed: model.feedMessages, since: cutoff) else { return } jumpAndWave(id) } // Everything a feed row needs from this screen, resolved at cell-configure // time (rows never observe the model — see FeedHooks). private var feedHooks: FeedHooks { FeedHooks(isGroup: isGroup, themeName: themeName, chatFontSize: chatFontSize, senderName: { model.senderName($0) }, foldsAsMuted: { model.foldsAsMuted($0) }, revealMuted: { model.revealMuted($0) }, actions: { messageActions($0) }, onJump: { jumpAndWave($0) }, onOpenThread: { openThread($0) }, blobSource: { model.blobSource($0) }, onImageTap: { att, image, frame in openQuickLook(att, thumbnail: image, sourceFrame: frame) }, onFileTap: { openFile($0) }, onVideoTap: { att, frame in playVideo(att, from: frame) }, unfurl: { await model.unfurl($0) }, peekUnfurl: { model.cachedUnfurl($0) }, startReply: { startReply($0) }, mentionIndex: { model.mentionIndex }, // A tapped @mention opens the same profile card the DM // header does — Message them, rename, mute, block (#577/8). onMentionTap: { headerProfile = HeaderProfileTarget(id: $0) }) } // ---- reply-thread view (chat.js openThread/closeThread/stageThreadReply) -- // The lifted thread panel over the dimmed feed. Rows reuse the exact // MessageBubble wiring the feed uses, so reactions, attachments and the // long-press menu keep working inside the view; reply actions in here // resolve to the thread's ROOT via the same startReply path. private func threadOverlay(_ rootID: Int64) -> some View { ReplyThreadView(model: model, rootID: rootID, isGroup: isGroup, swipeDownCloses: true, jumpTarget: threadJump, jumpTick: threadJumpTick, onClose: closeThread, onRootPaged: { // The root just paged in — refresh the banner to its // live snapshot, unless an edit borrowed the composer. if editingID == nil, replyTo?.id == rootID { stageThreadReply() } }, row: { m, showSender in threadBubble(m, showSender: showSender) }) } @ViewBuilder private func threadBubble(_ m: ChatFeedModel.Message, showSender: Bool, pane: Bool = false) -> some View { // The muted fold holds inside the thread view too (#578). if model.foldsAsMuted(m) { MutedMessageStub { model.revealMuted(m.id) } } else { threadMessageBubble(m, showSender: showSender, pane: pane) } } private func threadMessageBubble(_ m: ChatFeedModel.Message, showSender: Bool, pane: Bool = false) -> some View { // Wrapped in the interaction reader: the screen deliberately doesn't // observe FeedInteraction, but the overlay's download rings still need // live progress. FeedInteractionReader(interaction: interaction) { inter in MessageBubble(message: m, showSender: showSender, senderName: model.senderName(m), theme: .named(themeName), blobSource: { model.blobSource($0) }, onImageTap: { att, image, frame in openQuickLook(att, thumbnail: image, sourceFrame: frame) }, onFileTap: { openFile($0) }, onVideoTap: { att, frame in playVideo(att, from: frame) }, downloading: inter.blobDownload, actions: pane ? paneMessageActions(m) : threadMessageActions(m), unfurl: { await model.unfurl($0) }) .environment(\.chatFontSize, chatFontSize) .environment(\.mentionIndex, model.mentionIndex) .environment(\.openURL, OpenURLAction { url in guard let uid = MentionLink.uid(from: url) else { return .systemAction } headerProfile = HeaderProfileTarget(id: uid) return .handled }) // The jump wave, on the thread surface (jumpIntoThread's target). .jumpWave(on: m.id == threadDanceID, tick: threadDanceTick) } } // A jump whose target lives only in its thread — loaded, but absent from // the transcript (mainFeedItems: a thread reply with its root on screen): // open the thread view on the root (a no-op when already open there), // then land on THAT reply inside it and wave it — the feed jump's two // beats, on the view. The web lands inside .chatapp-thread-msgs the same // way, so a search hit or notification never dead-ends on "the newest // reply" instead of the one asked for. private func jumpIntoThread(_ target: ChatFeedModel.Message) { guard let root = ChatFeedModel.threadRootID(of: target) else { return } openThread(root) threadJump = target.id threadJumpTick += 1 DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { threadDanceID = target.id threadDanceTick += 1 } } // One catalog for the feed and the thread view — reply/edit/delete/copy/ // react, plus Mute/Unmute-sender (#578) on others' uid-carrying messages // (uid-less legacy messages have nothing to key a mute on). private func messageActions(_ m: ChatFeedModel.Message) -> MessageActions { var actions = MessageActions( canEdit: model.canEdit(m), onReply: { startReply(m) }, onReplyInThread: { replyInThread(m) }, onEdit: { startEdit(m) }, onDelete: { model.deleteMessage(m.id) }, onCopy: { UIPasteboard.general.string = m.text }, onReact: { model.toggleReaction(m.id, emoji: $0) }) // "Convert to thread" — only where there IS an inline chain under this // message to move, which is also what keeps it off a message nobody // answered and off the thread view's bubbles (nothing inline hangs off // one). Anyone in the chat may convert: it reorganises where messages // render, it doesn't touch what anyone wrote. The gate is the folded // flag (this runs for every row on screen); the chain itself is walked // at the tap, so what moves is what's on screen at that moment. if m.hasInlineChain { actions.onConvertToThread = { let ids = ChatFeedModel.inlineChainIDs(in: model.messages, root: m.id) guard !ids.isEmpty else { return } convertCount = ids.count // before the target: the alert reads it immediately convertTarget = ConvertTarget(root: m.id, ids: ids) } } if !m.mine, let uid = m.raw.uid, !uid.isEmpty { let muted = model.mutedPeople.contains(uid) actions.senderMuted = muted actions.senderLabel = model.senderName(m) actions.onMutePerson = { Task { await store.setPersonMuted(uid: uid, muted: !muted) } } // Report to the space's admins (#578) — same gate as the mute. actions.onReport = { reportTarget = ReportTarget(uid: uid, evidence: ReportEvidence(message: m)) } } return actions } // Reply/Edit on a bubble INSIDE the open thread OVERLAY (the Mac pane's // routing, paneMessageActions): Reply re-arms the thread's root reply on // the composer — flagged thread-only, like every send from the view — and // pops the keyboard (a reply intent, unlike a tally tap); "Reply in // thread" would resolve to the thread already open and do exactly that, // so the view's menus omit it. Edit stays the plain startEdit — cancelEdit // hands the composer back to the thread (stageThreadReply). private func threadMessageActions(_ m: ChatFeedModel.Message) -> MessageActions { var actions = messageActions(m) actions.onReply = { stageThreadReply() composerFocused = true } actions.onReplyInThread = nil // Nor "Convert to thread": a bubble in here is already in a thread, so // inlineChainIDs finds nothing under it anyway — except on the thread's // own ROOT, which can still have inline replies out in the feed. Tidying // those is a thing you do while looking at the feed, not from in here. actions.onConvertToThread = nil return actions } // The same routing for a bubble rendered inside the DOCKED pane (spec // §10.5), aimed at the pane's own bar instead of the channel one: Reply // just puts the caret there (its fixed reply already targets this // thread's root, flagged thread-only at send), Edit stages on it, and // "Reply in thread" stays omitted — inherited from the thread catalogue. private func paneMessageActions(_ m: ChatFeedModel.Message) -> MessageActions { var actions = threadMessageActions(m) actions.onReply = { paneFocused = true } actions.onEdit = { startPaneEdit(m) } return actions } private func openThread(_ rootID: Int64) { guard rootID > 0, threadRootID != rootID else { return } // A fresh open lands at the newest reply — never on a stale jump // target from an earlier one (jumpIntoThread re-aims right after). threadJump = nil // Switching roots: the old thread's half-typed pane draft or // in-flight edit must not leak into the new one — in EITHER // presentation, since pane state parked by a compact-width resize // would otherwise resurface on the next dock with paneEditingID // still naming a message from the old thread. if threadRootID != nil { resetPaneComposer() } withAnimation(.easeOut(duration: 0.18)) { threadRootID = rootID } if threadDocked { // The channel bar stays on the channel — no staging; the pane's // composer derives its reply fresh at send. Focus moves into // the pane (spec §10) only for a hardware-keyboard reader: a // tally/quote tap is a reading intent, and on a touch-only iPad // the software keyboard would rise over the replies just opened // — the rule stageThreadReply keeps for the overlay. The reply // verbs (replyInThread, the pane bubbles' Reply) focus regardless. if hardwareKeyboardAttached { paneFocused = true } } else { stageThreadReply() } } // "Reply in thread" (thread-pane spec §3.5): resolve the tapped message to // its thread's ROOT (threadTarget — a message already IN a thread // continues it, anything else roots a NEW thread on itself), open the // thread view pinned to it (a re-tap inside the open thread just re-arms // the root reply), then put the caret in the reply box — the pane's bar // when docked, the channel bar staged to the root under the overlay. // Unlike a tally/quote tap this IS a reply intent, so the keyboard pops // even on the overlay path, which otherwise never focuses on touch. This // verb is the ONLY way a thread ever begins; nothing is absorbed into one // by replying. private func replyInThread(_ m: ChatFeedModel.Message) { guard let rootID = model.threadTarget(for: m).id, rootID > 0 else { return } if threadRootID != rootID { openThread(rootID) } else if !threadDocked { stageThreadReply() } if threadDocked { paneFocused = true } else { composerFocused = true } } // Stage the composer's reply on the thread's root — the OVERLAY view's // composer contract ("Replying to "). A reply displaces an // in-flight edit, but the keyboard is deliberately NOT popped: it would // cover the thread the reader just opened (the web skips focus on touch // too). Docked, the pane's own bar carries the thread — never called. // Flagged thread-only (ReplyRef.thread): a send from the open thread // view lives in the thread, Slack-style — the main feed shows only the // root's "N replies" tally. The compact analogue of the Mac/web pane // composer's send (sendPaneDraft flags its own); a plain Reply/swipe/ // quote reply from the feed itself never carries the flag. private func stageThreadReply() { guard let rootID = threadRootID else { return } editingID = nil replyTo = model.threadReplyRef(root: rootID).inThread() } private func closeThread() { guard let rootID = threadRootID else { return } withAnimation(.easeOut(duration: 0.15)) { threadRootID = nil } threadJump = nil // The pane's composer dies with the thread (the web's docked pane // block is destroyed on close) — in EITHER presentation: pane state // a compact-width resize parked must not outlive the thread it was // typed for (a later dock would revive a draft, or an edit id, from // a thread no longer on screen). resetPaneComposer() if threadDocked { // Focus returns to the channel bar only if the pane's field HAD // it — a keyboard that's up moves over, a keyboard that's down // stays down (touch never pops one as a side effect of closing). let hadFocus = paneFocused paneFocused = false if hadFocus { composerFocused = true } } else { // Leaving the overlay drops its staged reply — never an unrelated // one staged since (an edit clears replyTo itself, so that // survives). if replyTo?.id == rootID { replyTo = nil } } } // ---- the docked pane (thread-pane spec §6, regular widths) ----------- // ReplyThreadView's panel content — header/✕, paging note, the root and // its replies as one list — as a flush right-hand column with its OWN // composer at the bottom, the web's .chatapp-thread-panel--docked. // Root-paging and the // empty-thread auto-close live in ReplyThreadView and ride along // unchanged. No onRootPaged: there's no staged banner to refresh — the // pane's reply target derives fresh at send. private func threadPane(_ rootID: Int64) -> some View { VStack(spacing: 0) { ReplyThreadView(model: model, rootID: rootID, isGroup: isGroup, docked: true, // A jump into a thread lands on THAT reply here // too (#1475): jumpIntoThread opens this pane and // re-aims with the tick, the rows wave via // threadDanceID below. jumpTarget: threadJump, jumpTick: threadJumpTick, onClose: closeThread, row: { m, showSender in threadBubble(m, showSender: showSender, pane: true) }) // An ended DM refuses new messages — the pane goes composer-less, // like the channel bar giving way to the closed banner; a // read-only app chat never had one. if !model.dmClosed, !readOnlyChat { Divider() paneComposer } } // The web's min(340px, 40%): fixed at 340 on a full-width iPad, a // 40% share when the chat surface is narrower (Split View 2/3, // Stage Manager) so the feed keeps the larger column. .frame(width: paneWidth) // The web dock's background: var(--panel), not the overlay's frosted // material — a flush column sits on the window, not over the feed. .background(ChatTheme.named(themeName).panel.resolved(scheme)) // The web dock is a complementary landmark ("Reply thread"); the // panel content inside carries the header/controls. .accessibilityElement(children: .contain) .accessibilityLabel("Reply thread") } // The pane's composer — a SECOND MessageComposerBar over its own state // set. fixedReply marks it a pane bar (no reply banner, no ✕ — the // target is the thread's root, derived fresh at send in sendPaneDraft); // commands stay off, so "/" text sends verbatim (spec risk 3). The edit // banner DOES render here (§10.5): editing a thread reply from the pane // is legitimate, with its ✕ cancelling back to the plain reply state. private var paneComposer: some View { MessageComposerBar( fixedReply: { threadRootID.map { model.threadReplyRef(root: $0).inThread() } }, allowCommands: false, placeholder: "Reply…", formatAsYouType: formatAsYouType, roster: store.roster, myUserId: store.myUserId, hasBot: hasBot, appCommands: [], displayNames: store.displayLabelMap(), hostRows: [], draft: $paneDraft, composerFocused: $paneFocused, controller: paneController, staged: paneStaged, replyTo: nil, editingID: paneEditingID, routeNotice: nil, onSend: sendPaneDraft, attach: defaultCommand == nil ? attachHooks(into: .pane) : nil, onUnstage: { s in paneStaged.removeAll { $0.id == s.id } }, onCancelReply: {}, // never rendered — fixedReply is non-nil onCancelEdit: cancelPaneEdit, onDraftChanged: { text in // Same thread, same typing beats; no "/" host pre-warm — the // pane runs no commands. model.typingBeat(active: !text.isEmpty && paneEditingID == nil) }, onEscapeFallback: { // The pane's ladder, innermost first (after the bar's own // typeahead): an armed edit cancels — the same peel as its // banner ✕ — then typed content makes Escape a no-op // (keyboard Escape is never destructive of a draft or staged // files; a deliberate close with content is the ✕), and only // an EMPTY pane composer closes the thread. if paneEditingID != nil { cancelPaneEdit() return true } if paneHasContent { return false } closeThread() return true }, onEditLatest: { // ↑ edits the latest own message IN THIS THREAD (root // included — it's pinned right here), not the channel's. guard let rootID = threadRootID, let last = ChatFeedModel.threadItems(in: model.messages, root: rootID) .last(where: { $0.mine && !$0.action }) else { return } startPaneEdit(last) }, panel: EmptyView()) } private var paneHasContent: Bool { !paneDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !paneStaged.isEmpty } private var paneWireDraft: String { let wire = paneController.serializedMarkdown(escapeLiterals: !formatAsYouType) return wire.isEmpty ? paneDraft : wire } // The pane's send: an ORDINARY replyTo reply — no command routing, no // default-command detour (matching the web pane composer). The reply // target derives FRESH here (ChatFeedModel.threadReplyRef): the root may // have paged in since the pane opened, and a loaded root snapshots better // than the frozen quote. private func sendPaneDraft() { // An edit replaces the message in place — same rule as the channel bar. if let id = paneEditingID { if model.edit(id, text: paneWireDraft) { cancelPaneEdit() } return } guard Self.canSend(draft: paneDraft, staged: paneStaged), let rootID = threadRootID else { return } // Only clear once the send reaches the socket — a down socket keeps // the draft to resend, never silently drops it (the web's rule). // Flagged thread-only (ReplyRef.thread, #1475): a send from the pane // lives in the thread — the transcript shows the root's tally only. if model.send(paneWireDraft, attachments: paneStaged.compactMap(\.attachment), replyTo: model.threadReplyRef(root: rootID).inThread()) { paneDraft = "" paneStaged = [] paneFocused = true // the next reply continues the thread (web parity) } } private func startPaneEdit(_ m: ChatFeedModel.Message) { paneEditingID = m.id paneDraft = m.raw.text ?? m.text paneFocused = true } private func cancelPaneEdit() { paneEditingID = nil paneDraft = "" } private func resetPaneComposer() { paneDraft = "" paneStaged = [] paneEditingID = nil } // Scroll to a message and wave it — quote-taps and search hits share this. // The near/far animation split lives in the container's scrollTo. private func jumpAndWave(_ id: Int64) { guard let target = model.messages.first(where: { $0.id == id }) else { // Not loaded at all — older than the page we hold. Page back for // it rather than doing nothing: this is the ordinary case for the // quote card above a plain reply on a cold-opened chat. reveal(id) return } // Loaded but not in the transcript = a thread-only reply whose root // is loaded (mainFeedItems): its bubble is in the thread view, so // land there. (With the root unloaded it degrades to an inline bubble // and the ordinary scroll below finds it.) if !model.feedMessages.contains(where: { $0.id == id }) { jumpIntoThread(target) return } feedController.scrollTo(id: id) DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { interaction.danceID = id interaction.danceTick += 1 } } // Page history back for a jump target that isn't loaded, then land on it — // the web's revealMessage, the Mac's reveal. Never a silent no-op: the // wait says so inline, and a walk that can't reach the message says which // kind of can't (deleted vs. further back than one jump goes). One walk at // a time. // // Cannot recurse: model.pageBack returning .found means the message IS in // model.messages, so jumpAndWave below takes a loaded path and can't come // back here. private func reveal(_ id: Int64) { guard revealing == nil, id > 0 else { return } revealing = id showFeedNote("Looking for that message…", sticky: true) // the outcome replaces it Task { let reach = await model.pageBack(to: id) revealing = nil showFeedNote(ChatFeedModel.jumpReachNote(reach)) guard reach == .found, model.messages.contains(where: { $0.id == id }) else { return } try? await Task.sleep(nanoseconds: 150_000_000) // let the prepended rows land jumpAndWave(id) } } // The note answers something the reader just did, so it clears itself // rather than going stale behind a thread switch — the transient-status // convention the busy captions already use here. A note that's still true // while work is in flight ("Looking for that message…") is `sticky`: it's // replaced by the outcome, never timed out from under a walk that runs // long. The generation counter keeps every clear cancel-safe. private static let feedNoteSeconds: TimeInterval = 6 private func showFeedNote(_ text: String?, sticky: Bool = false) { feedNote = text feedNoteGen += 1 guard text != nil, !sticky else { return } let gen = feedNoteGen DispatchQueue.main.asyncAfter(deadline: .now() + Self.feedNoteSeconds) { if feedNoteGen == gen { feedNote = nil } } } private func playVideo(_ att: Attachment, from frame: CGRect) { guard interaction.blobDownload == nil else { return } interaction.blobDownload = (att.id, 0) Task { let data = await model.blobData(att) { p in interaction.blobDownload = (att.id, p) } interaction.blobDownload = nil guard let data else { return } let loader = MemoryVideoAsset(data: data, mimeType: att.type) let player = AVPlayer(playerItem: AVPlayerItem(asset: loader.makeAsset())) // The frame is the card's at TAP time — good enough even though the // download ran in between (the feed can't scroll under the cover). coverWithoutSlide { playingVideo = PlayingVideo(id: att.id, player: player, loader: loader, sourceFrame: frame) } player.play() } } // A file chip tap: fetch + decrypt the blob (the chip grows a progress ring // meanwhile), stage it to a temp file, and open Quick Look — whose share // button gives Save to Files / AirDrop / open-in. Writing plaintext to a // temp dir is fine here: it's the member's deliberate act (like the web's // file-chip download), and the dir is burned when the preview closes. private func openFile(_ att: Attachment) { guard interaction.blobDownload == nil else { return } interaction.blobDownload = (att.id, 0) // The web's 150ms grace: a fetch that finishes instantly never flashes // a ring — only one that outlives the grace grows it. let started = Date() Task { let data = await model.blobData(att) { p in if Date().timeIntervalSince(started) > 0.15 { interaction.blobDownload = (att.id, p) } } interaction.blobDownload = nil guard let data, let dest = stagePreviewFile( data, name: AttachmentDownload.safeFilename(name: att.name, mime: att.type)) else { setRouteNotice("Couldn’t open that file.") return } filePreview = FilePreviewItem(url: dest) } } // Write decrypted bytes into a fresh temp subdirectory so the file keeps its // real name (Quick Look titles + previews by filename). Returns the file URL // and records the directory for cleanup. private func stagePreviewFile(_ data: Data, name: String) -> URL? { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("preview-\(UUID().uuidString)", isDirectory: true) do { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let dest = dir.appendingPathComponent(name) try data.write(to: dest, options: .atomic) previewCleanupDir = dir return dest } catch { return nil } } private func cleanupPreview() { guard let dir = previewCleanupDir else { return } try? FileManager.default.removeItem(at: dir) previewCleanupDir = nil } // ---- composer ------------------------------------------------------------ // The ended-DM banner that takes the composer's place (#578): inline in // the composer's layout slot — never floating over the feed. A refused // reopen swaps the line for its reason (their DM door / a blip), the // web's dm-closed-bar behaviour. private var dmClosedBanner: some View { HStack(spacing: 12) { Text(reopenNote ?? "This conversation is closed.") .font(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) Button("Reopen") { reopenNote = nil reopenBusy = true Task { switch await store.reopenDM(thread.id) { case .ok: break // the flag flips (store + model); the composer returns case .dmsClosed: reopenNote = "They're not accepting direct messages right now." case .failed: reopenNote = "Couldn't reopen — try again." } reopenBusy = false } } .buttonStyle(.borderedProminent) .disabled(reopenBusy) .accessibilityHint("Reopens this conversation for both of you") } .padding(.horizontal, 16) .padding(.vertical, 12) .background(.bar) } // The native composer — the bar itself is MessageComposerBar (file // scope, below): one instantiable view, so the docked thread pane can // mount a second one with its own draft. This is the MAIN mount: // user-armed reply/edit banners (fixedReply nil), commands on, the // command-host panel in the slot, and the send/staging/host machinery // below supplied as closures. private var composer: some View { MessageComposerBar( fixedReply: nil, allowCommands: true, placeholder: defaultCommand?.placeholder ?? "Message", formatAsYouType: formatAsYouType, roster: store.roster, myUserId: store.myUserId, hasBot: hasBot, // App-local verbs (/add in To-do) lead the strip, bot or no bot — // the web's commandCatalog(threadId). appCommands: AppDefaultCommand.localCommands(appID: thread.appId), displayNames: store.displayLabelMap(), hostRows: hostRows, draft: $draft, composerFocused: $composerFocused, controller: composerController, staged: staged, replyTo: replyTo, editingID: editingID, routeNotice: routeNotice, onSend: sendDraft, // A default-command thread is text-only — with no one to read a // file, an attachment is a dead end, so the "+" and paste/drop // staging go (the web composer hides its attach button the same // way). attach: defaultCommand == nil ? attachHooks(into: .main) : nil, onUnstage: { s in staged.removeAll { $0.id == s.id } }, onCancelReply: cancelReply, onCancelEdit: cancelEdit, onDraftChanged: { text in model.typingBeat(active: !text.isEmpty && editingID == nil) // Pre-warm the command host the moment a command might be // coming — its page load happens during typing, and its rows // upgrade the "/" typeahead (Mac parity). if text.first == "/", commandHost == nil { ensureCommandHost() } }, onEscapeFallback: { // An open command panel is the most recently summoned layer — // Escape peels it (after the bar's own typeahead) before the // bar's own armed edit/reply, exactly what the banner ✕ does. // The channel bar never closes the docked pane — that's the // pane bar's Escape, or its ✕ — so a reflex Escape here can't // take the thread away. if panelVisible { commandHost?.dismissPanel() return true } if editingID != nil { cancelEdit() return true } if replyTo != nil { // Under the overlay, cancelling the staged root reply IS // closing the thread (cancelReply) — and the draft would // silently stop being a thread reply. The pane bar's // rule holds here too: keyboard Escape is never // destructive, so typed/staged content makes it a no-op // (the banner ✕ closes with content); only an empty bar // peels the reply. A plain (non-thread) reply always // cancels — the draft stays, only the banner goes. if threadRootID != nil, !threadDocked, hasContent { return false } cancelReply() return true } return false }, onEditLatest: { // ↑ edits the latest own message on the surface in front of // the reader: the OVERLAY thread's (root included) while it // is up, else the FEED's — a thread-only reply of mine isn't // in the transcript. Docked, this bar IS the channel's, so // the open pane never claims it (the pane bar edits within // its own thread). let pool = threadRootID.flatMap { root in threadDocked ? nil : ChatFeedModel.threadItems(in: model.messages, root: root) } ?? model.feedMessages if let last = pool.last(where: { $0.mine && !$0.action }) { startEdit(last) } }, panel: commandPanel) } // The command panel — a command's transient interactive surface // (/emoji, /gif), the webview revealed above the composer while a // panel is showing. Mounted permanently once a host exists (height // 0 when closed) so the page loads and keeps its state — the Mac // composer's exact shape. The main bar's panel slot, between the // typeahead and the banners; the pane bar passes an EmptyView. @ViewBuilder private var commandPanel: some View { if let commandHost { CommandPanelWebView(host: commandHost) .frame(height: panelVisible ? panelHeight : 0) .clipShape(RoundedRectangle(cornerRadius: panelVisible ? 12 : 0)) .padding(.horizontal, panelVisible ? 10 : 0) .accessibilityHidden(!panelVisible) } } // Which composer a staged file lands on: the channel bar or the docked // thread pane's. Threaded through the one staging/upload pipeline below // so both bars share it — each keeps its own chip list. private enum ComposerTarget { case main, pane } private func stagedBinding(_ target: ComposerTarget) -> Binding<[StagedUpload]> { target == .main ? $staged : $paneStaged } // A bar's attachment sources, all landing on ONE target's chip list: the // "+" menu's camera/photo/file picks, an image paste, a drop. private func attachHooks(into target: ComposerTarget) -> ComposerAttachHooks { ComposerAttachHooks( onPickerItem: { stagePickerItem($0, into: target) }, onFile: { stageFile($0, into: target) }, onImageData: { stageImageData($0, name: $1, into: target) }, onDrop: { stageDroppedProviders($0, into: target) }) } // One provider pipeline for drops: prefer a real file (name + full // quality), else raw image/movie data with the provider's suggested name. private func stageDroppedProviders(_ providers: [NSItemProvider], into target: ComposerTarget) { for provider in providers { if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { _ = provider.loadObject(ofClass: URL.self) { url, _ in if let url { DispatchQueue.main.async { stageFile(url, into: target) } } } continue } if provider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { provider.loadDataRepresentation(forTypeIdentifier: UTType.movie.identifier) { data, _ in guard let data else { return } let name = provider.suggestedName ?? "video" DispatchQueue.main.async { stageVideoData(data, name: name, into: target) } } continue } if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { provider.loadDataRepresentation(forTypeIdentifier: UTType.image.identifier) { data, _ in guard let data else { return } let name = provider.suggestedName ?? "photo.jpg" DispatchQueue.main.async { stageImageData(data, name: name, into: target) } } } } } // Sendable = something typed or something uploaded, nothing still in // flight. Static so the bar's send button and both send paths (channel, // pane) read the one rule. static func canSend(draft: String, staged: [StagedUpload]) -> Bool { let hasText = !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty let uploading = staged.contains { $0.attachment == nil } return !uploading && (hasText || staged.contains { $0.attachment != nil }) } private var canSend: Bool { Self.canSend(draft: draft, staged: staged) } // Anything at all on the channel bar — typed or staged, uploaded or not // (the size-class handoff only ever moves a draft into an EMPTY bar). private var hasContent: Bool { !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !staged.isEmpty } // An upload still in flight on a bar's chip list — its task will write // back through the binding it started with, so the list must stay put. private func uploading(_ staged: [StagedUpload]) -> Bool { staged.contains { $0.attachment == nil } } // Show (or clear, with nil) the default-command push-back. Every showing // bumps the generation, so an in-flight clear timer from an EARLIER // showing — even of the identical string — can never wipe this one early. private func setRouteNotice(_ text: String?) { routeNoticeGen += 1 routeNotice = text guard text != nil else { return } let gen = routeNoticeGen DispatchQueue.main.asyncAfter(deadline: .now() + 8) { if routeNoticeGen == gen { routeNotice = nil } } } // The wire form of the draft: serialized markdown when the field is // mounted (styled runs become markers — the vector-pinned twin), the raw // draft otherwise. Commands and routing above always read `draft` (the // display text), mirroring the web's plain/markdown split. private var wireDraft: String { // Opted out of format-as-you-type = typed markers are literal and // must render literally: escape them; only the WYSIWYG formats. let wire = composerController.serializedMarkdown(escapeLiterals: !formatAsYouType) return wire.isEmpty ? draft : wire } private func sendDraft() { if let id = editingID { if model.edit(id, text: wireDraft) { cancelEdit() } return } guard canSend else { return } // Slash commands (bare text only): /me broadcasts an action line, // /theme applies a theme locally, /themes opens Settings; anything // else falls through to a normal send so a bot answers it. if staged.isEmpty, replyTo == nil, draft.first == "/" { switch SlashCommand.parse(draft, meName: model.myScreenName) { case .me(let line): if model.sendAction(line) { draft = "" } return case .setTheme(let id): themeName = id draft = "" return case .openThemes: draft = "" showThemeSettings = true return case .integrity: draft = "" integrityReport = (model.integrity?.reportLines()).map { $0.joined(separator: "\n") } ?? "Integrity monitoring isn't active for this space." return case .help, .sendAsMessage: // Not the native floor. The current app-thread's own verbs // stay native (the default-command block below); everything // else consults the thread's commandItems() rows: a // bot-answered command rides to the conversation as a // message, and every framework-owned command — /emoji, /gif, // /todo, a custom app's verb, even a typo (the web engine // answers "Unknown command — try /help" as a private notice // instead of posting the literal) — forwards to the command // host. Cleared immediately, like the web composer — a // booting page queues the text. let ownVerb = defaultCommand.map { AppDefaultCommand.routing(draft, spec: $0) != .passthrough } ?? false if !ownVerb { let host = ensureCommandHost() if CommandRouting.route(draft, rows: hostRows.isEmpty ? host.commandRows : hostRows) == .host { cmdLog.notice("forwarding to the command host: \(draft, privacy: .public)") host.run(draft) draft = "" return } } break } } // Default-command thread (To-do, Reminders): plain text — or one of the // app's own verbs — becomes the app event instead of a chat message // nobody reads; a bare verb is consumed, never posted. Async because // Reminders schedules server-side first; the sync routing() pre-check // decides whether the draft is ours at all (passthrough → normal send). // The routing contract is shared with the Mac via routeDefaultCommand. if staged.isEmpty, replyTo == nil, let dc = defaultCommand, AppDefaultCommand.routing(draft, spec: dc) != .passthrough { // One routing in flight at a time: the draft deliberately stays // visible during the schedule round-trip, so a second Enter must // not double-create the reminder. guard !dcRouting else { return } dcRouting = true let text = draft Task { @MainActor in defer { dcRouting = false } switch await model.routeDefaultCommand(text, spec: dc) { case .sent: // Clear only what we sent — typing during the schedule // round-trip must never be wiped by a stale completion. if draft == text { draft = "" } setRouteNotice(nil) // a success retires any stale push-back case .rejected(let prompt): // The draft stays put to fix; this line is the instruction. setRouteNotice(prompt) case .kept, .notHandled: break // draft stays put (down socket) } } return } if model.send(wireDraft, attachments: staged.compactMap(\.attachment), replyTo: replyTo) { draft = "" staged = [] // Overlay model only: sending from an open thread view keeps it // open and re-stages the reply to its root — the new reply folds // in live and the next send continues the same thread, like // iMessage (web parity). Docked, the channel bar stays on the // channel — the pane's own composer carries the thread. if threadRootID != nil, !threadDocked { stageThreadReply() } else { replyTo = nil } } } @State private var showThemeSettings = false @State private var integrityReport: String? // /integrity read-out (tamper evidence) // The thread's command host (CommandHostCache keeps it across thread // switches); every call re-points the sink at THIS on-screen pane — the // model-cache re-point pattern, same as the Mac composer. @discardableResult private func ensureCommandHost() -> CommandHost { let host = commandHost ?? CommandHostCache.shared.host( for: instance, thread: thread, myUserId: store.myUserId, instanceKeyB64: store.instanceKey()) if commandHost == nil { commandHost = host } host.sink = { event in switch event { case .panel(_, let height, let animate): // the web panel draws its own header panelAnimates = animate withAnimation(animate ? .easeOut(duration: 0.22) : nil) { panelHeight = min(height, 340) panelVisible = true } case .panelClosed: withAnimation(panelAnimates ? .easeIn(duration: 0.2) : nil) { panelVisible = false } case .panelHeight(let h): if panelVisible { withAnimation(panelAnimates ? .easeInOut(duration: 0.18) : nil) { panelHeight = min(h, 340) } } case .composerInsert(let text): // A panel acting on the composer (an /emoji tap) — inserted at // the caret, like the web's insertAtCaret. composerController.insert(text, replacingUTF16: nil) composerFocused = true case .notice(let from, let text): // A command's private "only visible to you" reply — the same // inline slot the default-command push-back uses. setRouteNotice(from.isEmpty ? text : "\(from) — \(text)") case .commands(let rows): // The thread's live command catalogue landed — the forwarding // decision follows it from here, and the composer bar re-ranks // its typeahead on the rows change (onChange(of: hostRows)). hostRows = rows } } // Cross-thread command routing (docs/native-apps.md phase 3): the page // asks for one narrow thread key ({needKey}) or an app's personal // thread ({needAppThread}); answer from what THIS member already // holds — never the private key. let origin = instance.origin host.keyProvider = { tid in guard let e = SharedThreadKeys.lookup(threadID: tid), e.origin == origin else { return nil } return e.key.base64urlEncodedString() } host.appThreadProvider = { [weak store] app, title in guard let store, let id = await store.openOrCreateAppThread(app: app, title: title) else { return nil } if let key = store.threads.first(where: { $0.id == id })?.threadKeyB64, !key.isEmpty { return (id, key) } return nil } if !host.commandRows.isEmpty, hostRows.isEmpty { hostRows = host.commandRows } return host } private func startReply(_ m: ChatFeedModel.Message) { editingID = nil // A second guard alongside the container menus: the overlay's own // bubbles already route Reply through threadMessageActions, which // stages the flagged root, so this catches any OTHER path into // startReply while the overlay owns the composer (a hotkey, a swipe, // a future call site). Under the overlay the channel bar IS the open // thread's reply box — without this the plain verb would quote that // bubble and land the reply in the transcript, out of the thread the // reader is standing in. if let rootID = threadRootID, !threadDocked, model.threadTarget(for: m).id == rootID { stageThreadReply() composerFocused = true return } // Plain Reply quotes the EXACT message tapped — never a root walk. // Replying to a reply stacks another quote in the feed; joining a // thread is the other verb's job ("Reply in thread"), and only that // verb's. replyTo = model.quoteTarget(for: m) composerFocused = true } // While the OVERLAY thread view is open its staged reply IS the view's // composer state — cancelling it (the banner's ✕, or a hardware Escape) // means leaving the thread (web parity), not stranding an open thread // whose sends would land outside it. Docked, the channel bar's banner is // always the reader's own staging: cancel just cancels, and the pane // closes through its own Escape/✕. private func cancelReply() { if threadRootID != nil, !threadDocked { closeThread() } else { replyTo = nil } } private func startEdit(_ m: ChatFeedModel.Message) { replyTo = nil editingID = m.id draft = m.raw.text ?? m.text composerFocused = true } private func cancelEdit() { editingID = nil draft = "" // Overlay model: an edit inside an open thread view borrowed the // composer (startEdit clears replyTo); hand it back so the next send // still continues the thread (web cancelEdit). Docked edits ride the // pane's own bar (cancelPaneEdit), so nothing to hand back. if threadRootID != nil, !threadDocked { stageThreadReply() } } // ---- uploads ------------------------------------------------------------- // The encrypt+upload pipeline behind both bars' chips: each stage* call // lands on ONE target's list (stagedBinding), the chip flips from // progress to ready when its blob id comes back. private func stagePickerItem(_ item: PhotosPickerItem, into target: ComposerTarget) { let staged = stagedBinding(target) let isVideo = item.supportedContentTypes.contains { $0.conforms(to: .movie) } let name = isVideo ? "video.mp4" : "photo.jpg" let entry = StagedUpload(name: name) staged.wrappedValue.append(entry) let onProgress: @MainActor (Double) -> Void = { p in if let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) { staged.wrappedValue[i].progress = p } } Task { guard let data = try? await item.loadTransferable(type: Data.self) else { staged.wrappedValue.removeAll { $0.id == entry.id } return } // The composer-chip preview downscale is a full decode — detached, // like the upload-side processImage in ChatFeedModel. if !isVideo, let thumb = await Task.detached(operation: { processImage(data, maxDim: 288) }).value, let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) { staged.wrappedValue[i].preview = UIImage(data: thumb.bytes) } let att = isVideo ? await model.uploadVideo(data, name: name, mimeType: "video/mp4", onProgress: onProgress) : await model.uploadImage(data, name: name, onProgress: onProgress) if let att, let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) { staged.wrappedValue[i].attachment = att } else { staged.wrappedValue.removeAll { $0.id == entry.id } } } } // Stage raw image bytes (paste / camera / share-sheet handoff) as an upload. private func stageImageData(_ data: Data, name: String = "pasted.jpg", into target: ComposerTarget = .main) { let staged = stagedBinding(target) let entry = StagedUpload(name: name) staged.wrappedValue.append(entry) let onProgress: @MainActor (Double) -> Void = { p in if let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) { staged.wrappedValue[i].progress = p } } Task { // The chip shows name-only for a beat while the preview downscale // (a full decode of the pasted image) runs detached. if let thumb = await Task.detached(operation: { processImage(data, maxDim: 288) }).value, let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) { staged.wrappedValue[i].preview = UIImage(data: thumb.bytes) } let att = await model.uploadImage(data, name: name, onProgress: onProgress) if let att, let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) { staged.wrappedValue[i].attachment = att } else { staged.wrappedValue.removeAll { $0.id == entry.id } } } } private func stageVideoData(_ data: Data, name: String, into target: ComposerTarget = .main) { let staged = stagedBinding(target) let entry = StagedUpload(name: name) staged.wrappedValue.append(entry) let onProgress: @MainActor (Double) -> Void = { p in if let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) { staged.wrappedValue[i].progress = p } } Task { let att = await model.uploadVideo(data, name: name, mimeType: "video/mp4", onProgress: onProgress) if let att, let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) { staged.wrappedValue[i].attachment = att } else { staged.wrappedValue.removeAll { $0.id == entry.id } } } } private func stageFile(_ url: URL, into target: ComposerTarget) { let staged = stagedBinding(target) let name = url.lastPathComponent let entry = StagedUpload(name: name) staged.wrappedValue.append(entry) let type = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType let onProgress: @MainActor (Double) -> Void = { p in if let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) { staged.wrappedValue[i].progress = p } } Task { // Whole-file read detached — a picked file can be tens of MB, and // the security scope only needs to bracket the read itself. let data = await Task.detached { () -> Data? in guard url.startAccessingSecurityScopedResource() else { return nil } defer { url.stopAccessingSecurityScopedResource() } return try? Data(contentsOf: url) }.value guard let data else { staged.wrappedValue.removeAll { $0.id == entry.id } return } let att = await model.uploadFile(data, name: name, mimeType: type, onProgress: onProgress) if let att, let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) { staged.wrappedValue[i].attachment = att } else { staged.wrappedValue.removeAll { $0.id == entry.id } } } } } // Attachments staged on the draft: picked → uploading (progress) → ready. // Several can ride one message, like the web's pending list. File scope: // NativeChatScreen owns the encrypt+upload pipeline that fills these; // MessageComposerBar renders them as chips. struct StagedUpload: Identifiable { let id = UUID() let name: String var preview: UIImage? // image picks/pastes show a real thumbnail var progress = 0.0 var attachment: Attachment? } // A bar's attachment sources — the "+" menu's camera / photo / file picks, // an image paste, a drop — each handed to the container's staging pipeline // for ONE composer. nil = no "+", no paste/drop staging (a default-command // thread's text-only surface). struct ComposerAttachHooks { let onPickerItem: (PhotosPickerItem) -> Void let onFile: (URL) -> Void let onImageData: (Data, String) -> Void // bytes + a filename (camera, paste) let onDrop: ([NSItemProvider]) -> Void } // The chat composer as ONE instantiable view — extracted from // NativeChatScreen so the docked reply-thread pane (regular widths) can // mount a SECOND composer, with its own draft and typeahead, beside the // channel bar. The Mac's MessageComposerBar boundary, phone-shaped: the bar // owns only what is genuinely composer-internal (the caret, the typeahead, // the picker/camera sheet flags); everything code outside the composer // reaches — the draft (startEdit writes it), focus (thread open/close // claims and releases it), reply/edit staging, the staged uploads (the // share-sheet handoff feeds them), the command host — stays with the // container and arrives as bindings/values/closures, so a second instance // is just a second container state set. // // The two presentation knobs (thread-pane spec §6): // - fixedReply: nil = the MAIN bar — user-armed reply/edit banners. Non-nil = // a pane bar: the reply target derives fresh at send time (inside the // container's onSend), so no reply banner and no cancel-✕ render here. // - allowCommands: false = no slash-command typeahead — "/" text sends // verbatim (the container's onSend skips command routing and passes no // panel). @mention and :emoji: completion still work. struct MessageComposerBar: View { let fixedReply: (() -> ReplyRef?)? let allowCommands: Bool let placeholder: String let formatAsYouType: Bool // Typeahead inputs (the web shell.js typeaheadItems' arguments) — plain // values, read point-in-time by the container like its other store reads. let roster: [RosterEntry] let myUserId: String let hasBot: Bool let appCommands: [(name: String, desc: String)] let displayNames: [String: String] let hostRows: [CommandRow] // Container-owned composer state (written from outside the bar — see the // header comment). @Binding var draft: String @Binding var composerFocused: Bool // Programmatic edits (typeahead splices, panel inserts) act on the field // via the controller, so styling and caret survive; the wire markdown is // read from it at send. Container-held: the command host's sink inserts // through it. let controller: ComposerFieldController let staged: [StagedUpload] let replyTo: ReplyRef? let editingID: Int64? let routeNotice: String? // Actions the container supplies — the send path, the staging pipeline // and the command host stay with it (they reach thread/store state). let onSend: () -> Void let attach: ComposerAttachHooks? // nil hides the "+" and paste/drop let onUnstage: (StagedUpload) -> Void let onCancelReply: () -> Void let onCancelEdit: () -> Void let onDraftChanged: (String) -> Void // typing beat + "/" host pre-warm let onEscapeFallback: () -> Bool // the container's deeper Escape layers // Hardware ↑ on an empty draft edits the latest editable message in THIS // bar's SCOPE — the channel feed, or the docked pane's thread. let onEditLatest: () -> Void // The container's command-panel slot, between the typeahead and the // banners (the main bar mounts the command-host webview here; a // commands-off bar passes an EmptyView). let panel: Panel // ---- bar-owned state ---------------------------------------------------- // The caret as a UTF-16 offset into `draft` — the typeahead needs to know // which token the caret sits in. @State private var caret = 0 @State private var nativeTypeahead: (token: ComposerToken, items: [TypeaheadItem])? // The "+" menu's sheets. @State private var pickerItem: PhotosPickerItem? @State private var showPhotoPicker = false @State private var showCamera = false @State private var showFilePicker = false var body: some View { VStack(spacing: 6) { if let ta = nativeTypeahead { typeaheadStrip(ta.items) } panel // A default command pushing back ("when should I remind you?") — // inline above the composer, occupying layout, never floating. if let routeNotice { Text(routeNotice) .font(.footnote) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) } if fixedReply == nil, let replyTo { banner("Replying to \(replyTo.name ?? "")", detail: replyTo.snippet, cancelLabel: "Cancel reply", onCancel: onCancelReply) } if editingID != nil { banner("Editing message", detail: nil, cancelLabel: "Cancel edit", onCancel: onCancelEdit) } if !staged.isEmpty { stagedChips } composerRow } .padding(.top, 4) } // The floating-glass composer: the field self-sizes // (GrowingTextView), and it shares one glass capsule with the send button. private var composerRow: some View { HStack(alignment: .bottom, spacing: 8) { // The container passes nil in a default-command thread (To-do) — // text-only surface, matching the web and Mac composers. if attach != nil { Menu { // Simulators (and some iPads) have no camera — the row // just doesn't exist there rather than failing on tap. if UIImagePickerController.isSourceTypeAvailable(.camera) { Button { showCamera = true } label: { Label("Camera", systemImage: "camera") } } Button { showPhotoPicker = true } label: { Label("Photo or Video", systemImage: "photo") } Button { showFilePicker = true } label: { Label("File", systemImage: "doc") } } label: { Image(systemName: "plus") .font(.system(size: 18, weight: .medium)) .frame(width: 36, height: 36) .floatingGlass(in: Circle()) } .buttonStyle(.plain) .accessibilityLabel("Attach") } HStack(alignment: .bottom, spacing: 6) { // sendOnReturn: false — touch Enter newlines, the send button // sends (the shouldSend rule). ComposerField(text: $draft, focused: $composerFocused, caret: $caret, placeholder: placeholder, sendOnReturn: false, formatAsYouType: formatAsYouType, controller: controller, onSend: onSend, onPasteImage: onPasteImage, onEditLast: onEditLatest, onEscape: { if nativeTypeahead != nil { nativeTypeahead = nil return true } // Deeper Escape layers belong to the // container (command panel, armed // edit/reply, the docked pane). return onEscapeFallback() }) .padding(.leading, 14) .padding(.vertical, 8) .onChange(of: draft) { _, text in refreshTypeahead() onDraftChanged(text) } .onChange(of: caret) { _, _ in refreshTypeahead() } // The thread's live command rows landing re-ranks an open // "/" menu — this stands in for the direct refresh the // container's command-host sink used to make when the // state all lived together. .onChange(of: hostRows) { _, _ in refreshTypeahead() } Button(action: onSend) { Image(systemName: "arrow.up.circle.fill") .font(.system(size: 28)) .foregroundStyle(canSend ? Color.accentColor : Color(.tertiaryLabel)) // 44pt target — a near-miss must send, not close the keyboard. .frame(width: 44, height: 40) .contentShape(Rectangle()) } .buttonStyle(.plain) .disabled(!canSend) .accessibilityLabel("Send") } .floatingGlass(in: RoundedRectangle(cornerRadius: 22)) } .padding(.horizontal, 10) .padding(.vertical, 8) .photosPicker(isPresented: $showPhotoPicker, selection: $pickerItem, matching: .any(of: [.images, .videos])) .onChange(of: pickerItem) { _, item in guard let item else { return } pickerItem = nil attach?.onPickerItem(item) } .fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.item]) { result in if case .success(let url) = result { attach?.onFile(url) } } // A fresh capture stages exactly like a pasted image — the upload // path's processImage downscales and strips EXIF/location before // encryption, same as every other photo source. .fullScreenCover(isPresented: $showCamera) { CameraCapture { data in attach?.onImageData(data, "photo.jpg") } .ignoresSafeArea() } // Drop-to-attach (iPad split view, iPhone app-to-app drags): images, // movies and files stage exactly like the pickers' choices. The system // drag badge is the whole affordance — no overlay (matching the Mac). .onDrop(of: attach != nil ? [.image, .movie, .fileURL] : [], isTargeted: nil) { providers in attach?.onDrop(providers) return true } } // A pasted image stages as an upload (the composer's "paste to upload"); // nil when the bar has no attachment sources, so a text paste there stays // a plain text paste. private var onPasteImage: ((Data) -> Void)? { guard let attach else { return nil } return { attach.onImageData($0, "pasted.jpg") } } // The container's send paths keep the same rule (NativeChatScreen.canSend) // — both read the same draft + staged, so they can't disagree. private var canSend: Bool { NativeChatScreen.canSend(draft: draft, staged: staged) } private func banner(_ title: String, detail: String?, cancelLabel: String, onCancel: @escaping () -> Void) -> some View { HStack(spacing: 8) { VStack(alignment: .leading, spacing: 1) { Text(title).font(.system(size: 12, weight: .semibold)) if let detail, !detail.isEmpty { Text(detail).font(.system(size: 12)).foregroundStyle(.secondary).lineLimit(1) } } Spacer() // A full 44pt target — the bare ~20pt glyph needed several tries. Button(action: onCancel) { Image(systemName: "xmark.circle.fill") .font(.system(size: 18)) .foregroundStyle(.tertiary) .frame(width: 44, height: 44) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel(cancelLabel) } .padding(.leading, 12) .padding(.trailing, 2) .padding(.vertical, 2) .floatingGlass(in: RoundedRectangle(cornerRadius: 16)) .padding(.horizontal, 10) } // ---- typeahead (same native brain as the Mac) -------------------------- private func refreshTypeahead() { let utf16 = min(max(caret, 0), draft.utf16.count) let idx = String.Index(utf16Offset: utf16, in: draft) let charCaret = draft.distance(from: draft.startIndex, to: idx) // A commands-off bar never menus a "/" draft — the text sends // verbatim, matching the web pane composer (cfg.commands:false). guard let token = composerToken(draft, caret: charCaret), allowCommands || token.kind != .command else { nativeTypeahead = nil return } let items = typeaheadItems(token, roster: roster, myUserId: myUserId, hasBot: hasBot, appCommands: appCommands, displayNames: displayNames, rows: hostRows) nativeTypeahead = items.isEmpty ? nil : (token, items) } private func typeaheadStrip(_ items: [TypeaheadItem]) -> some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { ForEach(items) { item in Button { pick(item) } label: { Text(chipLabel(item)) .font(.subheadline) .lineLimit(1) .padding(.horizontal, 12) .padding(.vertical, 7) .floatingGlass(in: Capsule()) } .buttonStyle(.plain) } } .padding(.horizontal, 10) } } private func chipLabel(_ item: TypeaheadItem) -> String { switch item { case .command(let name, _): return name case .emoji(let glyph, let code): return "\(glyph) :\(code):" // The chip shows the name I use for them (#577); the pick still // inserts the published @name (item.insert). case .mention(_, _, let display): return "@" + display } } private func pick(_ item: TypeaheadItem) { guard let ta = nativeTypeahead else { return } // Splice trigger → caret through the controller: a binding rewrite // would clobber styled runs and park the caret at the end. let chars = Array(draft) let utf16 = min(max(caret, 0), draft.utf16.count) let charCaret = draft.distance(from: draft.startIndex, to: String.Index(utf16Offset: utf16, in: draft)) let start = max(0, min(ta.token.start, chars.count)) let end = max(start, min(charCaret, chars.count)) let head = String(chars[0.. some View { // Fixed height, width follows the image's aspect (clamped so a // panorama can't swallow the tray and a tall shot stays readable). let h: CGFloat = 96 let aspect = preview.size.height > 0 ? preview.size.width / preview.size.height : 1 let w = min(max(h * aspect, 64), 160) return Image(uiImage: preview) .resizable().scaledToFill() .frame(width: w, height: h) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .opacity(s.attachment == nil ? 0.55 : 1) .overlay(alignment: .bottom) { if s.attachment == nil { ProgressView(value: s.progress) .tint(.white) .padding(.horizontal, 10) .padding(.bottom, 8) } } .overlay(alignment: .topTrailing) { // Dark scrim keeps the ✕ legible over any photo, either theme. Button { onUnstage(s) } label: { Image(systemName: "xmark") .font(.system(size: 10, weight: .bold)) .foregroundStyle(.white) .frame(width: 22, height: 22) .background(Circle().fill(.black.opacity(0.55))) .contentShape(Circle()) } .buttonStyle(.plain) .padding(5) .accessibilityLabel("Remove attachment") } } private func stagedFileChip(_ s: StagedUpload) -> some View { HStack(spacing: 5) { if s.attachment == nil { ProgressView(value: s.progress).frame(width: 40) } else { Text(fileIcon(s.name)).font(.system(size: 13)) } Text(s.name).font(.system(size: 12)).lineLimit(1) Button { onUnstage(s) } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 12)) .foregroundStyle(.tertiary) } .accessibilityLabel("Remove attachment") } .padding(.horizontal, 8) .padding(.vertical, 4) .background(Capsule().fill(.primary.opacity(0.06))) } } // The system camera (UIImagePickerController — SwiftUI has no native capture // view). Stills only, on purpose: movie capture records audio, which would // demand a microphone-permission prompt for a flow nobody asked for. The // shot comes back as JPEG bytes and rides the ordinary image-upload path. private struct CameraCapture: UIViewControllerRepresentable { let onCapture: (Data) -> Void @Environment(\.dismiss) private var dismiss func makeUIViewController(context: Context) -> UIImagePickerController { let picker = UIImagePickerController() picker.sourceType = .camera picker.delegate = context.coordinator return picker } func updateUIViewController(_ picker: UIImagePickerController, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(self) } final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { let parent: CameraCapture init(_ parent: CameraCapture) { self.parent = parent } func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { if let image = info[.originalImage] as? UIImage, let data = image.jpegData(compressionQuality: 0.9) { parent.onCapture(data) } parent.dismiss() } func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { parent.dismiss() } } } // Full-screen encrypted-video playback — an AVPlayerViewController wrapped so // its letterbox is TRANSPARENT (PlayerSurface): the backdrop is our own layer, // so the dismiss swipe translates only the video surface (scaling down as it // goes) while the black backdrop stays put and fades with the drag — the // Photos grammar the photo viewer already follows. (SwiftUI's VideoPlayer // painted its whole full-screen frame opaque black, so the swipe visibly // dragged the entire lightbox, dim and all.) Bytes still come from memory only. // // Like the photo viewer, the surface ZOOMS OUT OF ITS CARD to full-bleed (the // cover presents with no transition — this view is the whole choreography) // and zooms back into place on close, tap-X and swipe-down alike. // // Rotating the phone rotates the player into landscape fullscreen even though // the app is portrait-locked (VideoOrientation gates the mask while this view // is up), and a YouTube-style expand button covers phones whose rotation lock // blocks auto-rotation (RotationLockSensor infers the lock). private struct VideoViewer: View { let player: AVPlayer let sourceFrame: CGRect // the video card, in window coordinates let onClose: () -> Void // Drives the scrim and the card↔fullscreen geometry: sprung on in onAppear, // off in close() with delayed teardown. @State private var shown = false @State private var closing = false @State private var dismissOffset: CGFloat = 0 // Our overlay buttons shadow the system chrome's visibility: any touch on // the player shows them + restarts the hide timer. We can't SEE the real // chrome's state (no public API), so we never tap-to-hide — erring visible // keeps the fullscreen path reachable at the cost of occasionally outliving // the system fade by a moment. @State private var controlsVisible = true @State private var hideGen = 0 @StateObject private var lockSensor = RotationLockSensor() private var dismissProgress: CGFloat { min(1, abs(dismissOffset) / 300) } private var chromeShown: Bool { controlsVisible && shown && !closing && dismissOffset == 0 } var body: some View { ZStack { GeometryReader { geo in stage(in: geo.size) } // Full-bleed stage; local coordinates == window coordinates, the // space sourceFrame is in. .ignoresSafeArea() // The chrome keeps its own reader INSIDE the safe area. GeometryReader { geo in chrome(landscape: geo.size.width > geo.size.height) } .animation(.easeInOut(duration: 0.25), value: chromeShown) } .onAppear { withAnimation(.spring(response: 0.36, dampingFraction: 0.86)) { shown = true } VideoOrientation.setAllowsLandscape(true) pokeChrome() } // Belt-and-braces: close() already re-locks, but a cover torn down any // other way must still return the app to portrait-only. .onDisappear { VideoOrientation.setAllowsLandscape(false) } } private func stage(in viewport: CGSize) -> some View { let full = CGRect(origin: .zero, size: viewport) // A frame that never got captured (shouldn't happen) degrades to a // fade-up from center rather than a zoom from nowhere. let src = sourceFrame.isEmpty ? CGRect(x: viewport.width / 2, y: viewport.height / 2, width: 0, height: 0) : sourceFrame return ZStack { // The backdrop FADES (the chat shows through the clear cover); // only the video surface rides the swipe. Color.black.opacity(shown ? Double(1 - dismissProgress * 0.9) : 0) // Full-bleed once open (the letterbox is transparent and the // system chrome insets itself); the zoom interpolates the surface // from the card's frame, so the video grows out of its bubble. PlayerSurface(player: player, onTouch: pokeChrome) .frame(width: shown ? full.width : src.width, height: shown ? full.height : src.height) .clipShape(RoundedRectangle(cornerRadius: shown ? 0 : 18, style: .continuous)) .scaleEffect(1 - dismissProgress * 0.15) .position(x: shown ? full.midX : src.midX, y: (shown ? full.midY : src.midY) + dismissOffset) .simultaneousGesture( DragGesture(minimumDistance: 25) .onChanged { v in guard abs(v.translation.height) > abs(v.translation.width) * 1.4 else { return } dismissOffset = v.translation.height } .onEnded { v in if abs(dismissOffset) > 130 || abs(v.predictedEndTranslation.height) > 450 { close() } else { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { dismissOffset = 0 } } } ) } } @ViewBuilder private func chrome(landscape: Bool) -> some View { Button(action: close) { Image(systemName: "xmark.circle.fill") .font(.system(size: 28)) .foregroundStyle(.white.opacity(0.8)) .padding() } .opacity(chromeShown ? 1 : 0) .accessibilityLabel("Close") .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) // The YouTube-style fullscreen toggle, tucked bottom-right above // the transport bar. Portrait shows expand only while rotation // looks blocked (locked or no signal yet — .unknown defaults to // showing: a spare button beats no path to fullscreen); // landscape always shows collapse, because the lock could have // been flipped on mid-video, stranding auto-rotation back. if landscape || lockSensor.verdict != .unlocked { Button { lockSensor.noteManualRotation() VideoOrientation.rotate(to: landscape ? .portrait : .landscapeRight) } label: { Image(systemName: landscape ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right") .font(.system(size: 17, weight: .semibold)) .foregroundStyle(.white) .frame(width: 44, height: 44) .background(.black.opacity(0.55), in: Circle()) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) .padding(.trailing, 16) .padding(.bottom, 84) // clear the system transport bar .opacity(chromeShown ? 1 : 0) .accessibilityLabel(landscape ? "Exit fullscreen" : "Fullscreen") } } // Show the overlay buttons and (while playing) fade them after the same // few seconds the system chrome lingers; paused keeps them up, as it does. private func pokeChrome() { controlsVisible = true hideGen += 1 let gen = hideGen DispatchQueue.main.asyncAfter(deadline: .now() + 4) { guard gen == hideGen, player.timeControlStatus == .playing else { return } controlsVisible = false } } // Everything springs home together — the surface back into its card, the // scrim out — then the animation-less cover clears once the zoom-back has // played. Sound stops up front: a video still talking through its own // exit is wrong (the YouTube lightbox set that rule). private func close() { guard !closing else { return } closing = true player.pause() // Re-lock portrait BEFORE the cover starts leaving, or the chat behind // gets caught (or stuck) sideways. VideoOrientation.setAllowsLandscape(false) if VideoOrientation.interfaceIsLandscape { VideoOrientation.rotate(to: .portrait) } withAnimation(.spring(response: 0.32, dampingFraction: 0.88)) { shown = false dismissOffset = 0 } DispatchQueue.main.asyncAfter(deadline: .now() + 0.32, execute: onClose) } } // The AVPlayerViewController host. Two jobs SwiftUI's VideoPlayer can't do: // a CLEAR view background, so the letterbox doesn't paint the whole screen // black (the opaque full-screen surface is what made the dismiss swipe look // like it dragged the entire lightbox); and a non-stealing touch recognizer // that mirrors "the user touched the player" up to SwiftUI, so our overlay // buttons can fade roughly in step with the system chrome. private struct PlayerSurface: UIViewControllerRepresentable { let player: AVPlayer let onTouch: () -> Void func makeCoordinator() -> Coordinator { Coordinator(onTouch: onTouch) } func makeUIViewController(context: Context) -> AVPlayerViewController { let vc = AVPlayerViewController() vc.player = player vc.showsPlaybackControls = true vc.updatesNowPlayingInfoCenter = false vc.view.backgroundColor = .clear let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.tapped)) tap.cancelsTouchesInView = false tap.delegate = context.coordinator vc.view.addGestureRecognizer(tap) return vc } func updateUIViewController(_ vc: AVPlayerViewController, context: Context) { vc.view.backgroundColor = .clear // AVKit relayouts can repaint it black } final class Coordinator: NSObject, UIGestureRecognizerDelegate { let onTouch: () -> Void init(onTouch: @escaping () -> Void) { self.onTouch = onTouch } @objc func tapped() { onTouch() } // Recognise alongside the player's own recognisers — never instead of. func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { true } } } // 🤖 The "Convert to thread" confirm, as its own modifier: the chat screen's // body is a long modifier chain and an inline alert pushed the Swift // type-checker past its budget. It confirms at all because a conversion // reorganises the chat for EVERYONE and, in v1, can't be undone — and the // wording is ChatFeedModel.convertConfirm, so web, Mac and iOS ask the same // question. private struct ConvertToThreadConfirm: ViewModifier { @Binding var target: NativeChatScreen.ConvertTarget? /// The count, held by the PARENT and set in the same update as `target` — /// so it is already right when SwiftUI first evaluates the message closure, /// and it survives the dismiss (by which time `target` is nil). Reading /// through the optional instead flashed "these 0 replies" at one end or the /// other. let count: Int let convert: (Int64, [Int64]) -> Void func body(content: Content) -> some View { content.alert("Convert to Thread", isPresented: Binding( get: { target != nil }, set: { if !$0 { target = nil } }) ) { Button("Cancel", role: .cancel) { target = nil } Button("Move") { if let t = target { convert(t.root, t.ids) } target = nil } } message: { Text(ChatFeedModel.convertConfirm(count)) } } }