eleven / messenger Public

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>
Paul Campbell 7a01ca2235e6471b0e3349c133da14e7d8709cf6 parent 9dacc8c
4 files changed, +200 −48
  • changelog/2026-07-02-ios-instant-chats.md +1 −0
  • ios/LChat/FeedWebCache.swift +155 −0
  • ios/LChat/Views/ChatView.swift +38 −47
  • ios/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