perf: instant article open + non-blocking mark-read + static CSS
Changes: 1. Article opens INSTANTLY — articleContent initialized from entry's existing content in init(), not after API fetch. WebView renders immediately with whatever we have. Full content swaps in silently when getEntry returns (only if longer than current). 2. markAsRead is fire-and-forget — wrapped in detached Task inside .task, does not block the content display chain. Toolbar syncs from vm.entries immediately after. 3. CSS template pre-built as static string in ArticleHTMLBuilder. Avoids rebuilding ~2KB of CSS on every article open. HTML builder is a stateless enum with a single static method. 4. Removed isContentReady flag — no longer needed since content is available from init. Spinner only shows if entry truly has no content at all (rare edge case). Flow is now: tap → ArticleView created with entry.articleHTML → WebView loads immediately → user can scroll → background: markAsRead fires, getEntry fetches full content → if full content is better, WebView updates silently Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,18 +6,20 @@ struct ArticleView: View {
|
|||||||
@State private var currentEntry: ReaderEntry
|
@State private var currentEntry: ReaderEntry
|
||||||
@State private var isFetchingFull = false
|
@State private var isFetchingFull = false
|
||||||
@State private var savedToBrain = false
|
@State private var savedToBrain = false
|
||||||
@State private var isContentReady = false
|
|
||||||
@State private var articleContent = ""
|
@State private var articleContent = ""
|
||||||
|
|
||||||
init(entry: ReaderEntry, vm: ReaderViewModel) {
|
init(entry: ReaderEntry, vm: ReaderViewModel) {
|
||||||
self.entry = entry
|
self.entry = entry
|
||||||
self.vm = vm
|
self.vm = vm
|
||||||
_currentEntry = State(initialValue: entry)
|
_currentEntry = State(initialValue: entry)
|
||||||
|
// Initialize with whatever content we already have (from slim list or cache)
|
||||||
|
_articleContent = State(initialValue: entry.articleHTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if !isContentReady {
|
if articleContent.isEmpty {
|
||||||
|
// Only show spinner if we truly have no content at all
|
||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -29,26 +31,15 @@ struct ArticleView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else if articleContent.isEmpty {
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
Spacer()
|
|
||||||
Image(systemName: "doc.text")
|
|
||||||
.font(.system(size: 32))
|
|
||||||
.foregroundStyle(Color.textTertiary)
|
|
||||||
Text("No content available")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(Color.textSecondary)
|
|
||||||
Button("Fetch Full Article") {
|
|
||||||
Task { await fetchFull() }
|
|
||||||
}
|
|
||||||
.font(.subheadline.weight(.medium))
|
|
||||||
.foregroundStyle(Color.accentWarm)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
} else {
|
} else {
|
||||||
// WKWebView handles all scrolling — header is in the HTML
|
ArticleWebView(html: ArticleHTMLBuilder.build(
|
||||||
ArticleWebView(html: buildArticleHTML())
|
title: currentEntry.displayTitle,
|
||||||
|
feedName: currentEntry.feedName,
|
||||||
|
author: currentEntry.author,
|
||||||
|
timeAgo: currentEntry.timeAgo,
|
||||||
|
readingTime: currentEntry.readingTimeText,
|
||||||
|
body: articleContent
|
||||||
|
))
|
||||||
.ignoresSafeArea(edges: .bottom)
|
.ignoresSafeArea(edges: .bottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,20 +94,25 @@ struct ArticleView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
// 1. Mark as read IMMEDIATELY — before any network call
|
// 1. Mark as read — fire-and-forget, don't block UI
|
||||||
await vm.markAsRead(entry)
|
Task { await vm.markAsRead(entry) }
|
||||||
|
|
||||||
// 2. Sync local state with the read mutation
|
// 2. Sync toolbar state
|
||||||
if let updated = vm.entries.first(where: { $0.id == entry.id }) {
|
if let updated = vm.entries.first(where: { $0.id == entry.id }) {
|
||||||
currentEntry = updated
|
currentEntry = updated
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Fetch full content — do NOT overwrite status/starred from server
|
// 3. Fetch full content in background — update when ready
|
||||||
do {
|
do {
|
||||||
let fullEntry = try await ReaderAPI().getEntry(id: entry.id)
|
let fullEntry = try await ReaderAPI().getEntry(id: entry.id)
|
||||||
articleContent = fullEntry.articleHTML
|
let fullHTML = fullEntry.articleHTML
|
||||||
|
|
||||||
// Preserve local status/starred (may differ from server due to race)
|
// Only update if we got better content
|
||||||
|
if !fullHTML.isEmpty && fullHTML.count > articleContent.count {
|
||||||
|
articleContent = fullHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve local status/starred
|
||||||
var merged = fullEntry
|
var merged = fullEntry
|
||||||
if let local = vm.entries.first(where: { $0.id == entry.id }) {
|
if let local = vm.entries.first(where: { $0.id == entry.id }) {
|
||||||
merged.status = local.status
|
merged.status = local.status
|
||||||
@@ -124,9 +120,8 @@ struct ArticleView: View {
|
|||||||
}
|
}
|
||||||
currentEntry = merged
|
currentEntry = merged
|
||||||
} catch {
|
} catch {
|
||||||
articleContent = currentEntry.articleHTML
|
// Keep whatever content we already have
|
||||||
}
|
}
|
||||||
isContentReady = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +144,6 @@ struct ArticleView: View {
|
|||||||
do {
|
do {
|
||||||
let updated = try await ReaderAPI().fetchFullContent(entryId: currentEntry.id)
|
let updated = try await ReaderAPI().fetchFullContent(entryId: currentEntry.id)
|
||||||
articleContent = updated.articleHTML
|
articleContent = updated.articleHTML
|
||||||
// Preserve local status
|
|
||||||
var merged = updated
|
var merged = updated
|
||||||
if let local = vm.entries.first(where: { $0.id == updated.id }) {
|
if let local = vm.entries.first(where: { $0.id == updated.id }) {
|
||||||
merged.status = local.status
|
merged.status = local.status
|
||||||
@@ -166,32 +160,13 @@ struct ArticleView: View {
|
|||||||
savedToBrain = true
|
savedToBrain = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - HTML Builder (header + body in one document)
|
// MARK: - HTML Builder (stateless, reusable CSS template)
|
||||||
|
|
||||||
private func buildArticleHTML() -> String {
|
enum ArticleHTMLBuilder {
|
||||||
let title = currentEntry.displayTitle
|
// Pre-built CSS — avoids rebuilding the same string every article open
|
||||||
.replacingOccurrences(of: "<", with: "<")
|
private static let css = """
|
||||||
.replacingOccurrences(of: ">", with: ">")
|
|
||||||
let feed = currentEntry.feedName
|
|
||||||
.replacingOccurrences(of: "<", with: "<")
|
|
||||||
let author = currentEntry.author ?? ""
|
|
||||||
let time = currentEntry.timeAgo
|
|
||||||
let reading = currentEntry.readingTimeText
|
|
||||||
|
|
||||||
var metaParts = [feed]
|
|
||||||
if !author.isEmpty { metaParts.append("by \(author)") }
|
|
||||||
if !time.isEmpty { metaParts.append(time) }
|
|
||||||
metaParts.append("\(reading) read")
|
|
||||||
let meta = metaParts.joined(separator: " · ")
|
|
||||||
|
|
||||||
return """
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
|
||||||
<meta name="color-scheme" content="light dark">
|
|
||||||
<style>
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, system-ui, sans-serif;
|
font-family: -apple-system, system-ui, sans-serif;
|
||||||
@@ -217,68 +192,59 @@ struct ArticleView: View {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.article-title {
|
.article-title {
|
||||||
font-size: 24px;
|
font-size: 24px; font-weight: 700;
|
||||||
font-weight: 700;
|
line-height: 1.25; margin: 0 0 8px;
|
||||||
line-height: 1.25;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
}
|
}
|
||||||
.article-meta {
|
.article-meta {
|
||||||
font-size: 13px;
|
font-size: 13px; color: #8B6914; line-height: 1.4;
|
||||||
color: #8B6914;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
img {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin: 12px 0;
|
|
||||||
}
|
}
|
||||||
|
img { max-width: 100%; height: auto; border-radius: 8px; margin: 12px 0; }
|
||||||
a { color: #8B6914; }
|
a { color: #8B6914; }
|
||||||
h1, h2, h3, h4 {
|
h1, h2, h3, h4 { line-height: 1.3; margin-top: 24px; margin-bottom: 8px; }
|
||||||
line-height: 1.3;
|
pre, code { background: #f5f0e8; border-radius: 6px; padding: 2px 6px; font-size: 15px; overflow-x: auto; }
|
||||||
margin-top: 24px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
pre, code {
|
|
||||||
background: #f5f0e8;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
font-size: 15px;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
pre { padding: 12px; }
|
pre { padding: 12px; }
|
||||||
pre code { padding: 0; background: none; }
|
pre code { padding: 0; background: none; }
|
||||||
blockquote {
|
blockquote { border-left: 3px solid #8B6914; margin-left: 0; padding-left: 16px; color: #666; }
|
||||||
border-left: 3px solid #8B6914;
|
|
||||||
margin-left: 0;
|
|
||||||
padding-left: 16px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
figure { margin: 16px 0; }
|
figure { margin: 16px 0; }
|
||||||
figcaption {
|
figcaption { font-size: 14px; color: #888; text-align: center; margin-top: 4px; }
|
||||||
font-size: 14px;
|
table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
||||||
color: #888;
|
td, th { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||||
text-align: center;
|
"""
|
||||||
margin-top: 4px;
|
|
||||||
}
|
static func build(
|
||||||
table {
|
title: String,
|
||||||
width: 100%;
|
feedName: String,
|
||||||
border-collapse: collapse;
|
author: String?,
|
||||||
margin: 12px 0;
|
timeAgo: String,
|
||||||
}
|
readingTime: String,
|
||||||
td, th {
|
body: String
|
||||||
border: 1px solid #ddd;
|
) -> String {
|
||||||
padding: 8px;
|
let safeTitle = title
|
||||||
text-align: left;
|
.replacingOccurrences(of: "<", with: "<")
|
||||||
}
|
.replacingOccurrences(of: ">", with: ">")
|
||||||
</style>
|
let safeFeed = feedName
|
||||||
|
.replacingOccurrences(of: "<", with: "<")
|
||||||
|
|
||||||
|
var metaParts = [safeFeed]
|
||||||
|
if let author, !author.isEmpty { metaParts.append("by \(author)") }
|
||||||
|
if !timeAgo.isEmpty { metaParts.append(timeAgo) }
|
||||||
|
metaParts.append("\(readingTime) read")
|
||||||
|
let meta = metaParts.joined(separator: " · ")
|
||||||
|
|
||||||
|
return """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
<style>\(css)</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="article-header">
|
<div class="article-header">
|
||||||
<h1 class="article-title">\(title)</h1>
|
<h1 class="article-title">\(safeTitle)</h1>
|
||||||
<div class="article-meta">\(meta)</div>
|
<div class="article-meta">\(meta)</div>
|
||||||
</div>
|
</div>
|
||||||
\(articleContent)
|
\(body)
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user