ios: keep feed webviews alive + prewarm the top chats (instant opens) (#90)
Every chat open used to create a fresh WKWebView and boot chat.js from scratch — a loading spinner on every tap. FeedWebCache now keeps each thread's webview (and its WebBridge) alive after you leave, and the thread list prewarms the top five chats off-screen after each load, so opening a chat attaches an already-rendered, already-live feed. The native mirror of the web's per-thread feed cache + prebuild. Parked pages keep their WebSocket (chat.js reconnects and catches up after drops), so a re-attached feed is current; didAttach() re-pins to the newest message since a parked page can't run its smooth-scroll. LRU-capped at 8 webviews, on-screen entries never evicted, everything parked is shed on memory warnings. The E2E posture is unchanged: each page is still seeded with exactly one thread's key.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+200
−48
changelog/2026-07-02-ios-instant-chats.md+1 −0ios/LChat/FeedWebCache.swift+155 −0ios/LChat/Views/ChatView.swift+38 −47ios/LChat/Views/ThreadListView.swift+6 −1
diff --git a/changelog/2026-07-02-ios-instant-chats.md b/changelog/2026-07-02-ios-instant-chats.md| new file mode 100644 |
| index 0000000..bd758f3 |
| --- /dev/null |
| +++ b/changelog/2026-07-02-ios-instant-chats.md |
| @@ -0,0 +1 @@ |
| +Improved: in the iPhone app, chats you've already visited now open instantly, and your most recent chats are loaded in the background — no more loading spinner every time you tap into a conversation. |
diff --git a/ios/LChat/FeedWebCache.swift b/ios/LChat/FeedWebCache.swift| new file mode 100644 |
| index 0000000..de634f3 |
| --- /dev/null |
| +++ b/ios/LChat/FeedWebCache.swift |
| @@ -0,0 +1,155 @@ |
| +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. |
| +// |
| +// 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. |
| +@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 { |
| + let web: WKWebView |
| + let bridge: WebBridge |
| + let seedKey: String // the thread key the page was seeded with |
| + var lastUsed: Date |
| + init(web: WKWebView, bridge: WebBridge, seedKey: String) { |
| + self.web = web |
| + self.bridge = bridge |
| + self.seedKey = seedKey |
| + self.lastUsed = Date() |
| + } |
| + } |
| + |
| + private var entries: [String: Entry] = [:] // "instanceID|threadID" -> entry |
| + |
| + private init() { |
| + // Parked feeds are pure cache — shed them under memory pressure. |
| + NotificationCenter.default.addObserver( |
| + forName: UIApplication.didReceiveMemoryWarningNotification, object: nil, queue: .main |
| + ) { _ in |
| + Task { @MainActor in FeedWebCache.shared.dropParked() } |
| + } |
| + } |
| + |
| + // The live webview for this thread — reused if we have it, created (and left |
| + // loading) if not. Marks the entry most-recently-used so eviction spares it. |
| + func webView(for instance: SavedInstance, thread: Thread, myUserId: String, |
| + instanceKeyB64: String?) -> WKWebView { |
| + let e = entry(for: instance, thread: thread, myUserId: myUserId, instanceKeyB64: instanceKeyB64) |
| + e.lastUsed = Date() |
| + 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. |
| + func bridge(for instance: SavedInstance, thread: Thread, myUserId: String, |
| + instanceKeyB64: String?) -> WebBridge { |
| + entry(for: instance, thread: thread, myUserId: myUserId, instanceKeyB64: instanceKeyB64).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: [Thread], myUserId: String, instanceKeyB64: String?) { |
| + for t in threads where !t.threadKeyB64.isEmpty { |
| + _ = entry(for: instance, thread: t, myUserId: myUserId, instanceKeyB64: instanceKeyB64) |
| + } |
| + } |
| + |
| + private func entry(for instance: SavedInstance, thread: Thread, myUserId: String, |
| + instanceKeyB64: String?) -> Entry { |
| + let key = instance.id + "|" + thread.id |
| + // Reuse only if the page was seeded with the same thread key — a re-invite / |
| + // key change makes the parked page stale, so rebuild it. |
| + 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) |
| + entries[key] = e |
| + evictIfNeeded() |
| + return e |
| + } |
| + |
| + // The webview construction that used to live in ChatFeedWebView.makeUIView. |
| + private func makeEntry(instance: SavedInstance, thread: Thread, myUserId: String, |
| + instanceKeyB64: String?) -> Entry { |
| + let bridge = WebBridge() |
| + let cfg = WKWebViewConfiguration() |
| + cfg.websiteDataStore = .default() |
| + cfg.allowsInlineMediaPlayback = true |
| + if let script = Self.bootScript(instance: instance, thread: thread, myUserId: myUserId, |
| + instanceKeyB64: instanceKeyB64) { |
| + cfg.userContentController.addUserScript(script) |
| + } |
| + cfg.userContentController.add(bridge, name: "lchat") |
| + let web = WKWebView(frame: .zero, configuration: cfg) |
| + bridge.webView = web |
| + web.navigationDelegate = bridge |
| + // 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.scrollView.contentInsetAdjustmentBehavior = .never |
| + #if DEBUG |
| + if #available(iOS 16.4, macCatalyst 16.4, *) { web.isInspectable = true } |
| + #endif |
| + if let url = instance.baseURL { web.load(URLRequest(url: url)) } |
| + 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. |
| + private static func bootScript(instance: SavedInstance, thread: Thread, myUserId: String, |
| + instanceKeyB64: String?) -> WKUserScript? { |
| + var seed: [String: Any] = [ |
| + "token": instance.deviceToken, |
| + "name": instance.screenName, |
| + "myUserId": myUserId, |
| + "threadId": thread.id, |
| + "threadKey": thread.threadKeyB64, |
| + ] |
| + if let ik = instanceKeyB64 { seed["instanceKey"] = ik } |
| + guard let data = try? JSONSerialization.data(withJSONObject: seed), |
| + let json = String(data: data, encoding: .utf8) else { return nil } |
| + // json is a JSON object literal (valid JS). Assigning it as the flag means |
| + // chat.js both detects embed mode and reads the seed from one global. |
| + let js = "window.__lchatEmbed = \(json);" |
| + return WKUserScript(source: js, injectionTime: .atDocumentStart, forMainFrameOnly: true) |
| + } |
| + |
| + // Trim least-recently-opened parked feeds past the cap. A webview that's on |
| + // screen (in a window) is never evicted, whatever its age. |
| + private func evictIfNeeded() { |
| + while entries.count > maxEntries { |
| + let parked = entries.filter { $0.value.web.window == nil } |
| + guard let oldest = parked.min(by: { $0.value.lastUsed < $1.value.lastUsed }) else { return } |
| + if let e = entries.removeValue(forKey: oldest.key) { release(e) } |
| + } |
| + } |
| + |
| + private func dropParked() { |
| + for (key, e) in entries where e.web.window == nil { |
| + entries.removeValue(forKey: key) |
| + release(e) |
| + } |
| + } |
| + |
| + private func release(_ e: Entry) { |
| + e.web.stopLoading() |
| + // The controller retains the bridge; break that so the whole page tears down. |
| + e.web.configuration.userContentController.removeScriptMessageHandler(forName: "lchat") |
| + } |
| +} |
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift| index ac1c60b..622d049 100644 |
| --- a/ios/LChat/Views/ChatView.swift |
| +++ b/ios/LChat/Views/ChatView.swift |
| @@ -18,7 +18,9 @@ import UIKit |
| // the + button stages a photo via window.lchatNative.stageAttachment. Both run the |
| // one crypto/upload path (the web layer). |
| struct ChatView: View { |
| - @StateObject private var bridge = WebBridge() |
| + // 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 |
| @State private var text = "" |
| @State private var caret = 0 // UTF-16, kept in sync by ComposerField |
| @@ -34,6 +36,16 @@ struct ChatView: View { |
| var myUserId: String = "" |
| let instanceKeyB64: String? |
| + @MainActor |
| + init(instance: SavedInstance, thread: Thread, 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) |
| + } |
| + |
| private var isMac: Bool { ProcessInfo.processInfo.isMacCatalystApp } |
| private var canSend: Bool { |
| @@ -42,12 +54,12 @@ struct ChatView: View { |
| var body: some View { |
| ZStack { |
| - if let url = instance.baseURL { |
| + 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, origin: url, bridge: bridge) |
| + instanceKeyB64: instanceKeyB64) |
| .ignoresSafeArea() |
| // The webview is the full-bleed reference surface: its own |
| // global frame is what the insets are measured against (never |
| @@ -90,7 +102,10 @@ struct ChatView: View { |
| .modifier(GlassNavigationBar()) |
| .navigationTitle(isMac ? "" : thread.title) |
| .navigationBarTitleDisplayMode(.inline) |
| - .onAppear { if isMac { composerFocused = true } } |
| + .onAppear { |
| + bridge.didAttach() // a cached feed may need re-pinning to its newest message |
| + if isMac { composerFocused = true } |
| + } |
| #if targetEnvironment(macCatalyst) |
| .background(MacWindowTitle(title: thread.title)) |
| #endif |
| @@ -342,6 +357,19 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav |
| applyInsets() |
| } |
| + // 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 a bubble or two shy of the newest |
| + // message. Re-pin unless the user had deliberately scrolled up into history. |
| + func didAttach() { |
| + webView?.evaluateJavaScript(""" |
| + (function(){ |
| + var f = document.getElementById('feed'); |
| + if (f && f.scrollHeight - f.scrollTop - f.clientHeight < f.clientHeight) f.scrollTop = f.scrollHeight; |
| + })() |
| + """) |
| + } |
| + |
| // 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 |
| @@ -468,59 +496,22 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav |
| } |
| } |
| -// WKWebView host for one thread's feed. Loads the instance origin (`/`) and injects |
| -// window.__lchatEmbed at documentStart with the material chat.js needs to render this |
| -// one thread: the device token, our screen name + user id, the thread id, and the |
| -// already-unwrapped thread key (plus the instance key for avatars, if we have it). |
| +// 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: Thread |
| let myUserId: String |
| let instanceKeyB64: String? |
| - let origin: URL |
| - let bridge: WebBridge |
| func makeUIView(context: Context) -> WKWebView { |
| - let cfg = WKWebViewConfiguration() |
| - cfg.websiteDataStore = .default() |
| - cfg.allowsInlineMediaPlayback = true |
| - if let script = bootScript() { |
| - cfg.userContentController.addUserScript(script) |
| - } |
| - cfg.userContentController.add(bridge, name: "lchat") |
| - let web = WKWebView(frame: .zero, configuration: cfg) |
| - bridge.webView = web |
| - web.navigationDelegate = bridge |
| - // 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.scrollView.contentInsetAdjustmentBehavior = .never |
| - #if DEBUG |
| - if #available(iOS 16.4, macCatalyst 16.4, *) { web.isInspectable = true } |
| - #endif |
| - web.load(URLRequest(url: origin)) |
| - return web |
| + FeedWebCache.shared.webView(for: instance, thread: thread, myUserId: myUserId, |
| + instanceKeyB64: instanceKeyB64) |
| } |
| func updateUIView(_ web: WKWebView, context: Context) {} |
| - |
| - // 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. |
| - private func bootScript() -> WKUserScript? { |
| - var seed: [String: Any] = [ |
| - "token": instance.deviceToken, |
| - "name": instance.screenName, |
| - "myUserId": myUserId, |
| - "threadId": thread.id, |
| - "threadKey": thread.threadKeyB64, |
| - ] |
| - if let ik = instanceKeyB64 { seed["instanceKey"] = ik } |
| - guard let data = try? JSONSerialization.data(withJSONObject: seed), |
| - let json = String(data: data, encoding: .utf8) else { return nil } |
| - // json is a JSON object literal (valid JS). Assigning it as the flag means |
| - // chat.js both detects embed mode and reads the seed from one global. |
| - let js = "window.__lchatEmbed = \(json);" |
| - return WKUserScript(source: js, injectionTime: .atDocumentStart, forMainFrameOnly: true) |
| - } |
| } |
| // Liquid Glass on iOS 26, thin material below — one modifier so every floating |
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift| index 6519416..2274e79 100644 |
| --- a/ios/LChat/Views/ThreadListView.swift |
| +++ b/ios/LChat/Views/ThreadListView.swift |
| @@ -101,6 +101,11 @@ struct ThreadListView: View { |
| private func load() async { |
| await store.refresh() |
| app.note(id: store.instance.id, myUserId: store.myUserId, groupName: store.instanceName) |
| + // Warm the top chats' feed webviews off-screen so tapping one attaches an |
| + // already-rendered feed — the native mirror of the web's prebuild. |
| + FeedWebCache.shared.warm(instance: store.instance, |
| + threads: Array(store.threads.prefix(FeedWebCache.prewarmCount)), |
| + myUserId: store.myUserId, instanceKeyB64: store.instanceKey()) |
| } |
| } |
| @@ -115,7 +120,7 @@ struct ThreadDestination: View { |
| if let t = store.threads.first(where: { $0.id == id }) { |
| ChatView(instance: store.instance, thread: t, |
| myUserId: store.myUserId, instanceKeyB64: store.instanceKey()) |
| - .id(store.instance.id + "|" + t.id) // fresh webview per thread |
| + .id(store.instance.id + "|" + t.id) // stable identity per thread (webview reused via FeedWebCache) |
| .onAppear { |
| store.markRead(t) |
| PushManager.activeThreadID = t.id // suppress banners for the open chat |