import LChatCore import SwiftUI import AVKit 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 = "" @State private var caret = 0 // The per-thread command host (LChatCore CommandHost — the web command // engine, headless): slash commands the native floor doesn't own forward // here; replies come back as inline notices, panels as the webview above // 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) @State private var nativeTypeahead: (token: ComposerToken, items: [TypeaheadItem])? // A default command's push-back line, generation-tokened so a re-shown // identical prompt is never wiped by an earlier showing's clear timer; // dcRouting keeps one async routing in flight (no double-sent reminders). @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) and the header // stand-in while that root isn't loaded — frozen at open, like the web's // threadFallback (chat.js openThread's ref argument). @State private var threadRootID: Int64? @State private var threadFallback: ReplyRef? @State private var pickerItem: PhotosPickerItem? @State private var showFilePicker = false @State private var staged: [StagedUpload] = [] @State private var sendQueued = false @State private var quickLookItem: QuickLookItem? @State private var playingVideo: PlayingVideo? // A downloaded file staged on disk for Quick Look, plus the temp directory // 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 StagedUpload: Identifiable { let id = UUID() let name: String var preview: UIImage? // image picks/pastes show a real thumbnail var progress = 0.0 var attachment: Attachment? } struct PlayingVideo: Identifiable { let id: String let player: AVPlayer 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 @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 } } // 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 @State private var showMembers = false @State private var showRename = false @State private var showGroupApps = 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 { feed } } // The thread view lifts over the FEED only (the web's // .chatapp-feedwrap overlay) — applied before the safe-area // insets, so the header pill stays above it and the real composer // below stays live, staged to the thread's root. .overlay { if let rootID = threadRootID { threadOverlay(rootID) .transition(.opacity.combined(with: .offset(y: 12))) } } .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) } // 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]) } .safeAreaInset(edge: .bottom) { // An app surface owns the whole pane, inputs included — the // chat composer steps aside; a read-only app chat never has // one. if activeGroupAppID == nil, !readOnlyChat { // An ended DM (#578): the server refuses new messages, so // the composer gives way to the closed banner — the one // honest action left is Reopen. Group { if model.dmClosed { dmClosedBanner } else { composerCluster } } // 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 } }) } } .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) } // 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") } } 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)) }) } .sheet(isPresented: $showRename) { RenameGroupSheet(store: store, thread: thread) } .sheet(isPresented: $showGroupApps) { GroupAppsSheet(model: model) } .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 lands ON its message: page history until the target is // loaded, then jump-and-wave. (The open still lands at the bottom first — // the walk is bounded so a hit deeper than ~8 pages stays un-jumped // rather than risking a watchdog-length stall.) private func runPendingJump() { guard let j = Self.pendingJump, j.thread == thread.id, model.ready else { return } Self.pendingJump = nil Task { var tries = 0 while !model.messages.contains(where: { $0.id == j.message }), !model.exhausted, tries < 8 { tries += 1 await model.loadOlder() try? await Task.sleep(nanoseconds: 550_000_000) } guard model.messages.contains(where: { $0.id == j.message }) else { return } try? await Task.sleep(nanoseconds: 150_000_000) jumpAndWave(j.message) } } // 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() } } // 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() } } .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() } } // 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, fallback: threadFallback, theme: .named(themeName), isGroup: isGroup, swipeDownCloses: true, 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) -> 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) } } private func threadMessageBubble(_ m: ChatFeedModel.Message, showSender: Bool) -> 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: messageActions(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 }) } } // 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) }, onEdit: { startEdit(m) }, onDelete: { model.deleteMessage(m.id) }, onCopy: { UIPasteboard.general.string = m.text }, onReact: { model.toggleReaction(m.id, emoji: $0) }) 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 } private func openThread(_ rootID: Int64) { guard rootID > 0, threadRootID != rootID else { return } // Freeze the header stand-in NOW (web threadFallback) — the deepest // quoted snapshot in loaded state, in case the root never pages in. threadFallback = model.threadReplyRef(root: rootID) withAnimation(.easeOut(duration: 0.18)) { threadRootID = rootID } stageThreadReply() } // Stage the composer's reply on the thread's root — the 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). private func stageThreadReply() { guard let rootID = threadRootID else { return } editingID = nil replyTo = model.threadReplyRef(root: rootID) } private func closeThread() { guard let rootID = threadRootID else { return } withAnimation(.easeOut(duration: 0.15)) { threadRootID = nil } threadFallback = nil // Leaving the thread drops its staged reply — never an unrelated one // staged since (an edit clears replyTo itself, so that survives). if replyTo?.id == rootID { replyTo = nil } } // 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) { feedController.scrollTo(id: id) DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { interaction.danceID = id interaction.danceTick += 1 } } 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) } private var composerCluster: some View { VStack(spacing: 6) { if let ta = nativeTypeahead { typeaheadStrip(ta.items) } // The command panel — a command's transient interactive surface // (/emoji, /gif), the webview revealed above the composer while a // panel is showing. Mounted permanently once a host exists (height // 0 when closed) so the page loads and keeps its state — the Mac // composer's exact shape. if let commandHost { CommandPanelWebView(host: commandHost) .frame(height: panelVisible ? panelHeight : 0) .clipShape(RoundedRectangle(cornerRadius: panelVisible ? 12 : 0)) .padding(.horizontal, panelVisible ? 10 : 0) .accessibilityHidden(!panelVisible) } // A default command pushing back ("when should I remind you?") — // inline above the composer, occupying layout, never floating. if let routeNotice { Text(routeNotice) .font(.footnote) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) } if let replyTo { banner("Replying to \(replyTo.name ?? "")", detail: replyTo.snippet) { // While the thread view is open its staged reply IS the // view's composer state — cancelling it means leaving the // thread (web parity), not stranding an open thread whose // sends would land outside it. if threadRootID != nil { closeThread() } else { self.replyTo = nil } } } if editingID != nil { banner("Editing message", detail: nil) { cancelEdit() } } 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) { // A default-command thread is text-only — with no one to read a // file, an attachment is a dead end, so the affordance goes (the // web composer hides its attach button the same way). if defaultCommand == nil { Menu { // Simulators (and some iPads) have no camera — the row // just doesn't exist there rather than failing on tap. if UIImagePickerController.isSourceTypeAvailable(.camera) { Button { showCamera = true } label: { Label("Camera", systemImage: "camera") } } Button { showPhotoPicker = true } label: { Label("Photo or Video", systemImage: "photo") } Button { showFilePicker = true } label: { Label("File", systemImage: "doc") } } label: { Image(systemName: "plus") .font(.system(size: 18, weight: .medium)) .frame(width: 36, height: 36) .floatingGlass(in: Circle()) } .buttonStyle(.plain) } HStack(alignment: .bottom, spacing: 6) { // sendOnReturn: false — touch Enter newlines, the send button // sends (the shouldSend rule). ComposerField(text: $draft, focused: $composerFocused, caret: $caret, placeholder: defaultCommand?.placeholder ?? "Message", sendOnReturn: false, formatAsYouType: formatAsYouType, controller: composerController, onSend: sendDraft, onPasteImage: { if defaultCommand == nil { stageImageData($0) } }, onEditLast: { if let last = model.messages.last(where: { $0.mine && !$0.action }) { startEdit(last) } }) .padding(.leading, 14) .padding(.vertical, 8) .onChange(of: draft) { _, _ in refreshTypeahead() model.typingBeat(active: !draft.isEmpty && editingID == nil) // Pre-warm the command host the moment a command might // be coming — its page load happens during typing, and // its rows upgrade the "/" typeahead (Mac parity). if draft.first == "/", commandHost == nil { ensureCommandHost() } } .onChange(of: caret) { _, _ in refreshTypeahead() } Button(action: sendDraft) { Image(systemName: "arrow.up.circle.fill") .font(.system(size: 28)) .foregroundStyle(canSend ? Color.accentColor : Color(.tertiaryLabel)) // 44pt target — a near-miss must send, not close the keyboard. .frame(width: 44, height: 40) .contentShape(Rectangle()) } .buttonStyle(.plain) .disabled(!canSend) .accessibilityLabel("Send") } .floatingGlass(in: RoundedRectangle(cornerRadius: 22)) } .padding(.horizontal, 10) .padding(.vertical, 8) .photosPicker(isPresented: $showPhotoPicker, selection: $pickerItem, matching: .any(of: [.images, .videos])) .onChange(of: pickerItem) { _, item in guard let item else { return } pickerItem = nil stagePickerItem(item) } .fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.item]) { result in if case .success(let url) = result { stageFile(url) } } // A fresh capture stages exactly like a pasted image — the upload // path's processImage downscales and strips EXIF/location before // encryption, same as every other photo source. .fullScreenCover(isPresented: $showCamera) { CameraCapture { data in stageImageData(data, name: "photo.jpg") } .ignoresSafeArea() } // Drop-to-attach (iPad split view, iPhone app-to-app drags): images, // movies and files stage exactly like the pickers' choices. The system // drag badge is the whole affordance — no overlay (matching the Mac). .onDrop(of: defaultCommand == nil ? [.image, .movie, .fileURL] : [], isTargeted: nil) { providers in stageDroppedProviders(providers) return true } } // 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]) { 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) } } } 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) } } 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) } } } } } @State private var showPhotoPicker = false @State private var showCamera = false private var canSend: Bool { let hasText = !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty let uploading = staged.contains { $0.attachment == nil } return !uploading && (hasText || 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 = [] // Sending from an open thread view keeps it open and re-stages the // reply to its root — the new reply folds in live and the next // send continues the same thread, like iMessage (web parity). if threadRootID != nil { stageThreadReply() } else { replyTo = nil } } } @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 typeahead // and the forwarding decision follow it from here. hostRows = rows refreshTypeahead() } } // 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 // Replying to a reply continues its thread: target the TOP message. replyTo = model.replyTarget(for: m) composerFocused = true } 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 = "" // An edit inside an open thread view borrowed the composer (startEdit // clears replyTo); hand it back so the next send still continues the // thread (web cancelEdit). if threadRootID != nil { stageThreadReply() } } private func banner(_ title: String, detail: 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) } .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) guard let token = composerToken(draft, caret: charCaret) else { nativeTypeahead = nil return } // App-local verbs (/add in To-do) lead the strip, bot or no bot — // the web's commandCatalog(threadId). let items = typeaheadItems(token, roster: store.roster, myUserId: store.myUserId, hasBot: hasBot, appCommands: AppDefaultCommand.localCommands(appID: thread.appId), displayNames: store.displayLabelMap(), 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 { staged.removeAll { $0.id == s.id } } 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 { staged.removeAll { $0.id == s.id } } 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))) } private func stagePickerItem(_ item: PhotosPickerItem) { let isVideo = item.supportedContentTypes.contains { $0.conforms(to: .movie) } let name = isVideo ? "video.mp4" : "photo.jpg" let entry = StagedUpload(name: name) staged.append(entry) let onProgress: @MainActor (Double) -> Void = { p in if let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].progress = p } } Task { guard let data = try? await item.loadTransferable(type: Data.self) else { staged.removeAll { $0.id == entry.id } return } // The composer-chip preview downscale is a full decode — detached, // like the upload-side processImage in ChatFeedModel. if !isVideo, let thumb = await Task.detached(operation: { processImage(data, maxDim: 288) }).value, let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].preview = UIImage(data: thumb.bytes) } let att = isVideo ? await model.uploadVideo(data, name: name, mimeType: "video/mp4", onProgress: onProgress) : await model.uploadImage(data, name: name, onProgress: onProgress) if let att, let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].attachment = att } else { staged.removeAll { $0.id == entry.id } } } } // Stage raw image bytes (paste / share-sheet handoff) as an upload. private func stageImageData(_ data: Data, name: String = "pasted.jpg") { let entry = StagedUpload(name: name) staged.append(entry) let onProgress: @MainActor (Double) -> Void = { p in if let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].progress = p } } Task { // The chip shows name-only for a beat while the preview downscale // (a full decode of the pasted image) runs detached. if let thumb = await Task.detached(operation: { processImage(data, maxDim: 288) }).value, let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].preview = UIImage(data: thumb.bytes) } let att = await model.uploadImage(data, name: name, onProgress: onProgress) if let att, let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].attachment = att } else { staged.removeAll { $0.id == entry.id } } } } private func stageVideoData(_ data: Data, name: String) { let entry = StagedUpload(name: name) staged.append(entry) let onProgress: @MainActor (Double) -> Void = { p in if let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].progress = p } } Task { let att = await model.uploadVideo(data, name: name, mimeType: "video/mp4", onProgress: onProgress) if let att, let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].attachment = att } else { staged.removeAll { $0.id == entry.id } } } } private func stageFile(_ url: URL) { let name = url.lastPathComponent let entry = StagedUpload(name: name) staged.append(entry) let type = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType let onProgress: @MainActor (Double) -> Void = { p in if let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].progress = p } } Task { // Whole-file read detached — a picked file can be tens of MB, and // the security scope only needs to bracket the read itself. let data = await Task.detached { () -> Data? in guard url.startAccessingSecurityScopedResource() else { return nil } defer { url.stopAccessingSecurityScopedResource() } return try? Data(contentsOf: url) }.value guard let data else { staged.removeAll { $0.id == entry.id } return } let att = await model.uploadFile(data, name: name, mimeType: type, onProgress: onProgress) if let att, let i = staged.firstIndex(where: { $0.id == entry.id }) { staged[i].attachment = att } else { staged.removeAll { $0.id == entry.id } } } } } // The system camera (UIImagePickerController — SwiftUI has no native capture // 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 } } }