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