Add in-app browser with reader mode
All checks were successful
CI / build-and-deploy (push) Successful in 22s

Tapping a bookmark now opens an in-app WKWebView instead of bouncing to
Safari. Reader mode injects JS to extract article content and rerender
with clean serif typography and dark-mode support. Bottom toolbar has
back/forward, reader toggle, share, and open-in-Safari escape hatch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krishna Kumar
2026-05-21 12:43:33 -05:00
parent 7d1b53bac6
commit ac2f9cf85d
3 changed files with 214 additions and 2 deletions

View File

@@ -26,6 +26,7 @@
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; }; BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; };
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; }; C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; };
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; }; EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; };
FA1B2C3D4E5F6A7B8C9D0E1F /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA1B2C3D4E5F6A7B8C9D0E10 /* BrowserView.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -75,6 +76,7 @@
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; }; CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; };
D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = "<group>"; }; D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = "<group>"; };
D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; }; D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; };
FA1B2C3D4E5F6A7B8C9D0E10 /* BrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserView.swift; sourceTree = "<group>"; };
E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = "<group>"; }; FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -129,6 +131,7 @@
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */, AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */,
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */, B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */,
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */, CBE3C5E420F078D499B2D926 /* BookmarksView.swift */,
FA1B2C3D4E5F6A7B8C9D0E10 /* BrowserView.swift */,
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */, CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */,
FE19F7619214271A8C12EEEB /* CollectionsView.swift */, FE19F7619214271A8C12EEEB /* CollectionsView.swift */,
D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */, D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */,
@@ -265,6 +268,7 @@
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */, 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */,
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */, 96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */,
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */, A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */,
FA1B2C3D4E5F6A7B8C9D0E1F /* BrowserView.swift in Sources */,
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */, 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */,
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */, BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */,
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */, 6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */,

View File

