ios: open message links in the system browser (#132)
Message links and link-preview cards render as target="_blank" anchors, which WebKit routes to WKUIDelegate.createWebViewWith as a new-window request rather than a normal navigation. The iOS feed webview set a navigation delegate but no UI delegate, so the request fell on the floor and tapping a link did nothing. Make WebBridge the webview's WKUIDelegate and hand the URL to UIApplication.shared.open (never opening a child webview, which would blow away the embedded feed). Mirrors the fix already shipped in the Mac app.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3 files changed,
+18
−1
changelog/2026-07-03-open-message-links.md+1 −0ios/LChat/FeedWebCache.swift+1 −0ios/LChat/Views/ChatView.swift+16 −1
diff --git a/changelog/2026-07-03-open-message-links.md b/changelog/2026-07-03-open-message-links.md| new file mode 100644 |
| index 0000000..7885dcc |
| --- /dev/null |
| +++ b/changelog/2026-07-03-open-message-links.md |
| @@ -0,0 +1 @@ |
| +In the iOS app, tapping a link in a message opens it in your browser again — before, nothing happened when you tapped one. |
diff --git a/ios/LChat/FeedWebCache.swift b/ios/LChat/FeedWebCache.swift| index 471900d..718068f 100644 |
| --- a/ios/LChat/FeedWebCache.swift |
| +++ b/ios/LChat/FeedWebCache.swift |
| @@ -100,6 +100,7 @@ final class FeedWebCache { |
| 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.scrollView.contentInsetAdjustmentBehavior = .never |
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift| index 6e2b78a..9f66d5a 100644 |
| --- a/ios/LChat/Views/ChatView.swift |
| +++ b/ios/LChat/Views/ChatView.swift |
| @@ -368,7 +368,7 @@ struct ChatView: View { |
| // 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 { |
| +final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNavigationDelegate, WKUIDelegate { |
| weak var webView: WKWebView? |
| // The native shell's floating bars + keyboard, as CSS pixel insets. The page |
| @@ -444,6 +444,21 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav |
| for sub in view.subviews { setInteractiveDismiss(in: sub) } |
| } |
| + // 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 |