eleven / messenger Public

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>
Paul Campbell fbc5c4d9bd6c60fb7a41f09b292ffa06b581f31b parent cae02d8
3 files changed, +18 −1
  • changelog/2026-07-03-open-message-links.md +1 −0
  • ios/LChat/FeedWebCache.swift +1 −0
  • ios/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