eleven / messenger Public

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>
Paul Campbell a6e20df3a97977742a50bc3a1abcfaf06eccd06c parent e027ee7
14 files changed, +535 −1170
  • docs/native-apps.md +11 −4
  • docs/native-chat.md +8 −0
  • ios/LChat/FeedWebCache.swift +33 −58
  • ios/LChat/PushManager.swift +2 −2
  • ios/LChat/Views/AppThreadScreen.swift +201 −0
  • ios/LChat/Views/ChatView.swift +0 −1019
  • ios/LChat/Views/EdgeOnlyBackGesture.swift +0 −41
  • ios/LChat/Views/FloatingGlass.swift +29 −0
  • ios/LChat/Views/GroupAppPane.swift +2 −2
  • ios/LChat/Views/NativeChatScreen.swift +29 −11
  • ios/LChat/Views/SchemaAppScreen.swift +20 −26
  • ios/LChat/Views/ThreadListView.swift +6 −7
  • ios/LChat/Views/WebBridge.swift +185 −0
  • ios/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",