iOS: chat is native everywhere — delete ChatView, the webview chat fallback (#1349)
* ios: chat is native everywhere — delete ChatView, the webview chat fallback An app thread without a native presentation (Admin, webhook-monitor, member apps) used to open ChatView: the whole web client in a WKWebView, carrying the app's interface AND its chat. Per the product rule — chat never renders in a webview; webviews are only for custom app UI the native primitives can't express — that surface is gone: - AppThreadScreen routes every such thread: the Chat view is NativeChatScreen (the same native feed every chat uses), and only the app's own Interface stays a webview (AppInterfacePane, pinned to view:"interface" at seed + attach + page-ready). Companion apps get the Chat|Interface toggle (SchemaAppScreen's shape), chat-only companions get pure native chat, takeover apps get interface only (with pin/close in a ⋮ since there's no chat chrome to carry them). - NativeChatScreen learns readOnlyChat (the manifest's chatReadOnly — Admin's update stream): no composer at all, the web's .chatapp-root.readonly rule. - ChatView.swift → WebBridge.swift: the bridge survives as the interface-pane bridge (ready state, setAppView/setGroupApp, WebKit delegate glue); the chat-embed machinery (native composer forwarding, insets, reply/edit mirroring, didAttach re-pinning) is deleted with the surface. floatingGlass moves to its own file; EdgeOnlyBackGesture (only ever installed by ChatView) is deleted; FeedWebCache drops the chat-view seed, AppViewMemory, and the never-called warm(). Mac already had this shape (native chat panes + interface-only webviews); iOS now matches. docs/native-apps.md + native-chat.md updated to say so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: Chat/Interface switch moves into the kebab — dropdown rows, never bar tabs The segmented control sat in the nav bar's principal slot, colliding with the floating avatar header (and SchemaAppScreen had the same). Now: AppViewPicker (Chat + the app's own title/symbol, checkmarked) rides as inline picker rows in the ⋮ — NativeChatScreen's own menu in chat mode, the new shared AppInterfaceKebab (view switch + pin/close) in interface mode. AppThreadScreen and SchemaAppScreen both use it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: thread the navigation path through AppThreadScreen (post-#1344 rebase) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
14 files changed,
+535
−1170
docs/native-apps.md+11 −4docs/native-chat.md+8 −0ios/LChat/FeedWebCache.swift+33 −58ios/LChat/PushManager.swift+2 −2ios/LChat/Views/AppThreadScreen.swift+201 −0ios/LChat/Views/ChatView.swift+0 −1019ios/LChat/Views/EdgeOnlyBackGesture.swift+0 −41ios/LChat/Views/FloatingGlass.swift+29 −0ios/LChat/Views/GroupAppPane.swift+2 −2ios/LChat/Views/NativeChatScreen.swift+29 −11ios/LChat/Views/SchemaAppScreen.swift+20 −26ios/LChat/Views/ThreadListView.swift+6 −7ios/LChat/Views/WebBridge.swift+185 −0ios/LChatCore/Package.resolved+9 −0
diff --git a/docs/native-apps.md b/docs/native-apps.md| index 5038f33..df4d28b 100644 |
| --- a/docs/native-apps.md |
| +++ b/docs/native-apps.md |
| @@ -134,6 +134,10 @@ Three parallel app subsystems grew independently in `ios/`: |
| iOS: `ChatView`+`WebBridge`+`FeedWebCache`; Mac: `AppThreadWebView`+ |
| `MacWebBridge`+`AppWebViewCache`, plus a Mac-only per-thread **command |
| host** (`CommandPanelWebView`, `mode:"command"`) used for `/emoji`/`/gif`. |
| + *(Since resolved on iOS: `ChatView` — the embed that could carry a |
| + thread's CHAT — is deleted. `AppThreadScreen` renders app-thread chat |
| + native and pins every surviving embed to `view:"interface"`; a webview |
| + now only ever carries an app's own interface, on both platforms.)* |
| 3. **Flow apps** — `FlowEngine` for `setup`/`welcome`. |
| Overlaid: a native `groupapps` fold (`GroupApps.swift`) and **~10 hand-kept |
| @@ -242,10 +246,13 @@ apps' commands route correctly inside the host too (phase 4). |
| - **Surface routing**: iOS `ThreadListView.chatSurface` and Mac |
| `NativeChatView.feed` stop switching on hard-coded names. One shared |
| decision: `registry.presentation(appId)` → *native-schema* (schema exists) / |
| - *flow* (`setup`/`welcome`) / *companion webview* (Chat|Split|Interface on |
| - Mac, Chat|Interface on iOS, honouring `defaultView`, `chatOnly`, |
| - `chatReadOnly`) / *takeover webview* (seed `view:"interface"` — fixes |
| - Admin-on-iOS) — with *webview* as the universal fallback. |
| + *flow* (`setup`/`welcome`) / *companion* (Chat|Split|Interface on Mac, |
| + Chat|Interface on iOS — the Chat pane is the NATIVE feed on both, honouring |
| + `defaultView`, `chatOnly`, `chatReadOnly`) / *takeover* (the app's |
| + interface, full pane) — with the interface-only webview as the universal |
| + fallback. Chat itself never renders in a webview: a webview may only carry |
| + an app's own interface (iOS `AppThreadScreen`/`AppInterfacePane`, Mac |
| + `companionSplit`/`appInterfacePane`). |
| - **Pickers**: New-chat ▸ Apps (personal = `places` ∋ "you"), group ⋮ ▸ Apps |
| (chat-capable, so the board appears), and the space band (space installs, |
| from `SpaceAppsStore`) all render from the registry — custom and member |
diff --git a/docs/native-chat.md b/docs/native-chat.md| index fc32933..a10e08f 100644 |
| --- a/docs/native-chat.md |
| +++ b/docs/native-chat.md |
| @@ -1,5 +1,13 @@ |
| # 🤖 Native chat feed (Mac first) |
| +> **Update 2026-07-25 — the takeover is COMPLETE on iOS too.** NativeChatScreen |
| +> is the only chat surface (plain chats and app-thread Chat views alike); |
| +> `ChatView` — the per-thread webview feed this document plans around — is |
| +> **deleted**. Webviews on both native apps now carry only an app's own |
| +> interface (iOS `AppThreadScreen`/`AppInterfacePane`, Mac `appInterfacePane`); |
| +> chat never renders in a webview, by policy. The status note below is the |
| +> historical hand-off brief from the Mac takeover era — read it as history. |
| + |
| > Status: **THE TAKEOVER SHIPPED (2026-07-07)** — native chat is the Mac's |
| > main surface: NativeChatView = native thread list (sidebar header with |
| > search/new-chat/⋮, pin/close context menus, live via an instance-wide |
diff --git a/ios/LChat/FeedWebCache.swift b/ios/LChat/FeedWebCache.swift| index 08df33a..c0e4023 100644 |
| --- a/ios/LChat/FeedWebCache.swift |
| +++ b/ios/LChat/FeedWebCache.swift |
| @@ -2,25 +2,21 @@ import LChatCore |
| import WebKit |
| import UIKit |
| -// Keeps each thread's feed webview (plus its WebBridge) alive after you leave the |
| -// chat, and pre-warms the top chats after a thread-list load — so tapping into a |
| -// chat attaches an already-rendered feed instead of booting the page from scratch |
| -// behind its loading spinner. The native mirror of chat.js's per-thread feed cache |
| -// + prebuild on web/desktop. |
| +// Keeps each app-interface webview (plus its WebBridge) alive after you leave |
| +// the thread, so re-opening attaches an already-rendered page instead of |
| +// booting it from scratch behind its loading spinner. Chat never renders in |
| +// these pages — every surface here is an app's own interface (AppInterfacePane, |
| +// GroupAppPane, SpaceAppScreen); the chat feed is native everywhere. |
| // |
| // The E2E posture is unchanged: every webview is still seeded with exactly ONE |
| // thread's unwrapped key (never the user private key), so a page can't reach |
| // another thread's content — we just keep more of those single-thread pages |
| -// around. Parked pages keep their WebSocket, and chat.js reconnects + catches up |
| -// on its own after a drop, so a re-attached feed is already current. |
| +// around. Parked pages keep their WebSocket, and the page reconnects + catches |
| +// up on its own after a drop, so a re-attached surface is already current. |
| @MainActor |
| final class FeedWebCache { |
| static let shared = FeedWebCache() |
| - // How many of the newest chats to warm after a thread-list load. Webviews are |
| - // heavier than the web's cached DOM fragments (a content process + an open |
| - // socket each), so this and the cap sit well under chat.js's PREBUILD_MAX. |
| - static let prewarmCount = 5 |
| private let maxEntries = 8 |
| private final class Entry { |
| @@ -80,27 +76,17 @@ final class FeedWebCache { |
| return e.web |
| } |
| - // The bridge that owns this thread's webview. ChatView observes this instead of |
| - // creating its own, so composer state (staged reply/photo) survives with the page. |
| + // The bridge that owns this thread's webview. Panes observe this instead of |
| + // creating their own, so ready state survives with the page. |
| func bridge(for instance: SavedInstance, thread: ChatThread, myUserId: String, |
| - instanceKeyB64: String?, app: String? = nil, |
| - defaultView: String = "chat") -> WebBridge { |
| + instanceKeyB64: String?, app: String? = nil) -> WebBridge { |
| entry(for: instance, thread: thread, myUserId: myUserId, |
| - instanceKeyB64: instanceKeyB64, app: app, defaultView: defaultView).bridge |
| - } |
| - |
| - // Pre-warm feeds for the given threads (the top of the sidebar): create their |
| - // webviews off-screen and start the page load, so the first tap attaches a |
| - // feed that's already rendered. Existing entries are left as they are. |
| - func warm(instance: SavedInstance, threads: [ChatThread], myUserId: String, instanceKeyB64: String?) { |
| - for t in threads where !t.threadKeyB64.isEmpty { |
| - _ = entry(for: instance, thread: t, myUserId: myUserId, instanceKeyB64: instanceKeyB64) |
| - } |
| + instanceKeyB64: instanceKeyB64, app: app).bridge |
| } |
| private func entry(for instance: SavedInstance, thread: ChatThread, myUserId: String, |
| instanceKeyB64: String?, app: String? = nil, |
| - defaultView: String = "chat", space: Bool = false) -> Entry { |
| + space: Bool = false) -> Entry { |
| // One shared pane per group for ALL its apps (switching apps is a JS |
| // call into the same page — WebBridge.setGroupApp — not a reload), so |
| // the key marks the surface, not the app — EXCEPT space surfaces: |
| @@ -113,17 +99,15 @@ final class FeedWebCache { |
| if let e = entries[key], e.seedKey == thread.threadKeyB64 { return e } |
| if let e = entries.removeValue(forKey: key) { release(e) } |
| let e = makeEntry(instance: instance, thread: thread, myUserId: myUserId, |
| - instanceKeyB64: instanceKeyB64, app: app, defaultView: defaultView, |
| - space: space) |
| + instanceKeyB64: instanceKeyB64, app: app, space: space) |
| entries[key] = e |
| evictIfNeeded() |
| return e |
| } |
| - // The webview construction that used to live in ChatFeedWebView.makeUIView. |
| private func makeEntry(instance: SavedInstance, thread: ChatThread, myUserId: String, |
| instanceKeyB64: String?, app: String? = nil, |
| - defaultView: String = "chat", space: Bool = false) -> Entry { |
| + space: Bool = false) -> Entry { |
| let bridge = WebBridge() |
| bridge.origin = instance.origin // the app-catalogue pull's registry key |
| let cfg = WKWebViewConfiguration() |
| @@ -131,22 +115,23 @@ final class FeedWebCache { |
| cfg.allowsInlineMediaPlayback = true |
| if let script = Self.bootScript(instance: instance, thread: thread, myUserId: myUserId, |
| instanceKeyB64: instanceKeyB64, app: app, |
| - defaultView: defaultView, space: space) { |
| + space: space) { |
| cfg.userContentController.addUserScript(script) |
| } |
| cfg.userContentController.add(bridge, name: "lchat") |
| let web = WKWebView(frame: .zero, configuration: cfg) |
| bridge.webView = web |
| web.navigationDelegate = bridge |
| - web.uiDelegate = bridge // opens target="_blank" message links in the system browser |
| - // The shell owns the insets (they ride in as CSS vars) — don't let WebKit |
| - // add its own on top when the webview runs full-bleed. |
| + web.uiDelegate = bridge // opens target="_blank" links in the system browser |
| + // The pane sits inside the safe area already — don't let WebKit add its |
| + // own insets on top. |
| web.scrollView.contentInsetAdjustmentBehavior = .never |
| - // A downward drag that meets the keyboard should carry it away with the |
| - // finger (the Messages feel). The feed scrolls in a nested WebKit overflow |
| - // scroll view — the page body is pinned at 100% so this outer scrollView |
| - // never scrolls — so the bridge re-applies .interactive to that inner |
| - // scroller on load / attach / keyboard-show (see WebBridge below). |
| + // A downward drag that meets the keyboard (an app interface's own |
| + // inputs) should carry it away with the finger. The page scrolls in a |
| + // nested WebKit overflow scroll view — the page body is pinned at 100% |
| + // so this outer scrollView never scrolls — so the bridge re-applies |
| + // .interactive to that inner scroller on load / keyboard-show |
| + // (see WebBridge). |
| web.scrollView.keyboardDismissMode = .interactive |
| bridge.enableInteractiveKeyboardDismiss() |
| #if DEBUG |
| @@ -156,24 +141,21 @@ final class FeedWebCache { |
| return Entry(web: web, bridge: bridge, seedKey: thread.threadKeyB64) |
| } |
| - // Runs before any page script: hand chat.js the embed seed for this thread. Only |
| - // the thread key crosses over — never the user private key. |
| - // (AppViewMemory lives below so the seed and ChatView's toggle share one truth.) |
| + // Runs before any page script: hand the page the embed seed for this thread. |
| + // Only the thread key crosses over — never the user private key. |
| private static func bootScript(instance: SavedInstance, thread: ChatThread, myUserId: String, |
| instanceKeyB64: String?, app: String? = nil, |
| - defaultView: String = "chat", space: Bool = false) -> WKUserScript? { |
| + space: Bool = false) -> WKUserScript? { |
| var seed: [String: Any] = [ |
| "token": instance.deviceToken, |
| "name": instance.screenName, |
| "myUserId": myUserId, |
| "threadId": thread.id, |
| "threadKey": thread.threadKeyB64, |
| - // App-threads open on the remembered view, falling back to the |
| - // app's own default (registry presentation — a takeover app is |
| - // its interface). The native header's toggle (ChatView) drives |
| - // any switch and shares this memory, so the button state and the |
| - // page always start in agreement, parked OR freshly booted. |
| - "view": AppViewMemory.views[thread.id] ?? defaultView, |
| + // Every page here is an app's own face — the chat view is native, |
| + // so the seed pins the interface (AppInterfacePane re-asserts it |
| + // on attach for parked pages). |
| + "view": "interface", |
| ] |
| if let app { |
| // A group's shared app pane: this page is purely that app's |
| @@ -223,17 +205,10 @@ final class FeedWebCache { |
| } |
| } |
| -// The per-thread companion-app view choice ("chat" | "interface") for this |
| -// session — ONE truth shared by the boot seed above (a fresh page opens on it) |
| -// and ChatView's header toggle (its button state + live switches). Session |
| -// memory only, like the web's appThreadView; deliberately not persisted. |
| -enum AppViewMemory { |
| - static var views: [String: String] = [:] |
| -} |
| - |
| // A GROUP chat's chosen view for this session: nil/absent = the conversation, |
| // an app id = that added app's interface. The native mirror of the web's |
| -// groupAppView; session memory only, like AppViewMemory. |
| +// groupAppView; session memory only, like the web's appThreadView, and |
| +// deliberately not persisted. |
| enum GroupAppMemory { |
| static var selection: [String: String] = [:] // thread id -> app id |
| } |
diff --git a/ios/LChat/PushManager.swift b/ios/LChat/PushManager.swift| index 3f43a39..fa9410a 100644 |
| --- a/ios/LChat/PushManager.swift |
| +++ b/ios/LChat/PushManager.swift |
| @@ -140,8 +140,8 @@ final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUs |
| // MARK: Presentation + taps |
| - // Which chat is on-screen right now (set by the pushed ChatView) — a banner |
| - // for the conversation you're reading is just noise. |
| + // Which chat is on-screen right now (set by the pushed ThreadDestination) — |
| + // a banner for the conversation you're reading is just noise. |
| static var activeThreadID: String? |
| /// Sweep a read chat's banners out of Notification Center — iOS never does |
diff --git a/ios/LChat/Views/AppThreadScreen.swift b/ios/LChat/Views/AppThreadScreen.swift| new file mode 100644 |
| index 0000000..65ecbe6 |
| --- /dev/null |
| +++ b/ios/LChat/Views/AppThreadScreen.swift |
| @@ -0,0 +1,201 @@ |
| +import LChatCore |
| +import SwiftUI |
| +import WebKit |
| + |
| +// 🤖 An app thread without a native interface (Admin, webhook-monitor, member |
| +// apps): the CHAT view is the native feed — NativeChatScreen, the same surface |
| +// every chat uses — and only the app's own INTERFACE stays a webview, pinned |
| +// to view:"interface". Chat never renders in a webview (the product rule: |
| +// webviews are for custom app UI the native primitives can't express, nothing |
| +// else); this replaced ChatView, the old whole-thread webview fallback. |
| +// |
| +// The Chat ↔ Interface switch lives in the ⋮ menu as picker rows — never a |
| +// segmented control in the bar (it collided with the floating avatar header, |
| +// and Paul's call 2026-07-25: dropdown items, not tabs). In chat mode the |
| +// rows ride NativeChatScreen's own ⋮; in interface mode this screen's. |
| +// |
| +// The registry's presentation decides the shape, mirroring the Mac's ladder |
| +// (NativeChatView.companionSplit / appInterfacePane): |
| +// companion → Chat + Interface, switched from the ⋮ (iOS has no |
| +// split, so defaultView "split" opens chat-first, |
| +// exactly as ChatView read it) |
| +// companion, chatOnly → just the native chat, no switch |
| +// takeover → just the interface webview, no chat at all |
| +// schema (fallback) → SchemaAppScreen couldn't build its store; same |
| +// companion shape, with the webview as the interface |
| +struct AppThreadScreen: View { |
| + let instance: SavedInstance |
| + let thread: ChatThread |
| + let store: ThreadStore |
| + |
| + @State private var mode: String // "chat" | "interface" |
| + private let hasChat: Bool |
| + private let hasInterface: Bool |
| + // The app posts into its chat, the member doesn't (Admin's update stream — |
| + // the manifest's chatReadOnly): the native feed drops its composer. |
| + private let chatReadOnly: Bool |
| + // Passed through to the chat view: its profile cards hop threads. |
| + @Binding private var path: [Route] |
| + @Environment(\.dismiss) private var dismiss |
| + |
| + @MainActor |
| + init(instance: SavedInstance, thread: ChatThread, store: ThreadStore, |
| + path: Binding<[Route]>) { |
| + self.instance = instance |
| + self.thread = thread |
| + self.store = store |
| + self._path = path |
| + switch store.registry.presentation(thread.appId ?? "") { |
| + case .companion(let defaultView, let chatOnly, let readOnly): |
| + hasChat = true |
| + hasInterface = !chatOnly |
| + chatReadOnly = readOnly |
| + _mode = State(initialValue: |
| + (defaultView == "interface" && !chatOnly) ? "interface" : "chat") |
| + case .schema(let companion): |
| + hasChat = companion |
| + hasInterface = true |
| + chatReadOnly = false |
| + _mode = State(initialValue: "interface") |
| + default: // .takeover; .flow routes to ElevenChatScreen before here |
| + hasChat = false |
| + hasInterface = true |
| + chatReadOnly = false |
| + _mode = State(initialValue: "interface") |
| + } |
| + } |
| + |
| + var body: some View { |
| + if mode == "chat", hasChat { |
| + NativeChatScreen(instance: instance, thread: thread, store: store, |
| + path: $path, readOnlyChat: chatReadOnly, |
| + appView: hasInterface ? $mode : nil) |
| + } else { |
| + AppInterfacePane(instance: instance, thread: thread, |
| + myUserId: store.myUserId, |
| + instanceKeyB64: store.instanceKey()) |
| + .navigationTitle(thread.title) |
| + .navigationBarTitleDisplayMode(.inline) |
| + .toolbar { |
| + ToolbarItem(placement: .primaryAction) { |
| + AppInterfaceKebab(store: store, thread: thread, |
| + appView: hasChat ? $mode : nil, |
| + onClose: { dismiss() }) |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +// The Chat ↔ Interface switch as dropdown picker rows: Chat plus the app |
| +// itself (its own title + symbol), checkmarked by selection. Rides inside a |
| +// ⋮ Menu — NativeChatScreen's in chat mode, AppInterfaceKebab's in interface |
| +// mode — so the nav bar stays clear for the floating header. |
| +struct AppViewPicker: View { |
| + let store: ThreadStore |
| + let thread: ChatThread |
| + @Binding var view: String // "chat" | "interface" |
| + |
| + var body: some View { |
| + Picker("View", selection: $view) { |
| + Label("Chat", systemImage: "bubble.left").tag("chat") |
| + // Labels with SF-symbol icons only — iOS menus silently drop |
| + // non-image icons (the emoji glyphs never rendered). |
| + Label(store.registry.title(thread.appId ?? ""), |
| + systemImage: AppIdentity.symbol(for: thread.appId ?? "") ?? "square.grid.2x2") |
| + .tag("interface") |
| + } |
| + .pickerStyle(.inline) |
| + } |
| +} |
| + |
| +// The interface view's ⋮: the view switch (when the app has a Chat view at |
| +// all) plus the chat-management basics the chat chrome would otherwise carry. |
| +struct AppInterfaceKebab: View { |
| + let store: ThreadStore |
| + let thread: ChatThread |
| + /// nil = no Chat view to switch to (takeover apps). |
| + let appView: Binding<String>? |
| + let onClose: () -> Void |
| + |
| + var body: some View { |
| + Menu { |
| + if let appView { |
| + AppViewPicker(store: store, thread: thread, view: appView) |
| + } |
| + Button { |
| + store.setPinned(thread, !isPinned) |
| + } label: { |
| + Label(isPinned ? "Unpin chat" : "Pin chat", |
| + systemImage: isPinned ? "pin.slash" : "pin") |
| + } |
| + Button(role: .destructive) { |
| + store.setHidden(thread, true) |
| + onClose() |
| + } label: { |
| + Label("Close chat", systemImage: "xmark.circle") |
| + } |
| + } label: { |
| + Image(systemName: "ellipsis.circle") |
| + } |
| + .accessibilityLabel("Chat options") |
| + } |
| + |
| + // Live pinned state: the store row changes as the menu toggles it. |
| + private var isPinned: Bool { |
| + store.threads.first { $0.id == thread.id }?.pinned ?? thread.pinned |
| + } |
| +} |
| + |
| +// The app's own face: the embed page (FeedWebCache) seeded — and held — on |
| +// view:"interface". The page can carry the whole web client, but this surface |
| +// only ever shows the app pane; the re-asserts pin a parked or freshly booted |
| +// page there so it can never sit on a chat view (GroupAppPane's pattern). |
| +struct AppInterfacePane: View { |
| + @ObservedObject private var bridge: WebBridge |
| + let instance: SavedInstance |
| + let thread: ChatThread |
| + let myUserId: String |
| + let instanceKeyB64: String? |
| + |
| + @MainActor |
| + init(instance: SavedInstance, thread: ChatThread, myUserId: String, |
| + instanceKeyB64: String?) { |
| + self.instance = instance |
| + self.thread = thread |
| + self.myUserId = myUserId |
| + self.instanceKeyB64 = instanceKeyB64 |
| + bridge = FeedWebCache.shared.bridge(for: instance, thread: thread, |
| + myUserId: myUserId, |
| + instanceKeyB64: instanceKeyB64) |
| + } |
| + |
| + var body: some View { |
| + ZStack { |
| + AppInterfaceWebView(instance: instance, thread: thread, |
| + myUserId: myUserId, instanceKeyB64: instanceKeyB64) |
| + if !bridge.pageReady { |
| + WebLoadingVeil().transition(.opacity) |
| + } |
| + } |
| + .animation(.easeOut(duration: 0.25), value: bridge.pageReady) |
| + .onAppear { bridge.setAppView("interface") } |
| + .onChange(of: bridge.pageReady) { _, ready in |
| + if ready { bridge.setAppView("interface") } |
| + } |
| + } |
| +} |
| + |
| +private struct AppInterfaceWebView: UIViewRepresentable { |
| + let instance: SavedInstance |
| + let thread: ChatThread |
| + let myUserId: String |
| + let instanceKeyB64: String? |
| + |
| + func makeUIView(context: Context) -> WKWebView { |
| + FeedWebCache.shared.webView(for: instance, thread: thread, myUserId: myUserId, |
| + instanceKeyB64: instanceKeyB64) |
| + } |
| + |
| + func updateUIView(_ web: WKWebView, context: Context) {} |
| +} |
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift| deleted file mode 100644 |
| index 0f91e07..0000000 |
| --- a/ios/LChat/Views/ChatView.swift |
| +++ /dev/null |
| @@ -1,1019 +0,0 @@ |
| -import LChatCore |
| -import SwiftUI |
| -import WebKit |
| -import PhotosUI |
| -import UniformTypeIdentifiers |
| -import UIKit |
| - |
| -// One thread's feed. Hybrid: the message *feed* is the web client (chat.js) running |
| -// in a WKWebView in embed mode — so rendering, reactions, edits, replies, typing and |
| -// attachments ship for free — while the native shell provides the title and composer. |
| -// |
| -// E2E is preserved and tightened: native unwraps this thread's AES key (Keychain |
| -// private key, never leaves the device) and seeds ONLY that key into the page via |
| -// window.__lchatEmbed. chat.js does all message crypto with it. The webview never |
| -// sees the user private key or any other thread's key, so a single thread's page |
| -// can't reach another thread's content. |
| -// |
| -// The native composer forwards typed text to chat.js's window.lchatNative.sendText; |
| -// the + button stages a photo via window.lchatNative.stageAttachment. Both run the |
| -// one crypto/upload path (the web layer). |
| -struct ChatView: View { |
| - // Owned by FeedWebCache alongside the thread's webview, so both survive |
| - // leaving the chat and re-opening attaches the same live page. |
| - @ObservedObject private var bridge: WebBridge |
| - // The sidebar store, so the header menu can pin/close this chat and read the |
| - // live pinned state (it changes as the menu toggles it). |
| - @ObservedObject private var store: ThreadStore |
| - @Environment(\.dismiss) private var dismiss |
| - |
| - @State private var text = "" |
| - @State private var caret = 0 // UTF-16, kept in sync by ComposerField |
| - @State private var composerFocused = false |
| - @State private var showPhotos = false |
| - @State private var pickerItem: PhotosPickerItem? |
| - @State private var attachError: String? |
| - @State private var fullFrame: CGRect = .zero // the full-bleed webview, global coords |
| - @State private var safeFrame: CGRect = .zero // the safe region between the bars |
| - @State private var avatarPop = false // header avatar springs to full size once the pane lands |
| - // The DM partner's profile card (rename them for yourself there — #577). |
| - @State private var profileUid: ProfileTarget? |
| - struct ProfileTarget: Identifiable { let id: String } |
| - // The native typeahead (token under the caret + ranked candidates) — |
| - // computed locally, no webview round-trip. |
| - @State private var composerController = ComposerFieldController() |
| - @AppStorage("formatAsYouType") private var formatAsYouType = true |
| - @State private var nativeTypeahead: (token: ComposerToken, items: [TypeaheadItem])? |
| - @Environment(\.scenePhase) private var scenePhase |
| - |
| - // Which view a companion app-thread shows: the Chat conversation or the |
| - // app's Interface, toggled from the header button. Remembered per thread |
| - // for the session in AppViewMemory (shared with the webview boot seed) |
| - // because a parked page keeps ITS state across reopens and a fresh page |
| - // boots from the seed — the button and the page must always agree, and |
| - // onAppear re-asserts to be sure. |
| - @State private var appView: String |
| - |
| - let instance: SavedInstance |
| - let thread: ChatThread |
| - var myUserId: String = "" |
| - let instanceKeyB64: String? |
| - |
| - @MainActor |
| - init(instance: SavedInstance, thread: ChatThread, store: ThreadStore, |
| - myUserId: String = "", instanceKeyB64: String?) { |
| - self.instance = instance |
| - self.thread = thread |
| - self.store = store |
| - self.myUserId = myUserId |
| - self.instanceKeyB64 = instanceKeyB64 |
| - // First open honours the app's own declaration (the registry's |
| - // presentation — the web's appDefaultView): a TAKEOVER app (Admin) is |
| - // its interface, so seeding view:"chat" showed an empty chat instead |
| - // of the app; a companion opens per its defaultView (iOS has no |
| - // split, so "split" reads as chat-first). The member's own last |
| - // choice, when they've made one, still wins. |
| - let initial: String |
| - switch AppRegistry.shared(origin: instance.origin).presentation(thread.appId ?? "") { |
| - case .takeover: |
| - initial = "interface" |
| - case .companion(let defaultView, let chatOnly, _): |
| - initial = (defaultView == "interface" && !chatOnly) ? "interface" : "chat" |
| - default: |
| - initial = "chat" |
| - } |
| - bridge = FeedWebCache.shared.bridge(for: instance, thread: thread, |
| - myUserId: myUserId, instanceKeyB64: instanceKeyB64, |
| - defaultView: initial) |
| - _appView = State(initialValue: AppViewMemory.views[thread.id] ?? initial) |
| - } |
| - |
| - private var isMac: Bool { ProcessInfo.processInfo.isMacCatalystApp } |
| - |
| - // Companion app-threads (a Chat view AND an Interface view — To-do, |
| - // Reminders) get the header toggle; takeover apps are their interface |
| - // already, chat-only companions have no Interface to toggle to, and |
| - // plain chats have nothing to toggle. The registry's presentation |
| - // (appWantsChatView + chatOnly, pre-answered) is the fact. |
| - private var isCompanionApp: Bool { |
| - guard thread.isApp, |
| - case .companion(_, let chatOnly, _) = |
| - store.registry.presentation(thread.appId ?? "") else { return false } |
| - return !chatOnly |
| - } |
| - |
| - private func setAppView(_ view: String) { |
| - appView = view |
| - AppViewMemory.views[thread.id] = view |
| - bridge.setAppView(view) |
| - } |
| - |
| - // Live pinned state: the store row changes as the menu toggles it, so read from |
| - // there (falling back to the value captured when this view was built). |
| - private var isPinned: Bool { |
| - store.threads.first { $0.id == thread.id }?.pinned ?? thread.pinned |
| - } |
| - |
| - private var canSend: Bool { |
| - !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || bridge.attachmentStaged |
| - } |
| - |
| - // The Messages-style header: a big avatar sitting level with the bar buttons, |
| - // the name in a Liquid-glass pill tucked just under it (the avatar overlaps the |
| - // pill's top edge slightly). The offset lifts the whole stack up into the (now |
| - // transparent) bar so the avatar's top lines up with the back button / ⋯. The |
| - // pill's material + explicit colour keep the name legible over any message. |
| - // The DM partner's uid — the header opens their profile card (rename them |
| - // for yourself there, #577), like tapping the web header title. |
| - private var dmPartner: String? { |
| - guard thread.kind == "dm" else { return nil } |
| - return thread.members.first { $0 != store.myUserId } |
| - } |
| - |
| - private var chatHeader: some View { |
| - VStack(spacing: -5) { |
| - Avatar(name: thread.title, size: 54, source: thread.avatar) |
| - .scaleEffect(avatarPop ? 1 : 0.7) |
| - .zIndex(1) // sit on top where it overlaps the pill |
| - Text(thread.title) |
| - .font(.headline) |
| - .lineLimit(1) |
| - .foregroundStyle(.primary) |
| - .padding(.horizontal, 12) |
| - .padding(.vertical, 4) |
| - .floatingGlass(in: Capsule()) |
| - } |
| - .frame(maxWidth: .infinity) |
| - .padding(.bottom, 6) |
| - .offset(y: -52) // rise up level with the top of the back button / ⋯ menu |
| - .accessibilityElement(children: .combine) |
| - .contentShape(Rectangle()) |
| - .onTapGesture { if let uid = dmPartner { profileUid = ProfileTarget(id: uid) } } |
| - .accessibilityAddTraits(dmPartner != nil ? .isButton : []) |
| - .accessibilityHint(dmPartner != nil ? "Shows their profile" : "") |
| - } |
| - |
| - var body: some View { |
| - ZStack { |
| - if instance.baseURL != nil { |
| - // Full-bleed: the feed scrolls under the (glass) nav bar and the |
| - // floating composer. The page gets the real insets as CSS vars via |
| - // the geometry probe below, so messages clear the bars + keyboard. |
| - ChatFeedWebView(instance: instance, thread: thread, myUserId: myUserId, |
| - instanceKeyB64: instanceKeyB64) |
| - .ignoresSafeArea() |
| - // The webview is the full-bleed reference surface: its own |
| - // global frame is what the insets are measured against (never |
| - // UIScreen.main, which disagrees under Display Zoom). |
| - .overlay { |
| - GeometryReader { geo in |
| - Color.clear.onChange(of: geo.frame(in: .global), initial: true) { _, f in |
| - fullFrame = f |
| - pushInsets() |
| - } |
| - } |
| - // Children of a full-bleed view are STILL safe-area |
| - // constrained — without this the "full" frame equals the |
| - // safe frame and every inset computes to zero. |
| - .ignoresSafeArea() |
| - .allowsHitTesting(false) |
| - } |
| - // The probe spans the safe region (between the glass bars, above the |
| - // keyboard); the bars' heights are the gaps between its frame and |
| - // the webview's. A full-bleed GeometryReader can't self-report this — |
| - // once it ignores the safe area, it reports zero insets. |
| - GeometryReader { geo in |
| - let frame = geo.frame(in: .global) |
| - Color.clear |
| - .onChange(of: frame, initial: true) { _, f in |
| - safeFrame = f |
| - pushInsets() |
| - } |
| - } |
| - .allowsHitTesting(false) |
| - // A booting page is a blank pane — veil it until it has |
| - // painted ({ready:true} from shell.js, or the nav fallback). |
| - if !bridge.pageReady { |
| - WebLoadingVeil() |
| - .ignoresSafeArea() |
| - .transition(.opacity) |
| - } |
| - } else { |
| - ContentUnavailableView("Can't open this chat", systemImage: "exclamationmark.triangle", |
| - description: Text("This chat's address is invalid.")) |
| - } |
| - } |
| - .animation(.easeOut(duration: 0.25), value: bridge.pageReady) |
| - .safeAreaInset(edge: .bottom, spacing: 0) { |
| - // In the Interface view the app surface owns the whole pane — |
| - // including its own inputs (To-do's add row, Reminders' form) — |
| - // so the native chat composer steps out of the way. |
| - if instance.baseURL != nil, appView == "chat" { composer } |
| - } |
| - .background(EdgeOnlyBackGesture()) |
| - .navigationTitle("") |
| - .navigationBarTitleDisplayMode(.inline) |
| - // Transparent nav bar so the big avatar can rise up LEVEL with the back |
| - // button and ⋯ menu (iMessage-style) and float over the feed. The bar itself |
| - // stays — keeping the system back button, its edge-swipe (EdgeOnlyBackGesture) |
| - // and the ⋯ menu — only its background is hidden. |
| - .toolbarBackground(.hidden, for: .navigationBar) |
| - // The header is CONTENT (a top safe-area inset), not a nav-bar title view, so |
| - // it slides cleanly with the pane — no clipping against the bar on push OR |
| - // pop, and no title-colour flash. Its own offset lifts it into the bar; the |
| - // feed's top inset (pushInsets) still grows to clear its laid-out height. |
| - .safeAreaInset(edge: .top, spacing: 0) { |
| - VStack(spacing: 0) { |
| - if !isMac { chatHeader } |
| - // A DM partner renamed themself past my name for them (#577): |
| - // the keep/adopt choice, inline under the header. |
| - RenameBanner(store: store, thread: thread) |
| - } |
| - } |
| - .sheet(item: $profileUid) { target in |
| - MemberProfileSheet(store: store, uid: target.id) { e in |
| - Avatar(name: store.displayName(for: e), size: 72, |
| - source: store.avatarSource(for: e)) |
| - } |
| - .presentationDetents([.medium]) |
| - } |
| - .toolbar { |
| - // Top-right overflow: the companion-app view switch (named for |
| - // where it TAKES you — "App view" from chat, "Chat view" from the |
| - // app), then pin/unpin and close (both per-user, server-synced). |
| - ToolbarItem(placement: .primaryAction) { |
| - Menu { |
| - if isCompanionApp { |
| - Button { |
| - setAppView(appView == "chat" ? "interface" : "chat") |
| - } label: { |
| - Label(appView == "chat" ? "App view" : "Chat view", |
| - systemImage: appView == "chat" ? "square.grid.2x2" : "bubble.left") |
| - } |
| - } |
| - Button { |
| - store.setPinned(thread, !isPinned) |
| - } label: { |
| - Label(isPinned ? "Unpin chat" : "Pin chat", |
| - systemImage: isPinned ? "pin.slash" : "pin") |
| - } |
| - Button(role: .destructive) { |
| - store.setHidden(thread, true) |
| - dismiss() |
| - } label: { |
| - Label("Close chat", systemImage: "xmark.circle") |
| - } |
| - } label: { |
| - Image(systemName: "ellipsis.circle") |
| - } |
| - .accessibilityLabel("Chat options") |
| - } |
| - } |
| - .onAppear { |
| - bridge.didAttach(force: consumeNotificationScroll()) // a cached feed may need re-pinning to its newest message |
| - // Re-assert the remembered view on the (possibly parked) page so |
| - // the toggle button and the webview can never disagree. |
| - if isCompanionApp { bridge.setAppView(appView) } |
| - if isMac { composerFocused = true } |
| - // A little finesse once the pane has landed: the avatar springs up past |
| - // full size and settles back — a low damping fraction gives the overshoot. |
| - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { |
| - withAnimation(.spring(response: 0.42, dampingFraction: 0.45)) { avatarPop = true } |
| - } |
| - } |
| - // A notification tap can foreground the app with this chat already on top — |
| - // no onAppear fires, but the feed was parked all the same and sits shy of |
| - // the newest message until it's re-pinned. Whichever of these two fires |
| - // first consumes the force-scroll marker (a notification tap wants the |
| - // newest message even if the feed had been scrolled up into history). |
| - .onChange(of: scenePhase) { _, phase in |
| - if phase == .active { |
| - bridge.didAttach(force: consumeNotificationScroll()) |
| - // Also the moment this chat's banners become read: notifications |
| - // delivered while the phone was locked (willPresent never ran) |
| - // are on-screen now, so sweep them from Notification Center. |
| - PushManager.clearDelivered(thread: thread.id) |
| - } |
| - } |
| - .alert("Couldn’t attach that", |
| - isPresented: Binding(get: { attachError != nil }, set: { if !$0 { attachError = nil } })) { |
| - Button("OK", role: .cancel) {} |
| - } message: { |
| - Text(attachError ?? "") |
| - } |
| - .onChange(of: bridge.lastError) { _, msg in |
| - if let msg { attachError = msg; bridge.lastError = nil } |
| - } |
| - } |
| - |
| - private func pushInsets() { |
| - guard fullFrame != .zero, safeFrame != .zero else { return } |
| - bridge.setInsets(top: max(0, safeFrame.minY - fullFrame.minY), |
| - bottom: max(0, fullFrame.maxY - safeFrame.maxY)) |
| - } |
| - |
| - // True (once) if this chat was opened by a notification tap — the feed should |
| - // then jump to the newest message, overriding a parked "scrolled up" position. |
| - private func consumeNotificationScroll() -> Bool { |
| - guard PushManager.forceScrollThreadID == thread.id else { return false } |
| - PushManager.forceScrollThreadID = nil |
| - return true |
| - } |
| - |
| - // Toggling a picker's isPresented binding directly inside a Menu action races with |
| - // the menu's own dismissal (the picker can fail to appear on Mac Catalyst). Defer |
| - // the toggle until after the menu has gone. |
| - private func presentAfterMenu(_ action: @escaping () -> Void) { |
| - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: action) |
| - } |
| - |
| - // MARK: Composer (native input, web send) |
| - |
| - // Floats over the feed (installed via safeAreaInset): a glass plus-button and |
| - // input capsule on iOS 26, thin material before that — no opaque bar, the |
| - // messages show through underneath (the Signal/Messages look). A staged |
| - // reply (swipe-right in the feed) shows as a glass banner above the row. |
| - private var composer: some View { |
| - VStack(spacing: 8) { |
| - if bridge.editing != nil { |
| - editBanner() |
| - .transition(.move(edge: .bottom).combined(with: .opacity)) |
| - } |
| - if let replying = bridge.replying { |
| - replyBanner(replying) |
| - .transition(.move(edge: .bottom).combined(with: .opacity)) |
| - } |
| - if let ta = nativeTypeahead { |
| - typeaheadStrip(ta.items) |
| - .transition(.move(edge: .bottom).combined(with: .opacity)) |
| - } |
| - composerRow |
| - } |
| - .animation(.easeOut(duration: 0.18), value: bridge.replying) |
| - .animation(.easeOut(duration: 0.18), value: bridge.editing) |
| - .animation(.easeOut(duration: 0.12), value: nativeTypeahead?.items) |
| - // The cluster's blank padding consumes taps: a near-miss focuses the |
| - // input rather than falling through to the feed and closing the keyboard. |
| - .contentShape(Rectangle()) |
| - .onTapGesture { composerFocused = true } |
| - .onChange(of: bridge.replying) { _, r in |
| - if r != nil { composerFocused = true } // mirror the web: replying focuses the input |
| - } |
| - // Edit mode is driven by the web layer (message menu → Edit). Prefill the |
| - // native composer with the original text so it's visible + editable; clear |
| - // it when edit ends (sent or cancelled, both arrive as editing == nil). |
| - .onChange(of: bridge.editing) { _, e in |
| - if let e { |
| - text = e.text |
| - composerFocused = true |
| - } else { |
| - text = "" |
| - } |
| - } |
| - .onChange(of: text) { _, _ in |
| - refreshTypeahead() |
| - bridge.noteTyping(hasText: !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) |
| - } |
| - .onChange(of: caret) { _, _ in refreshTypeahead() } |
| - } |
| - |
| - // Token detection + ranking run natively (ComposerTypeahead — the same |
| - // format.js mirror the Mac uses), caret-aware: the field reports UTF-16, |
| - // the tokenizer speaks Characters, so convert at the boundary. |
| - private func refreshTypeahead() { |
| - let utf16 = min(max(caret, 0), text.utf16.count) |
| - let idx = String.Index(utf16Offset: utf16, in: text) |
| - let charCaret = text.distance(from: text.startIndex, to: idx) |
| - guard let token = composerToken(text, caret: charCaret) else { |
| - nativeTypeahead = nil |
| - return |
| - } |
| - let members = Set(thread.members) |
| - let hasBot = store.roster.contains { members.contains($0.user_id) && $0.isAgent } |
| - // App-local verbs (/add in To-do) lead the strip, bot or no bot — the |
| - // web's commandCatalog(threadId). The webview runs whatever's picked. |
| - let items = typeaheadItems(token, roster: store.roster, |
| - myUserId: store.myUserId, hasBot: hasBot, |
| - appCommands: AppDefaultCommand.localCommands(appID: thread.appId), |
| - displayNames: store.displayLabelMap()) |
| - nativeTypeahead = items.isEmpty ? nil : (token, items) |
| - } |
| - |
| - // Horizontal strip of candidates, floating like the rest of the composer |
| - // cluster. Tap to splice the pick into the field. |
| - private func typeaheadStrip(_ items: [TypeaheadItem]) -> some View { |
| - ScrollView(.horizontal, showsIndicators: false) { |
| - HStack(spacing: 6) { |
| - ForEach(items) { item in |
| - Button { pick(item) } label: { |
| - Text(chipLabel(item)) |
| - .font(.subheadline) |
| - .lineLimit(1) |
| - .padding(.horizontal, 12) |
| - .padding(.vertical, 7) |
| - .floatingGlass(in: Capsule()) |
| - } |
| - .buttonStyle(.plain) |
| - } |
| - } |
| - .padding(.horizontal, 10) |
| - } |
| - } |
| - |
| - private func chipLabel(_ item: TypeaheadItem) -> String { |
| - switch item { |
| - case .command(let name, _): return name |
| - case .emoji(let glyph, let code): return "\(glyph) :\(code):" |
| - // The chip shows the name I use for them (#577); the pick still |
| - // inserts the published @name (item.insert). |
| - case .mention(_, _, let display): return "@" + display |
| - } |
| - } |
| - |
| - // Replace the token (trigger + typed query) with the pick — in Character |
| - // space, since the native token's offsets are Characters; the caret then |
| - // moves to the end of the insert (back in the field's UTF-16 terms). |
| - private func pick(_ item: TypeaheadItem) { |
| - guard let ta = nativeTypeahead else { return } |
| - // Through the controller: a binding rewrite would clobber styled runs |
| - // and park the caret at the end. |
| - let chars = Array(text) |
| - let utf16 = min(max(caret, 0), text.utf16.count) |
| - let charCaret = text.distance(from: text.startIndex, |
| - to: String.Index(utf16Offset: utf16, in: text)) |
| - let start = max(0, min(ta.token.start, chars.count)) |
| - let end = max(start, min(charCaret, chars.count)) |
| - let head = String(chars[0..<start]) |
| - let span = String(chars[start..<end]) |
| - composerController.insert(item.insert, replacingUTF16: NSRange( |
| - location: (head as NSString).length, length: (span as NSString).length)) |
| - nativeTypeahead = nil |
| - composerFocused = true |
| - } |
| - |
| - // Mirrors replyBanner: a glass "Editing message" strip above the composer while |
| - // an edit is in flight. The ✕ drops edit mode on both sides (bridge.cancelEdit). |
| - private func editBanner() -> some View { |
| - HStack(spacing: 10) { |
| - Image(systemName: "pencil") |
| - .font(.footnote) |
| - .foregroundStyle(Color.accentColor) |
| - Text("Editing message") |
| - .font(.footnote.weight(.semibold)) |
| - .lineLimit(1) |
| - Spacer(minLength: 0) |
| - Button { bridge.cancelEdit() } label: { |
| - Image(systemName: "xmark.circle.fill") |
| - .font(.system(size: 18)) |
| - .foregroundStyle(Color(.tertiaryLabel)) |
| - } |
| - .buttonStyle(.plain) |
| - .accessibilityLabel("Cancel edit") |
| - } |
| - .padding(.horizontal, 12) |
| - .padding(.vertical, 8) |
| - .floatingGlass(in: RoundedRectangle(cornerRadius: 16)) |
| - .padding(.horizontal, 10) |
| - .padding(.top, 8) |
| - } |
| - |
| - private func replyBanner(_ replying: WebBridge.Replying) -> some View { |
| - HStack(spacing: 10) { |
| - Image(systemName: "arrowshape.turn.up.left.fill") |
| - .font(.footnote) |
| - .foregroundStyle(Color.accentColor) |
| - VStack(alignment: .leading, spacing: 1) { |
| - Text("Replying to \(replying.name)") |
| - .font(.footnote.weight(.semibold)) |
| - .lineLimit(1) |
| - if !replying.snippet.isEmpty { |
| - Text(replying.snippet) |
| - .font(.footnote) |
| - .foregroundStyle(.secondary) |
| - .lineLimit(1) |
| - } |
| - } |
| - Spacer(minLength: 0) |
| - Button { bridge.cancelReply() } label: { |
| - Image(systemName: "xmark.circle.fill") |
| - .font(.system(size: 18)) |
| - .foregroundStyle(Color(.tertiaryLabel)) |
| - } |
| - .buttonStyle(.plain) |
| - .accessibilityLabel("Cancel reply") |
| - } |
| - .padding(.horizontal, 12) |
| - .padding(.vertical, 8) |
| - .floatingGlass(in: RoundedRectangle(cornerRadius: 16)) |
| - .padding(.horizontal, 10) |
| - .padding(.top, 8) |
| - } |
| - |
| - // The thread's default command (To-do: plain text adds a to-do). The web |
| - // pipeline inside the webview does the actual routing (sendText → the |
| - // composer submit path) — this only shapes the NATIVE chrome around it: |
| - // the placeholder advertises the routing and the attach button goes |
| - // (text-only surface), mirroring NativeChatScreen and the Mac. |
| - private var defaultCommand: AppDefaultCommand.Spec? { |
| - let members = Set(thread.members) |
| - let hasBot = store.roster.contains { members.contains($0.user_id) && $0.isAgent } |
| - return AppDefaultCommand.spec(appID: thread.appId, hasBot: hasBot) |
| - } |
| - |
| - private var composerRow: some View { |
| - HStack(alignment: .bottom, spacing: 8) { |
| - if defaultCommand == nil { |
| - Menu { |
| - Button { presentAfterMenu { showPhotos = true } } label: { Label("Photo Library", systemImage: "photo") } |
| - } label: { |
| - Image(systemName: "plus") |
| - .font(.system(size: 18, weight: .medium)) |
| - .frame(width: 36, height: 36) |
| - .floatingGlass(in: Circle()) |
| - } |
| - .buttonStyle(.plain) |
| - } |
| - |
| - HStack(alignment: .bottom, spacing: 6) { |
| - ComposerField( |
| - text: $text, |
| - focused: $composerFocused, |
| - caret: $caret, |
| - placeholder: defaultCommand?.placeholder ?? "Message", |
| - sendOnReturn: isMac, |
| - formatAsYouType: formatAsYouType, |
| - controller: composerController, |
| - onSend: { if canSend { send() } } |
| - ) |
| - .padding(.leading, 14) |
| - .padding(.vertical, 8) |
| - |
| - Button(action: send) { |
| - Image(systemName: "arrow.up.circle.fill") |
| - .font(.system(size: 28)) |
| - .foregroundStyle(canSend ? Color.accentColor : Color(.tertiaryLabel)) |
| - // 44pt minimum target — the visible icon is 28pt, but a |
| - // near-miss to its right must still send, not fall through |
| - // to the feed (which dismisses the keyboard). |
| - .frame(width: 44, height: 40) |
| - .contentShape(Rectangle()) |
| - } |
| - .buttonStyle(.plain) |
| - .disabled(!canSend) |
| - .accessibilityLabel("Send") |
| - } |
| - .floatingGlass(in: RoundedRectangle(cornerRadius: 22)) |
| - } |
| - .padding(.horizontal, 10) |
| - .padding(.vertical, 8) |
| - .photosPicker(isPresented: $showPhotos, selection: $pickerItem, matching: .images) |
| - .onChange(of: pickerItem) { _, item in stagePhoto(item) } |
| - } |
| - |
| - // MARK: Actions |
| - |
| - private func send() { |
| - // The wire carries serialized markdown (== the display text for |
| - // unstyled drafts); the bridge stays one plain string either way. |
| - // Opted out of format-as-you-type: typed markers escape, so they |
| - // render as literal characters — only the WYSIWYG formats. |
| - let wire = composerController.serializedMarkdown(escapeLiterals: !formatAsYouType) |
| - bridge.sendText(wire.isEmpty ? text : wire) { sent in |
| - // Only clear on success: if the socket wasn't ready, keep the text so |
| - // nothing is lost and the user can retry. |
| - if sent { text = "" } |
| - composerFocused = true |
| - } |
| - } |
| - |
| - private func stagePhoto(_ item: PhotosPickerItem?) { |
| - guard let item else { return } |
| - Task { |
| - pickerItem = nil |
| - do { |
| - guard let data = try await item.loadTransferable(type: Data.self) else { |
| - attachError = "Couldn’t read that photo." |
| - return |
| - } |
| - bridge.stageAttachment(data, name: "image.jpg", type: "image/jpeg") |
| - } catch { |
| - attachError = "Couldn’t read that photo: \(error.localizedDescription)" |
| - } |
| - } |
| - } |
| -} |
| - |
| -// Holds a reference to the live WKWebView so the native composer can drive the web |
| -// client's send() path, and receives staged-attachment state + errors back. |
| -final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNavigationDelegate, WKUIDelegate { |
| - weak var webView: WKWebView? |
| - // The instance origin, for the app-catalogue pull (set by FeedWebCache |
| - // when it builds the entry) — a ready page reports the space's live app |
| - // set into AppRegistry. |
| - var origin: String? |
| - |
| - // True once the page has real content to show. The page posts {ready:true} |
| - // after its first render (shell.js bootEmbed) — that's the honest "painted" |
| - // moment; didFinish is only a fallback (it fires when the HTML lands, well |
| - // before the app has drawn, and never fires again on a parked page). Hosts |
| - // overlay a loading veil until this flips so a booting page never shows as |
| - // a blank pane. |
| - @Published var pageReady = false |
| - |
| - func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { |
| - pageReady = false |
| - } |
| - |
| - func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { |
| - pageReady = true // show whatever the page has — a veil that never lifts is worse |
| - } |
| - |
| - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, |
| - withError error: Error) { |
| - pageReady = true |
| - } |
| - |
| - // The native shell's floating bars + keyboard, as CSS pixel insets. The page |
| - // pads the feed with these (--native-top/bottom-inset in style.css) so |
| - // messages scroll under the glass but rest clear of it. Kept and re-applied |
| - // on every page load (the vars live on <html>, which navigation resets). |
| - private var topInset: CGFloat = 0 |
| - private var bottomInset: CGFloat = 0 |
| - |
| - private var settle: DispatchWorkItem? |
| - |
| - func setInsets(top: CGFloat, bottom: CGFloat) { |
| - guard top != topInset || bottom != bottomInset else { return } |
| - // Re-pin the feed to the bottom only when the reserved space GROWS (keyboard |
| - // rise, composer growth) — then scrollTop must chase the now-taller content. |
| - // When it SHRINKS (the keyboard dismissing, esp. an interactive drag firing |
| - // per-frame inset changes) the browser already clamps scrollTop to the new |
| - // bottom on its own; re-pinning every frame instead races that shrink and |
| - // makes the feed flash up and down. So: grow → repin, shrink → let it be. |
| - let grew = bottom > bottomInset || top > topInset |
| - topInset = top |
| - bottomInset = bottom |
| - applyInsets(repin: grew) |
| - // The keyboard animates ~0.25s of per-frame inset changes; run one more |
| - // apply (always re-pinning) after it settles so a lost race mid-animation |
| - // can't strand the feed a few px shy of the bottom. |
| - settle?.cancel() |
| - let task = DispatchWorkItem { [weak self] in self?.applyInsets(repin: true) } |
| - settle = task |
| - DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: task) |
| - } |
| - |
| - private func applyInsets(repin: Bool = true) { |
| - // Keep the feed pinned to the bottom through padding changes (keyboard |
| - // rise, composer growth). Judge "was at bottom" BEFORE the padding |
| - // mutates — measured after, the gap equals the padding just added and |
| - // the check always fails, stranding the newest message under the bars. |
| - // `repin` is false while the reserved space is shrinking (a dismiss): the |
| - // browser clamps scrollTop for us, so a manual snap only causes jitter. |
| - let js = """ |
| - (function(){ |
| - // The live chat.js feed is .chatapp-feed (id-less); the original #feed |
| - // still sits empty in the skeleton, so target the live one first. |
| - var f = document.querySelector('.chatapp-feed') || document.getElementById('feed'); |
| - var atBottom = f && (f.scrollHeight - f.scrollTop - f.clientHeight < 200); |
| - var r = document.documentElement.style; |
| - r.setProperty('--native-top-inset','\(Int(topInset))px'); |
| - r.setProperty('--native-bottom-inset','\(Int(bottomInset))px'); |
| - if (\(repin ? "true" : "false") && atBottom) requestAnimationFrame(function(){ f.scrollTop = f.scrollHeight; }); |
| - })() |
| - """ |
| - webView?.evaluateJavaScript(js) |
| - } |
| - |
| - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { |
| - applyInsets() |
| - applyKeyboardDismiss() |
| - // Fallback for a page that never posts {ready:true} (an older server's |
| - // shell.js): give its boot a beat to paint, then lift the veil anyway. |
| - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in |
| - self?.pageReady = true |
| - } |
| - } |
| - |
| - // MARK: Interactive keyboard dismissal |
| - // |
| - // With the keyboard up, dragging the feed down until the finger meets the |
| - // keyboard should carry it away with the drag, not leave it stranded open. |
| - // That's UIScrollView's `.interactive` mode — but the feed scrolls inside a |
| - // nested overflow scroll view WebKit builds for the scrolling feed (the page |
| - // body is pinned at 100%, so the webview's own scrollView never scrolls). WebKit |
| - // creates that inner scroller lazily and can rebuild it, so re-apply the mode |
| - // whenever the page loads, the feed re-attaches, and — the moment that counts — |
| - // the keyboard is about to show. |
| - func enableInteractiveKeyboardDismiss() { |
| - NotificationCenter.default.addObserver( |
| - self, selector: #selector(applyKeyboardDismiss), |
| - name: UIResponder.keyboardWillShowNotification, object: nil) |
| - NotificationCenter.default.addObserver( |
| - self, selector: #selector(keyboardWillRise), |
| - name: UIResponder.keyboardWillShowNotification, object: nil) |
| - } |
| - |
| - @objc func applyKeyboardDismiss() { |
| - // Only the on-screen feed owns the visible keyboard; skip parked webviews. |
| - guard let webView, webView.window != nil else { return } |
| - setInteractiveDismiss(in: webView.scrollView) |
| - } |
| - |
| - private func setInteractiveDismiss(in view: UIView) { |
| - (view as? UIScrollView)?.keyboardDismissMode = .interactive |
| - for sub in view.subviews { setInteractiveDismiss(in: sub) } |
| - } |
| - |
| - // On keyboard rise the feed should re-pin to the bottom so the newest message |
| - // rests above the composer, not behind the keyboard. The per-frame inset re-pin |
| - // (applyInsets) can't manage this alone: the keyboard grows the reserved space by |
| - // ~300px in one animation, which outruns the re-pin and trips its "at bottom" |
| - // check, stranding the feed mid-rise. So: capture whether the feed was at the |
| - // bottom BEFORE the rise, and once the keyboard + inset animation has settled, |
| - // snap it to the now-taller bottom. Gated on "was at bottom" so a reader who |
| - // scrolled up into history isn't yanked down just for tapping the field. |
| - @objc func keyboardWillRise() { |
| - guard let webView, webView.window != nil else { return } |
| - let probe = "(function(){var f=document.querySelector('.chatapp-feed')||document.getElementById('feed');return f?(f.scrollHeight-f.scrollTop-f.clientHeight<120):false;})()" |
| - webView.evaluateJavaScript(probe) { [weak self] result, _ in |
| - guard (result as? Bool) == true else { return } |
| - // After the rise (~0.25s) + the inset settle (0.35s), the padding is fully |
| - // grown; read the fresh scrollHeight and land on the true bottom. |
| - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { |
| - self?.webView?.evaluateJavaScript( |
| - "(function(){var f=document.querySelector('.chatapp-feed')||document.getElementById('feed');if(f)requestAnimationFrame(function(){f.scrollTop=f.scrollHeight;});})()") |
| - } |
| - } |
| - } |
| - |
| - // Message links and link-preview cards render as target="_blank" anchors |
| - // (chat.js), which WebKit routes here as a new-window request, not a normal |
| - // navigation — with no WKUIDelegate the tap fell on the floor and nothing |
| - // happened. Hand the URL to the system browser instead of ever opening a child |
| - // webview (which would also blow away the embedded feed). Mirrors the Mac app. |
| - func webView(_ webView: WKWebView, |
| - createWebViewWith configuration: WKWebViewConfiguration, |
| - for navigationAction: WKNavigationAction, |
| - windowFeatures: WKWindowFeatures) -> WKWebView? { |
| - if let url = navigationAction.request.url { |
| - UIApplication.shared.open(url) |
| - } |
| - return nil |
| - } |
| - |
| - // Called when this thread's (possibly cached) webview lands on screen. A parked |
| - // page keeps receiving messages, but with rendering suspended its smooth-scroll |
| - // pinning can't run — so the saved offset sits shy of the newest message. The |
| - // page's didAttach owns the re-pin: it re-asserts across a settle window (a |
| - // one-shot scrollTop write from here got clamped by WebKit's resume-time |
| - // layout) and knows whether the user had deliberately scrolled up into |
| - // history (a live gap check here misreads a parked backlog as that). |
| - // |
| - // `force` (a notification tap) overrides that "scrolled up" hold: the message |
| - // you were pinged about is the newest one, so jump to it regardless. |
| - func didAttach(force: Bool = false) { |
| - applyKeyboardDismiss() // the inner scroller now exists + is on screen |
| - webView?.evaluateJavaScript( |
| - "window.lchatNative && window.lchatNative.didAttach && window.lchatNative.didAttach(\(force))") |
| - } |
| - |
| - // Switch a companion app-thread's webview between its Chat and Interface |
| - // views (shell.js setAppThreadView, via the embed bridge) — the native |
| - // header toggle drives this. `view` is "chat" or "interface" (ours, never |
| - // user text — safe to interpolate). |
| - func setAppView(_ view: String) { |
| - webView?.evaluateJavaScript( |
| - "window.lchatNative && window.lchatNative.setAppView && window.lchatNative.setAppView('\(view)')") |
| - } |
| - |
| - // Swap which of a group's added apps the shared pane shows (shell.js |
| - // setGroupApp) — no reload, the pane re-renders from folded state. `app` |
| - // is a catalog id (ours, never user text — safe to interpolate). The pane |
| - // must NEVER sit on the chat view: against a server whose shell.js |
| - // predates setGroupApp/embed.app, fall back to setAppView("interface") |
| - // (the old bridge routes a group with apps to its app pane). The page's |
| - // bridge installs a beat after didFinish, so retry until a call lands. |
| - func setGroupApp(_ app: String, attempt: Int = 0) { |
| - let js = """ |
| - (function(){ var n = window.lchatNative; |
| - if (!n) return false; |
| - if (n.setGroupApp) { n.setGroupApp('\(app)'); return true; } |
| - if (n.setAppView) { n.setAppView('interface'); return true; } |
| - return false; })() |
| - """ |
| - webView?.evaluateJavaScript(js) { [weak self] result, _ in |
| - guard (result as? Bool) != true, attempt < 12 else { return } |
| - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { |
| - self?.setGroupApp(app, attempt: attempt + 1) |
| - } |
| - } |
| - } |
| - |
| - // WKUIDelegate shows nothing for window.confirm()/alert() unless we implement |
| - // these — chat.js's "Delete this message" confirm (reachable here since embed mode |
| - // still shares renderApp()/openThread() with the full app) silently no-ops without |
| - // them. The Mac app had the identical gap; see ChatFeedWebView.swift there. |
| - func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, |
| - initiatedByFrame frame: WKFrameInfo) async -> Bool { |
| - guard let vc = Self.topViewController() else { return false } |
| - let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) |
| - return await withCheckedContinuation { continuation in |
| - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in continuation.resume(returning: false) }) |
| - alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in continuation.resume(returning: true) }) |
| - vc.present(alert, animated: true) |
| - } |
| - } |
| - |
| - func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, |
| - initiatedByFrame frame: WKFrameInfo) async { |
| - guard let vc = Self.topViewController() else { return } |
| - let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) |
| - await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in |
| - alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in continuation.resume() }) |
| - vc.present(alert, animated: true) |
| - } |
| - } |
| - |
| - private static func topViewController() -> UIViewController? { |
| - guard let scene = UIApplication.shared.connectedScenes |
| - .compactMap({ $0 as? UIWindowScene }) |
| - .first(where: { $0.activationState == .foregroundActive }), |
| - let root = scene.windows.first(where: { $0.isKeyWindow })?.rootViewController else { return nil } |
| - var top = root |
| - while let presented = top.presentedViewController { top = presented } |
| - return top |
| - } |
| - |
| - // Whether the web composer currently has a photo staged. Drives the native send |
| - // button so an image-only message can be sent. Kept in sync by the "lchat" handler. |
| - @Published var attachmentStaged = false |
| - |
| - // A user-facing problem reported by the web layer (upload/stage failure). ChatView |
| - // surfaces it as an alert, then clears it — so an attachment never just vanishes. |
| - @Published var lastError: String? |
| - |
| - // The web layer's staged reply (swipe-right / the message menu set it). The |
| - // native shell shows the banner — the web one is hidden in embed mode. |
| - struct Replying: Equatable { |
| - let name: String |
| - let snippet: String |
| - } |
| - @Published var replying: Replying? |
| - |
| - // The native banner's ✕: clear locally right away (no round-trip lag), then |
| - // tell chat.js to drop its staged reply so the next send doesn't carry it. |
| - func cancelReply() { |
| - replying = nil |
| - webView?.evaluateJavaScript( |
| - "window.lchatNative && window.lchatNative.cancelReply && window.lchatNative.cancelReply()") |
| - } |
| - |
| - // The edit the web layer entered (message menu → Edit). In embed mode the web |
| - // composer + edit banner are hidden, so the native shell prefills its composer |
| - // with the original text and shows its own "Editing" banner. A native send then |
| - // hits chat.js's editing path; on success/cancel chat.js posts {editing:null}. |
| - struct Editing: Equatable { let text: String } |
| - @Published var editing: Editing? |
| - |
| - // The native edit banner's ✕: clear locally, then tell chat.js to drop edit mode. |
| - func cancelEdit() { |
| - editing = nil |
| - webView?.evaluateJavaScript( |
| - "window.lchatNative && window.lchatNative.cancelEdit && window.lchatNative.cancelEdit()") |
| - } |
| - |
| - // Composer typeahead: token detection + ranking are NATIVE now |
| - // (LChatCore's ComposerTypeahead, the format.js mirror the Mac uses) — the |
| - // JS bridge round-trip is gone. ChatView computes candidates itself. |
| - |
| - // Mirrors the web composer's per-keystroke typing beat: while there's text we |
| - // keep this thread's "X is typing…" alive on other members' screens; an emptied |
| - // field (or a send) stops it immediately. chat.js paces the actual broadcasts. |
| - func noteTyping(hasText: Bool) { |
| - webView?.evaluateJavaScript( |
| - "window.lchatNative && window.lchatNative.typing && window.lchatNative.typing(\(hasText))") |
| - } |
| - |
| - // Forwards typed text to chat.js's window.lchatNative.sendText. JSON-encoding the |
| - // string safely escapes quotes/newlines/unicode before it crosses into JS. The |
| - // completion reports whether the web layer accepted it (socket open, hook present, |
| - // something to send), so the caller knows whether to clear its field. |
| - func sendText(_ text: String, completion: @escaping (Bool) -> Void) { |
| - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) |
| - guard let webView, |
| - let data = try? JSONSerialization.data(withJSONObject: [trimmed]), |
| - let arg = String(data: data, encoding: .utf8) else { return completion(false) } |
| - webView.evaluateJavaScript("window.lchatNative ? window.lchatNative.sendText(\(arg)[0]) : false") { result, _ in |
| - completion((result as? Bool) ?? false) |
| - } |
| - } |
| - |
| - // Hands photo bytes to chat.js's window.lchatNative.stageAttachment, which uploads |
| - // + encrypts + previews it. Fire-and-forget: staging runs async in JS and reports |
| - // back via the "lchat" handler ({staged:true} on success, {error} on failure). |
| - func stageAttachment(_ data: Data, name: String, type: String?) { |
| - guard let webView else { |
| - lastError = "Internal error: the chat view isn’t ready." |
| - return |
| - } |
| - let payload: [String: Any] = [ |
| - "b64": data.base64EncodedString(), |
| - "name": name, |
| - "type": type ?? "application/octet-stream", |
| - ] |
| - guard let json = try? JSONSerialization.data(withJSONObject: payload), |
| - let arg = String(data: json, encoding: .utf8) else { |
| - lastError = "Couldn’t prepare that attachment." |
| - return |
| - } |
| - let js = "(function(){ if (!window.lchatNative) return 'no-bridge'; window.lchatNative.stageAttachment(\(arg)); return 'ok'; })()" |
| - webView.evaluateJavaScript(js) { [weak self] result, err in |
| - if let err = err { |
| - self?.lastError = "Couldn’t attach that: \(err.localizedDescription)" |
| - } else if (result as? String) == "no-bridge" { |
| - self?.lastError = "The chat isn’t ready yet. Try again in a moment." |
| - } |
| - } |
| - } |
| - |
| - // chat.js posts {staged: Bool} whenever a photo is staged or cleared, and |
| - // {error: String} when an upload/stage fails. Both arrive on the one "lchat" handler. |
| - func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { |
| - guard message.name == "lchat", let body = message.body as? [String: Any] else { return } |
| - if body["ready"] as? Bool == true { |
| - pageReady = true |
| - // A ready page has loaded all three app tiers — pull the live |
| - // catalogue into the registry (one pull per launch; cheap no-op |
| - // after). {catalogChanged} below forces a refresh. |
| - if let web = webView, let origin { AppCatalogPull.pull(from: web, origin: origin) } |
| - } |
| - if body["catalogChanged"] != nil, let web = webView, let origin { |
| - // The space's app set changed (apps_changed): the page re-registered |
| - // member apps live and told us — re-pull so pickers/routing follow. |
| - AppCatalogPull.pull(from: web, origin: origin, force: true) |
| - } |
| - if let staged = body["staged"] as? Bool { attachmentStaged = staged } |
| - if let err = body["error"] as? String { lastError = err } |
| - if body.keys.contains("replying") { |
| - if let r = body["replying"] as? [String: Any] { |
| - replying = Replying(name: (r["name"] as? String) ?? "", |
| - snippet: (r["snippet"] as? String) ?? "") |
| - } else { |
| - replying = nil // {replying: null} — reply cancelled or sent |
| - } |
| - } |
| - if body.keys.contains("editing") { |
| - if let e = body["editing"] as? [String: Any] { |
| - editing = Editing(text: (e["text"] as? String) ?? "") |
| - } else { |
| - editing = nil // {editing: null} — edit cancelled or sent |
| - } |
| - } |
| - } |
| -} |
| - |
| -// WKWebView host for one thread's feed. The webview itself (and its embed seed — |
| -// see FeedWebCache.bootScript) lives in FeedWebCache: reused across opens, and |
| -// possibly already loaded by a prewarm, which is what makes opening a chat |
| -// instant instead of a page boot behind the loading spinner. |
| -struct ChatFeedWebView: UIViewRepresentable { |
| - let instance: SavedInstance |
| - let thread: ChatThread |
| - let myUserId: String |
| - let instanceKeyB64: String? |
| - |
| - func makeUIView(context: Context) -> WKWebView { |
| - FeedWebCache.shared.webView(for: instance, thread: thread, myUserId: myUserId, |
| - instanceKeyB64: instanceKeyB64) |
| - } |
| - |
| - func updateUIView(_ web: WKWebView, context: Context) {} |
| -} |
| - |
| -// Liquid Glass on iOS 26, thin material below — one modifier so every floating |
| -// element falls back the same way. |
| -extension View { |
| - @ViewBuilder func floatingGlass<S: InsettableShape>(in shape: S) -> some View { |
| - #if os(iOS) |
| - if #available(iOS 26.0, *) { |
| - self.glassEffect(.regular, in: shape) |
| - } else { |
| - scrollSafeGlass(in: shape) |
| - } |
| - #else |
| - scrollSafeGlass(in: shape) |
| - #endif |
| - } |
| - |
| - // For glass that floats OVER SCROLLING CONTENT: the liquid-glass lens |
| - // refracts and duplicates whatever passes beneath it — over the chat |
| - // feed that rendered as ghosted, stacked, compressed bubbles sliding |
| - // under the header (the 2026-07-20 hunt; the /bareedges bisect convicted |
| - // the glass, not the list). A material blurs its backdrop without |
| - // lensing, so surfaces on the scroll path use this on every OS. |
| - func scrollSafeGlass<S: InsettableShape>(in shape: S) -> some View { |
| - self.background(.ultraThinMaterial, in: shape) |
| - .overlay(shape.strokeBorder(Color(.separator).opacity(0.4), lineWidth: 0.5)) |
| - } |
| -} |
diff --git a/ios/LChat/Views/EdgeOnlyBackGesture.swift b/ios/LChat/Views/EdgeOnlyBackGesture.swift| deleted file mode 100644 |
| index 7cbba19..0000000 |
| --- a/ios/LChat/Views/EdgeOnlyBackGesture.swift |
| +++ /dev/null |
| @@ -1,41 +0,0 @@ |
| -import SwiftUI |
| -import UIKit |
| - |
| -// iOS 26 pops navigation on a rightward swipe from anywhere in the view, which |
| -// fights the feed's swipe-to-reply (a web gesture inside the WKWebView). Turn |
| -// the content-pop gesture off so going back is the classic left-screen-edge |
| -// swipe only. Installed from ChatView; the edge gesture stays untouched. |
| -struct EdgeOnlyBackGesture: UIViewRepresentable { |
| - func makeUIView(context: Context) -> UIView { Probe() } |
| - func updateUIView(_ uiView: UIView, context: Context) {} |
| - |
| - final class Probe: UIView { |
| - init() { |
| - super.init(frame: .zero) |
| - isUserInteractionEnabled = false |
| - } |
| - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } |
| - |
| - override func didMoveToWindow() { |
| - super.didMoveToWindow() |
| - guard window != nil, let nav = navigationController() else { return } |
| - #if os(iOS) |
| - if #available(iOS 26.0, *) { |
| - nav.interactiveContentPopGestureRecognizer?.isEnabled = false |
| - } |
| - #endif |
| - } |
| - |
| - private func navigationController() -> UINavigationController? { |
| - var responder: UIResponder? = self |
| - while let current = responder { |
| - if let vc = current as? UIViewController { |
| - if let nav = vc as? UINavigationController { return nav } |
| - if let nav = vc.navigationController { return nav } |
| - } |
| - responder = current.next |
| - } |
| - return nil |
| - } |
| - } |
| -} |
diff --git a/ios/LChat/Views/FloatingGlass.swift b/ios/LChat/Views/FloatingGlass.swift| new file mode 100644 |
| index 0000000..6a84be6 |
| --- /dev/null |
| +++ b/ios/LChat/Views/FloatingGlass.swift |
| @@ -0,0 +1,29 @@ |
| +import SwiftUI |
| + |
| +// Liquid Glass on iOS 26, thin material below — one modifier so every floating |
| +// element falls back the same way. (Split out of the retired ChatView.swift; |
| +// used by composers, headers, and chips across the app.) |
| +extension View { |
| + @ViewBuilder func floatingGlass<S: InsettableShape>(in shape: S) -> some View { |
| + #if os(iOS) |
| + if #available(iOS 26.0, *) { |
| + self.glassEffect(.regular, in: shape) |
| + } else { |
| + scrollSafeGlass(in: shape) |
| + } |
| + #else |
| + scrollSafeGlass(in: shape) |
| + #endif |
| + } |
| + |
| + // For glass that floats OVER SCROLLING CONTENT: the liquid-glass lens |
| + // refracts and duplicates whatever passes beneath it — over the chat |
| + // feed that rendered as ghosted, stacked, compressed bubbles sliding |
| + // under the header (the 2026-07-20 hunt; the /bareedges bisect convicted |
| + // the glass, not the list). A material blurs its backdrop without |
| + // lensing, so surfaces on the scroll path use this on every OS. |
| + func scrollSafeGlass<S: InsettableShape>(in shape: S) -> some View { |
| + self.background(.ultraThinMaterial, in: shape) |
| + .overlay(shape.strokeBorder(Color(.separator).opacity(0.4), lineWidth: 0.5)) |
| + } |
| +} |
diff --git a/ios/LChat/Views/GroupAppPane.swift b/ios/LChat/Views/GroupAppPane.swift| index 735ea2e..098f0b2 100644 |
| --- a/ios/LChat/Views/GroupAppPane.swift |
| +++ b/ios/LChat/Views/GroupAppPane.swift |
| @@ -3,8 +3,8 @@ import SwiftUI |
| import WebKit |
| // 🤖 A GROUP chat's shared app surface: the selected added app's interface, |
| -// rendered by the same embed page ChatView uses — seeded with this thread's |
| -// key plus the app to show (FeedWebCache, app != nil). ONE cached page serves |
| +// rendered by the shared embed page (FeedWebCache) — seeded with this thread's |
| +// key plus the app to show (app != nil). ONE cached page serves |
| // all the group's apps; switching apps is a JS call into it |
| // (WebBridge.setGroupApp), never a reload. NativeChatScreen swaps this in for |
| // the native feed when the header's view picker chooses an app. |
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift| index 81da335..c7b6249 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -7,8 +7,9 @@ import UniformTypeIdentifiers |
| // The iOS native feed (docs/native-chat.md, iOS adoption): the same shared |
| // pieces the Mac takeover renders — ChatFeedModel (parked via |
| // NativeFeedModelCache), MessageBubble and friends — in a phone-shaped |
| -// container. The default surface for plain chats (ThreadDestination); app |
| -// threads keep ChatView's webview feed. |
| +// container. THE chat surface, full stop: plain chats mount it directly |
| +// (ThreadDestination) and app-thread Chat views mount it through |
| +// SchemaAppScreen/AppThreadScreen — chat never renders in a webview. |
| // |
| // Phone-appropriate divergences from the Mac container: actions ride the |
| // long-press context menu MessageBubble already provides on iOS (no hover, no |
| @@ -27,6 +28,14 @@ struct NativeChatScreen: View { |
| // reads (roster for the typeahead, thread state for the ⋮ menu) and calls |
| // methods; the feed itself renders from its own ChatFeedModel. |
| let store: ThreadStore |
| + // The app posts into this chat, the member doesn't (the manifest's |
| + // chatReadOnly — Admin's update stream): no composer at all, the web's |
| + // .chatapp-root.readonly rule. |
| + let readOnlyChat: Bool |
| + // Non-nil = this is an app thread's Chat view with an Interface to switch |
| + // to: the ⋮ menu leads with the Chat ↔ Interface picker rows (the parent |
| + // AppThreadScreen/SchemaAppScreen owns the mode). |
| + let appView: Binding<String>? |
| @ObservedObject private var model: ChatFeedModel |
| // The navigation path this screen sits on — for surfaces that hop |
| // threads (a profile card's "Message them", like ElevenChatScreen's |
| @@ -34,11 +43,14 @@ struct NativeChatScreen: View { |
| @Binding private var path: [Route] |
| init(instance: SavedInstance, thread: ChatThread, store: ThreadStore, |
| - path: Binding<[Route]>) { |
| + path: Binding<[Route]>, |
| + readOnlyChat: Bool = false, appView: Binding<String>? = nil) { |
| self.instance = instance |
| self.thread = thread |
| self.store = store |
| self._path = path |
| + self.readOnlyChat = readOnlyChat |
| + self.appView = appView |
| self.model = NativeFeedModelCache.shared.model(for: instance, thread: thread, |
| myUserId: store.myUserId) |
| // Opened from a notification tap: capture the read watermark NOW — |
| @@ -249,7 +261,7 @@ struct NativeChatScreen: View { |
| return thread.members.first { $0 != store.myUserId } |
| } |
| - // The Messages-style header ChatView established: big avatar level with the |
| + // The Messages-style header: big avatar level with the |
| // bar buttons, the name in a glass pill tucked under it. |
| private var chatHeader: some View { |
| VStack(spacing: -5) { |
| @@ -400,8 +412,9 @@ struct NativeChatScreen: View { |
| } |
| .safeAreaInset(edge: .bottom) { |
| // An app surface owns the whole pane, inputs included — the |
| - // chat composer steps aside (ChatView's interface-view rule). |
| - if activeGroupAppID == nil { |
| + // chat composer steps aside; a read-only app chat never has |
| + // one. |
| + if activeGroupAppID == nil, !readOnlyChat { |
| // An ended DM (#578): the server refuses new messages, so |
| // the composer gives way to the closed banner — the one |
| // honest action left is Reopen. |
| @@ -424,9 +437,14 @@ struct NativeChatScreen: View { |
| .toolbar { |
| ToolbarItem(placement: .primaryAction) { |
| Menu { |
| - // The view picker — the same header toggle app chats |
| - // have (ChatView's App view/Chat view), grown to list |
| - // every app this group has added. |
| + // An app thread's Chat ↔ Interface switch — dropdown |
| + // picker rows, never bar tabs (the parent screen owns |
| + // the mode; picking Interface swaps this screen out). |
| + if let appView { |
| + AppViewPicker(store: store, thread: thread, view: appView) |
| + } |
| + // The group view picker — the same shape, grown to |
| + // list every app this group has added. |
| if !groupApps.isEmpty { |
| Picker("View", selection: groupViewSelection) { |
| Label("Chat", systemImage: "bubble.left").tag("") |
| @@ -1030,7 +1048,7 @@ struct NativeChatScreen: View { |
| .padding(.top, 4) |
| } |
| - // ChatView's proven floating-glass composer, verbatim: the field self-sizes |
| + // The floating-glass composer: the field self-sizes |
| // (GrowingTextView), and it shares one glass capsule with the send button. |
| private var composerRow: some View { |
| HStack(alignment: .bottom, spacing: 8) { |
| @@ -1386,7 +1404,7 @@ struct NativeChatScreen: View { |
| .padding(.horizontal, 10) |
| } |
| - // ---- typeahead (same native brain as ChatView / the Mac) --------------- |
| + // ---- typeahead (same native brain as the Mac) -------------------------- |
| private func refreshTypeahead() { |
| let utf16 = min(max(caret, 0), draft.utf16.count) |
diff --git a/ios/LChat/Views/SchemaAppScreen.swift b/ios/LChat/Views/SchemaAppScreen.swift| index 500b7e5..a1b921b 100644 |
| --- a/ios/LChat/Views/SchemaAppScreen.swift |
| +++ b/ios/LChat/Views/SchemaAppScreen.swift |
| @@ -4,23 +4,23 @@ import LChatCore |
| // 🤖 The native surface for a schema-described app thread (To-do, Reminders): |
| // the Interface view is SchemaAppView — native components assembled from the |
| // app's schema — with the companion Chat view (the same DM-with-the-app the |
| -// web offers) one segment away, rendered by the native feed. The webview is |
| -// out of the loop entirely for these apps; others keep ChatView. |
| +// web offers) switched from the ⋮ menu (AppViewPicker rows — dropdown items, |
| +// never a segmented control in the bar) and rendered by the native feed. The |
| +// webview is out of the loop entirely for these apps; apps without a schema |
| +// get the same shape from AppThreadScreen, with a webview only as the |
| +// Interface. |
| struct SchemaAppScreen: View { |
| let instance: SavedInstance |
| let thread: ChatThread |
| @ObservedObject var store: ThreadStore |
| let schemaStore: SchemaAppStore |
| - private enum AppView: String, CaseIterable { |
| - case chat = "Chat" |
| - case interface = "Interface" |
| - } |
| // The Interface IS the app (the web's takeover default); Chat is the |
| - // companion conversation, one tap away. |
| - @State private var mode: AppView = .interface |
| + // companion conversation, a menu pick away. |
| + @State private var mode = "interface" // "chat" | "interface" |
| // Passed through to the companion chat: its profile cards hop threads. |
| @Binding private var path: [Route] |
| + @Environment(\.dismiss) private var dismiss |
| init?(instance: SavedInstance, thread: ChatThread, store: ThreadStore, |
| path: Binding<[Route]>) { |
| @@ -34,26 +34,20 @@ struct SchemaAppScreen: View { |
| } |
| var body: some View { |
| - Group { |
| - switch mode { |
| - case .chat: |
| - NativeChatScreen(instance: instance, thread: thread, store: store, path: $path) |
| - case .interface: |
| - SchemaAppView(store: schemaStore) |
| - .navigationTitle(thread.title) |
| - .navigationBarTitleDisplayMode(.inline) |
| - } |
| - } |
| - .toolbar { |
| - ToolbarItem(placement: .principal) { |
| - Picker("View", selection: $mode) { |
| - ForEach(AppView.allCases, id: \.self) { m in |
| - Text(m.rawValue).tag(m) |
| + if mode == "chat" { |
| + NativeChatScreen(instance: instance, thread: thread, store: store, |
| + path: $path, appView: $mode) |
| + } else { |
| + SchemaAppView(store: schemaStore) |
| + .navigationTitle(thread.title) |
| + .navigationBarTitleDisplayMode(.inline) |
| + .toolbar { |
| + ToolbarItem(placement: .primaryAction) { |
| + AppInterfaceKebab(store: store, thread: thread, |
| + appView: $mode, |
| + onClose: { dismiss() }) |
| } |
| } |
| - .pickerStyle(.segmented) |
| - .frame(maxWidth: 220) |
| - } |
| } |
| } |
| } |
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift| index 06e2178..b50840c 100644 |
| --- a/ios/LChat/Views/ThreadListView.swift |
| +++ b/ios/LChat/Views/ThreadListView.swift |
| @@ -595,9 +595,10 @@ struct ThreadDestination: View { |
| @ViewBuilder private func chatSurface(_ t: ChatThread) -> some View { |
| // One registry decision (docs/native-apps.md Layer 3) instead of |
| // hard-coded app-id checks: the flow interpreter and the schema |
| - // engine are native enhancements; the webview is the universal |
| - // fallback, seeded with the app's own default view — a takeover app |
| - // (Admin) opens on its interface, never an empty chat. |
| + // engine are native enhancements, and everything else is |
| + // AppThreadScreen — native chat, with the app's own interface (the |
| + // only part a webview may carry) a toggle away. Chat never renders |
| + // in a webview. |
| if !t.isApp { |
| NativeChatScreen(instance: store.instance, thread: t, store: store, path: $path) |
| } else { |
| @@ -608,12 +609,10 @@ struct ThreadDestination: View { |
| if let screen = SchemaAppScreen(instance: store.instance, thread: t, store: store, path: $path) { |
| screen |
| } else { |
| - ChatView(instance: store.instance, thread: t, store: store, |
| - myUserId: store.myUserId, instanceKeyB64: store.instanceKey()) |
| + AppThreadScreen(instance: store.instance, thread: t, store: store, path: $path) |
| } |
| default: |
| - ChatView(instance: store.instance, thread: t, store: store, |
| - myUserId: store.myUserId, instanceKeyB64: store.instanceKey()) |
| + AppThreadScreen(instance: store.instance, thread: t, store: store, path: $path) |
| } |
| } |
| } |
diff --git a/ios/LChat/Views/WebBridge.swift b/ios/LChat/Views/WebBridge.swift| new file mode 100644 |
| index 0000000..11a69d0 |
| --- /dev/null |
| +++ b/ios/LChat/Views/WebBridge.swift |
| @@ -0,0 +1,185 @@ |
| +import LChatCore |
| +import SwiftUI |
| +import WebKit |
| +import UIKit |
| + |
| +// 🤖 The native side of an app-interface webview (owned by FeedWebCache |
| +// alongside its page): load/ready state, the view/app switching calls into the |
| +// embed bridge, and the WebKit delegate glue (external links, JS dialogs, |
| +// interactive keyboard dismissal). |
| +// |
| +// This file used to be ChatView.swift — the whole-thread webview fallback that |
| +// rendered an app thread's CHAT in the web client. That surface is gone (chat |
| +// is native everywhere — AppThreadScreen / NativeChatScreen); a webview now |
| +// only ever carries an app's own interface, and this bridge is what remains. |
| +// |
| +// E2E is preserved and tightened: native unwraps this thread's AES key |
| +// (Keychain private key, never leaves the device) and seeds ONLY that key into |
| +// the page via window.__lchatEmbed. The webview never sees the user private |
| +// key or any other thread's key, so a single thread's page can't reach another |
| +// thread's content. |
| +final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNavigationDelegate, WKUIDelegate { |
| + weak var webView: WKWebView? |
| + // The instance origin, for the app-catalogue pull (set by FeedWebCache |
| + // when it builds the entry) — a ready page reports the space's live app |
| + // set into AppRegistry. |
| + var origin: String? |
| + |
| + // True once the page has real content to show. The page posts {ready:true} |
| + // after its first render (shell.js bootEmbed) — that's the honest "painted" |
| + // moment; didFinish is only a fallback (it fires when the HTML lands, well |
| + // before the app has drawn, and never fires again on a parked page). Hosts |
| + // overlay a loading veil until this flips so a booting page never shows as |
| + // a blank pane. |
| + @Published var pageReady = false |
| + |
| + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { |
| + pageReady = false |
| + } |
| + |
| + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { |
| + pageReady = true // show whatever the page has — a veil that never lifts is worse |
| + } |
| + |
| + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, |
| + withError error: Error) { |
| + pageReady = true |
| + } |
| + |
| + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { |
| + applyKeyboardDismiss() |
| + // Fallback for a page that never posts {ready:true} (an older server's |
| + // shell.js): give its boot a beat to paint, then lift the veil anyway. |
| + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in |
| + self?.pageReady = true |
| + } |
| + } |
| + |
| + // MARK: Interactive keyboard dismissal |
| + // |
| + // With the keyboard up (an app interface's own inputs), dragging the page |
| + // down until the finger meets the keyboard should carry it away with the |
| + // drag, not leave it stranded open. That's UIScrollView's `.interactive` |
| + // mode — but the page scrolls inside a nested overflow scroll view WebKit |
| + // builds (the page body is pinned at 100%, so the webview's own scrollView |
| + // never scrolls). WebKit creates that inner scroller lazily and can rebuild |
| + // it, so re-apply the mode whenever the page loads and — the moment that |
| + // counts — the keyboard is about to show. |
| + func enableInteractiveKeyboardDismiss() { |
| + NotificationCenter.default.addObserver( |
| + self, selector: #selector(applyKeyboardDismiss), |
| + name: UIResponder.keyboardWillShowNotification, object: nil) |
| + } |
| + |
| + @objc func applyKeyboardDismiss() { |
| + // Only the on-screen page owns the visible keyboard; skip parked webviews. |
| + guard let webView, webView.window != nil else { return } |
| + setInteractiveDismiss(in: webView.scrollView) |
| + } |
| + |
| + private func setInteractiveDismiss(in view: UIView) { |
| + (view as? UIScrollView)?.keyboardDismissMode = .interactive |
| + for sub in view.subviews { setInteractiveDismiss(in: sub) } |
| + } |
| + |
| + // Links inside an app's interface render as target="_blank" anchors, which |
| + // WebKit routes here as a new-window request, not a normal navigation — |
| + // with no WKUIDelegate the tap fell on the floor and nothing happened. |
| + // Hand the URL to the system browser instead of ever opening a child |
| + // webview. Mirrors the Mac app. |
| + func webView(_ webView: WKWebView, |
| + createWebViewWith configuration: WKWebViewConfiguration, |
| + for navigationAction: WKNavigationAction, |
| + windowFeatures: WKWindowFeatures) -> WKWebView? { |
| + if let url = navigationAction.request.url { |
| + UIApplication.shared.open(url) |
| + } |
| + return nil |
| + } |
| + |
| + // Pin an app-thread's webview to its Interface view (shell.js |
| + // setAppThreadView, via the embed bridge) — AppInterfacePane asserts this |
| + // on attach and page-ready so the page can never sit on a chat view. |
| + // `view` is "chat" or "interface" (ours, never user text — safe to |
| + // interpolate). |
| + func setAppView(_ view: String) { |
| + webView?.evaluateJavaScript( |
| + "window.lchatNative && window.lchatNative.setAppView && window.lchatNative.setAppView('\(view)')") |
| + } |
| + |
| + // Swap which of a group's added apps the shared pane shows (shell.js |
| + // setGroupApp) — no reload, the pane re-renders from folded state. `app` |
| + // is a catalog id (ours, never user text — safe to interpolate). The pane |
| + // must NEVER sit on the chat view: against a server whose shell.js |
| + // predates setGroupApp/embed.app, fall back to setAppView("interface") |
| + // (the old bridge routes a group with apps to its app pane). The page's |
| + // bridge installs a beat after didFinish, so retry until a call lands. |
| + func setGroupApp(_ app: String, attempt: Int = 0) { |
| + let js = """ |
| + (function(){ var n = window.lchatNative; |
| + if (!n) return false; |
| + if (n.setGroupApp) { n.setGroupApp('\(app)'); return true; } |
| + if (n.setAppView) { n.setAppView('interface'); return true; } |
| + return false; })() |
| + """ |
| + webView?.evaluateJavaScript(js) { [weak self] result, _ in |
| + guard (result as? Bool) != true, attempt < 12 else { return } |
| + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { |
| + self?.setGroupApp(app, attempt: attempt + 1) |
| + } |
| + } |
| + } |
| + |
| + // WKUIDelegate shows nothing for window.confirm()/alert() unless we implement |
| + // these — an app interface's confirms (embed mode shares the full web app's |
| + // code paths) silently no-op without them. |
| + func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, |
| + initiatedByFrame frame: WKFrameInfo) async -> Bool { |
| + guard let vc = Self.topViewController() else { return false } |
| + let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) |
| + return await withCheckedContinuation { continuation in |
| + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in continuation.resume(returning: false) }) |
| + alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in continuation.resume(returning: true) }) |
| + vc.present(alert, animated: true) |
| + } |
| + } |
| + |
| + func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, |
| + initiatedByFrame frame: WKFrameInfo) async { |
| + guard let vc = Self.topViewController() else { return } |
| + let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) |
| + await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in |
| + alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in continuation.resume() }) |
| + vc.present(alert, animated: true) |
| + } |
| + } |
| + |
| + private static func topViewController() -> UIViewController? { |
| + guard let scene = UIApplication.shared.connectedScenes |
| + .compactMap({ $0 as? UIWindowScene }) |
| + .first(where: { $0.activationState == .foregroundActive }), |
| + let root = scene.windows.first(where: { $0.isKeyWindow })?.rootViewController else { return nil } |
| + var top = root |
| + while let presented = top.presentedViewController { top = presented } |
| + return top |
| + } |
| + |
| + // The page's reports arrive on the one "lchat" handler. The old chat-embed |
| + // traffic ({staged}, {replying}, {editing}, {error}) only ever fired from |
| + // the chat view, which no page shows anymore. |
| + func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { |
| + guard message.name == "lchat", let body = message.body as? [String: Any] else { return } |
| + if body["ready"] as? Bool == true { |
| + pageReady = true |
| + // A ready page has loaded all three app tiers — pull the live |
| + // catalogue into the registry (one pull per launch; cheap no-op |
| + // after). {catalogChanged} below forces a refresh. |
| + if let web = webView, let origin { AppCatalogPull.pull(from: web, origin: origin) } |
| + } |
| + if body["catalogChanged"] != nil, let web = webView, let origin { |
| + // The space's app set changed (apps_changed): the page re-registered |
| + // member apps live and told us — re-pull so pickers/routing follow. |
| + AppCatalogPull.pull(from: web, origin: origin, force: true) |
| + } |
| + } |
| +} |
diff --git a/ios/LChatCore/Package.resolved b/ios/LChatCore/Package.resolved| index d82f91f..fe25d1c 100644 |
| --- a/ios/LChatCore/Package.resolved |
| +++ b/ios/LChatCore/Package.resolved |
| @@ -1,5 +1,14 @@ |
| { |
| "pins" : [ |
| + { |
| + "identity" : "sparkle", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/sparkle-project/Sparkle", |
| + "state" : { |
| + "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7", |
| + "version" : "2.9.4" |
| + } |
| + }, |
| { |
| "identity" : "swift-cmark", |
| "kind" : "remoteSourceControl", |