@@ -9,6 +9,7 @@ struct BookmarksView: View {
@State private var showCollections = false @State private var showCollections = false
@State private var showAddBookmark = false @State private var showAddBookmark = false
@State private var editingBookmark: Bookmark? @State private var editingBookmark: Bookmark?
@State private var browsingBookmark: Bookmark?
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -16,7 +17,7 @@ struct BookmarksView: View {
ForEach(viewModel.bookmarks) { bookmark in ForEach(viewModel.bookmarks) { bookmark in
BookmarkRow(bookmark: bookmark) BookmarkRow(bookmark: bookmark)
.onTapGesture { .onTapGesture {
if let url = URL(string: bookmark.url) { openURL(url) } browsingBookmark = bookmark
} }
.onAppear { maybeLoadMore(bookmark) } .onAppear { maybeLoadMore(bookmark) }
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
@@ -48,10 +49,15 @@ struct BookmarksView: View {
} label: { } label: {
Label("Edit", systemImage: "pencil") Label("Edit", systemImage: "pencil")
} }
Button {
browsingBookmark = bookmark
} label: {
Label("Open", systemImage: "globe")
}
Button { Button {
if let url = URL(string: bookmark.url) { openURL(url) } if let url = URL(string: bookmark.url) { openURL(url) }
} label: { } label: {
Label("Open", systemImage: "safari") Label("Open in Safari", systemImage: "safari")
} }
Divider() Divider()
Button { Button {
@@ -139,6 +145,11 @@ struct BookmarksView: View {
.sheet(item: $editingBookmark) { bookmark in .sheet(item: $editingBookmark) { bookmark in
EditBookmarkView(viewModel: viewModel, bookmark: bookmark) EditBookmarkView(viewModel: viewModel, bookmark: bookmark)
} }
.sheet(item: $browsingBookmark) { bookmark in
if let url = URL(string: bookmark.url) {
BrowserView(url: url, title: bookmark.displayTitle)
}
}
.refreshable { .refreshable {
await viewModel.load() await viewModel.load()
} }

View File

@@ -0,0 +1,197 @@
import SwiftUI
import WebKit
@Observable
final class BrowserState: NSObject, WKNavigationDelegate {
let webView: WKWebView
var isLoading = false
var canGoBack = false
var canGoForward = false
var pageTitle = ""
var currentURL: URL?
init(url: URL) {
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true
webView = WKWebView(frame: .zero, configuration: config)
super.init()
webView.navigationDelegate = self
webView.load(URLRequest(url: url))
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation _: WKNavigation!) {
isLoading = true
}
func webView(_ webView: WKWebView, didCommit _: WKNavigation!) {
currentURL = webView.url
canGoBack = webView.canGoBack
canGoForward = webView.canGoForward
}
func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
isLoading = false
canGoBack = webView.canGoBack
canGoForward = webView.canGoForward
pageTitle = webView.title ?? ""
currentURL = webView.url
}
func webView(_: WKWebView, didFail _: WKNavigation!, withError _: Error) {
isLoading = false
}
func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) {
isLoading = false
}
func enterReaderMode(completion: @escaping () -> Void) {
webView.evaluateJavaScript(readerModeJS) { _, _ in
DispatchQueue.main.async { completion() }
}
}
func exitReaderMode() {
webView.reload()
}
private var readerModeJS: String {
"""
(function() {
var title = document.title;
var selectors = ['article','[role="article"]','main','[role="main"]',
'.post-content','.article-content','.entry-content',
'.post-body','.article-body','.story-body','.content','#content'];
var content = null;
for (var i = 0; i < selectors.length; i++) {
var el = document.querySelector(selectors[i]);
if (el && (el.innerText||'').trim().length > 300) { content = el; break; }
}
if (!content) {
var best = { el: null, len: 0 };
Array.from(document.querySelectorAll('div')).forEach(function(d) {
var len = Array.from(d.querySelectorAll('p')).reduce(function(a,p){return a+(p.innerText||'').length;},0);
if (len > best.len) best = { el: d, len: len };
});
content = best.el || document.body;
}
var clone = content.cloneNode(true);
['script','style','nav','header','footer','aside'].forEach(function(tag){
clone.querySelectorAll(tag).forEach(function(el){ el.remove(); });
});
var html = clone.innerHTML;
var css = [
'body{font-family:-apple-system,Georgia,serif;max-width:680px;margin:0 auto;',
'padding:20px 20px 60px;line-height:1.75;font-size:18px;background:#fafafa;color:#1c1c1e}',
'h1{font-size:1.6em;line-height:1.3;margin:0 0 .5em}',
'h2,h3,h4{line-height:1.35;margin:1.5em 0 .5em}p{margin:0 0 1em}',
'img{max-width:100%;height:auto;border-radius:8px;margin:8px 0}',
'a{color:#007aff;text-decoration:none}',
'blockquote{border-left:3px solid #ccc;margin:1em 0;padding:0 1em;color:#555}',
'code,pre{font-family:ui-monospace,monospace;font-size:.9em;background:#f0f0f0;border-radius:4px;padding:2px 4px}',
'pre code{padding:0;background:none}pre{padding:12px;overflow-x:auto}',
'@media(prefers-color-scheme:dark){body{background:#1c1c1e;color:#e5e5e7}',
'blockquote{border-color:#555;color:#aaa}code,pre{background:#2c2c2e}a{color:#0a84ff}}'
].join('');
document.open();
document.write('<!DOCTYPE html><html><head>' +
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
'<style>' + css + '</style>' +
'<title>' + title + '</title></head><body>' +
'<h1>' + title + '</h1>' + html + '</body></html>');
document.close();
return 'ok';
})()
"""
}
}
private struct WebViewRepresentable: UIViewRepresentable {
let webView: WKWebView
func makeUIView(context: Context) -> WKWebView { webView }
func updateUIView(_ uiView: WKWebView, context: Context) {}
}
struct BrowserView: View {
let initialURL: URL
let initialTitle: String
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@State private var state: BrowserState
@State private var readerMode = false
init(url: URL, title: String) {
self.initialURL = url
self.initialTitle = title
self._state = State(initialValue: BrowserState(url: url))
}
var body: some View {
NavigationStack {
ZStack(alignment: .top) {
WebViewRepresentable(webView: state.webView)
.ignoresSafeArea(edges: .bottom)
if state.isLoading {
ProgressView()
.padding(.top, 8)
}
}
.navigationTitle(state.pageTitle.isEmpty ? initialTitle : state.pageTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.fontWeight(.semibold)
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
openURL(state.currentURL ?? initialURL)
} label: {
Image(systemName: "safari")
}
}
ToolbarItemGroup(placement: .bottomBar) {
Button {
state.webView.goBack()
} label: {
Image(systemName: "chevron.left")
}
.disabled(!state.canGoBack)
Spacer()
Button {
state.webView.goForward()
} label: {
Image(systemName: "chevron.right")
}
.disabled(!state.canGoForward)
Spacer()
Button {
if readerMode {
state.exitReaderMode()
readerMode = false
} else {
state.enterReaderMode { readerMode = true }
}
} label: {
Image(systemName: readerMode ? "doc.text.fill" : "doc.text")
}
.disabled(state.isLoading)
Spacer()
ShareLink(item: state.currentURL ?? initialURL) {
Image(systemName: "square.and.arrow.up")
}
}
}
}
}
}