| 1 | import LChatCore |
| 2 | import SwiftUI |
| 3 | import WebKit |
| 4 | |
| 5 | // ๐ค An app thread without a native interface (Admin, webhook-monitor, member |
| 6 | // apps): the CHAT view is the native feed โ NativeChatScreen, the same surface |
| 7 | // every chat uses โ and only the app's own INTERFACE stays a webview, pinned |
| 8 | // to view:"interface". Chat never renders in a webview (the product rule: |
| 9 | // webviews are for custom app UI the native primitives can't express, nothing |
| 10 | // else); this replaced ChatView, the old whole-thread webview fallback. |
| 11 | // |
| 12 | // The Chat โ Interface switch lives in the โฎ menu as picker rows โ never a |
| 13 | // segmented control in the bar (it collided with the floating avatar header, |
| 14 | // and Paul's call 2026-07-25: dropdown items, not tabs). In chat mode the |
| 15 | // rows ride NativeChatScreen's own โฎ; in interface mode this screen's. |
| 16 | // |
| 17 | // The registry's presentation decides the shape, mirroring the Mac's ladder |
| 18 | // (NativeChatView.companionSplit / appInterfacePane): |
| 19 | // companion โ Chat + Interface, switched from the โฎ (iOS has no |
| 20 | // split, so defaultView "split" opens chat-first, |
| 21 | // exactly as ChatView read it) |
| 22 | // companion, chatOnly โ just the native chat, no switch |
| 23 | // takeover โ just the interface webview, no chat at all |
| 24 | // schema (fallback) โ SchemaAppScreen couldn't build its store; same |
| 25 | // companion shape, with the webview as the interface |
| 26 | struct AppThreadScreen: View { |
| 27 | let instance: SavedInstance |
| 28 | let thread: ChatThread |
| 29 | let store: ThreadStore |
| 30 | |
| 31 | @State private var mode: String // "chat" | "interface" |
| 32 | private let hasChat: Bool |
| 33 | private let hasInterface: Bool |
| 34 | // The app posts into its chat, the member doesn't (Admin's update stream โ |
| 35 | // the manifest's chatReadOnly): the native feed drops its composer. |
| 36 | private let chatReadOnly: Bool |
| 37 | // Passed through to the chat view: its profile cards hop threads. |
| 38 | @Binding private var path: [Route] |
| 39 | @Environment(\.dismiss) private var dismiss |
| 40 | |
| 41 | @MainActor |
| 42 | init(instance: SavedInstance, thread: ChatThread, store: ThreadStore, |
| 43 | path: Binding<[Route]>) { |
| 44 | self.instance = instance |
| 45 | self.thread = thread |
| 46 | self.store = store |
| 47 | self._path = path |
| 48 | switch store.registry.presentation(thread.appId ?? "") { |
| 49 | case .companion(let defaultView, let chatOnly, let readOnly): |
| 50 | hasChat = true |
| 51 | hasInterface = !chatOnly |
| 52 | chatReadOnly = readOnly |
| 53 | _mode = State(initialValue: |
| 54 | (defaultView == "interface" && !chatOnly) ? "interface" : "chat") |
| 55 | case .schema(let companion): |
| 56 | hasChat = companion |
| 57 | hasInterface = true |
| 58 | chatReadOnly = false |
| 59 | _mode = State(initialValue: "interface") |
| 60 | default: // .takeover; .flow routes to ElevenChatScreen before here |
| 61 | hasChat = false |
| 62 | hasInterface = true |
| 63 | chatReadOnly = false |
| 64 | _mode = State(initialValue: "interface") |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | var body: some View { |
| 69 | if mode == "chat", hasChat { |
| 70 | NativeChatScreen(instance: instance, thread: thread, store: store, |
| 71 | path: $path, readOnlyChat: chatReadOnly, |
| 72 | appView: hasInterface ? $mode : nil) |
| 73 | } else { |
| 74 | AppInterfacePane(instance: instance, thread: thread, |
| 75 | myUserId: store.myUserId, |
| 76 | instanceKeyB64: store.instanceKey()) |
| 77 | .navigationTitle(thread.title) |
| 78 | .navigationBarTitleDisplayMode(.inline) |
| 79 | .toolbar { |
| 80 | ToolbarItem(placement: .primaryAction) { |
| 81 | AppInterfaceKebab(store: store, thread: thread, |
| 82 | appView: hasChat ? $mode : nil, |
| 83 | onClose: { dismiss() }) |
| 84 | } |
| 85 | } |
| 86 | } |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | // The Chat โ Interface switch as dropdown picker rows: Chat plus the app |
| 91 | // itself (its own title + symbol), checkmarked by selection. Rides inside a |
| 92 | // โฎ Menu โ NativeChatScreen's in chat mode, AppInterfaceKebab's in interface |
| 93 | // mode โ so the nav bar stays clear for the floating header. |
| 94 | struct AppViewPicker: View { |
| 95 | let store: ThreadStore |
| 96 | let thread: ChatThread |
| 97 | @Binding var view: String // "chat" | "interface" |
| 98 | |
| 99 | var body: some View { |
| 100 | Picker("View", selection: $view) { |
| 101 | Label("Chat", systemImage: "bubble.left").tag("chat") |
| 102 | // Labels with SF-symbol icons only โ iOS menus silently drop |
| 103 | // non-image icons (the emoji glyphs never rendered). |
| 104 | Label(store.registry.title(thread.appId ?? ""), |
| 105 | systemImage: AppIdentity.symbol(for: thread.appId ?? "") ?? "square.grid.2x2") |
| 106 | .tag("interface") |
| 107 | } |
| 108 | .pickerStyle(.inline) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | // The interface view's โฎ: the view switch (when the app has a Chat view at |
| 113 | // all) plus the chat-management basics the chat chrome would otherwise carry. |
| 114 | struct AppInterfaceKebab: View { |
| 115 | let store: ThreadStore |
| 116 | let thread: ChatThread |
| 117 | /// nil = no Chat view to switch to (takeover apps). |
| 118 | let appView: Binding<String>? |
| 119 | let onClose: () -> Void |
| 120 | |
| 121 | var body: some View { |
| 122 | Menu { |
| 123 | if let appView { |
| 124 | AppViewPicker(store: store, thread: thread, view: appView) |
| 125 | } |
| 126 | Button { |
| 127 | store.setPinned(thread, !isPinned) |
| 128 | } label: { |
| 129 | Label(isPinned ? "Unpin chat" : "Pin chat", |
| 130 | systemImage: isPinned ? "pin.slash" : "pin") |
| 131 | } |
| 132 | Button(role: .destructive) { |
| 133 | store.setHidden(thread, true) |
| 134 | onClose() |
| 135 | } label: { |
| 136 | Label("Close chat", systemImage: "xmark.circle") |
| 137 | } |
| 138 | } label: { |
| 139 | Image(systemName: "ellipsis.circle") |
| 140 | } |
| 141 | .accessibilityLabel("Chat options") |
| 142 | } |
| 143 | |
| 144 | // Live pinned state: the store row changes as the menu toggles it. |
| 145 | private var isPinned: Bool { |
| 146 | store.threads.first { $0.id == thread.id }?.pinned ?? thread.pinned |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | // The app's own face: the embed page (FeedWebCache) seeded โ and held โ on |
| 151 | // view:"interface". The page can carry the whole web client, but this surface |
| 152 | // only ever shows the app pane; the re-asserts pin a parked or freshly booted |
| 153 | // page there so it can never sit on a chat view (GroupAppPane's pattern). |
| 154 | struct AppInterfacePane: View { |
| 155 | @ObservedObject private var bridge: WebBridge |
| 156 | let instance: SavedInstance |
| 157 | let thread: ChatThread |
| 158 | let myUserId: String |
| 159 | let instanceKeyB64: String? |
| 160 | |
| 161 | @MainActor |
| 162 | init(instance: SavedInstance, thread: ChatThread, myUserId: String, |
| 163 | instanceKeyB64: String?) { |
| 164 | self.instance = instance |
| 165 | self.thread = thread |
| 166 | self.myUserId = myUserId |
| 167 | self.instanceKeyB64 = instanceKeyB64 |
| 168 | bridge = FeedWebCache.shared.bridge(for: instance, thread: thread, |
| 169 | myUserId: myUserId, |
| 170 | instanceKeyB64: instanceKeyB64) |
| 171 | } |
| 172 | |
| 173 | var body: some View { |
| 174 | VStack(spacing: 0) { |
| 175 | AppUpdateBar(bridge: bridge) |
| 176 | ZStack { |
| 177 | AppInterfaceWebView(instance: instance, thread: thread, |
| 178 | myUserId: myUserId, instanceKeyB64: instanceKeyB64) |
| 179 | if !bridge.pageReady { |
| 180 | WebLoadingVeil().transition(.opacity) |
| 181 | } |
| 182 | } |
| 183 | } |
| 184 | .animation(.easeOut(duration: 0.25), value: bridge.pageReady) |
| 185 | .onAppear { bridge.setAppView("interface") } |
| 186 | .onChange(of: bridge.pageReady) { _, ready in |
| 187 | if ready { bridge.setAppView("interface") } |
| 188 | } |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | private struct AppInterfaceWebView: UIViewRepresentable { |
| 193 | let instance: SavedInstance |
| 194 | let thread: ChatThread |
| 195 | let myUserId: String |
| 196 | let instanceKeyB64: String? |
| 197 | |
| 198 | func makeUIView(context: Context) -> WKWebView { |
| 199 | FeedWebCache.shared.webView(for: instance, thread: thread, myUserId: myUserId, |
| 200 | instanceKeyB64: instanceKeyB64) |
| 201 | } |
| 202 | |
| 203 | func updateUIView(_ web: WKWebView, context: Context) {} |
| 204 | } |
| 205 | |