diff --git a/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift b/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift index a259310..000b320 100644 --- a/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift @@ -6,18 +6,20 @@ struct ArticleView: View { @State private var currentEntry: ReaderEntry @State private var isFetchingFull = false @State private var savedToBrain = false - @State private var isContentReady = false @State private var articleContent = "" init(entry: ReaderEntry, vm: ReaderViewModel) { self.entry = entry self.vm = vm _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 { Group { - if !isContentReady { + if articleContent.isEmpty { + // Only show spinner if we truly have no content at all VStack { Spacer() ProgressView() @@ -29,27 +31,16 @@ struct ArticleView: View { Spacer() } .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 { - // WKWebView handles all scrolling — header is in the HTML - ArticleWebView(html: buildArticleHTML()) - .ignoresSafeArea(edges: .bottom) + ArticleWebView(html: ArticleHTMLBuilder.build( + title: currentEntry.displayTitle, + feedName: currentEntry.feedName, + author: currentEntry.author, + timeAgo: currentEntry.timeAgo, + readingTime: currentEntry.readingTimeText, + body: articleContent + )) + .ignoresSafeArea(edges: .bottom) } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -103,20 +94,25 @@ struct ArticleView: View { } } .task { - // 1. Mark as read IMMEDIATELY — before any network call - await vm.markAsRead(entry) + // 1. Mark as read — fire-and-forget, don't block UI + 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 }) { currentEntry = updated } - // 3. Fetch full content — do NOT overwrite status/starred from server + // 3. Fetch full content in background — update when ready do { 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 if let local = vm.entries.first(where: { $0.id == entry.id }) { merged.status = local.status @@ -124,9 +120,8 @@ struct ArticleView: View { } currentEntry = merged } catch { - articleContent = currentEntry.articleHTML + // Keep whatever content we already have } - isContentReady = true } } @@ -149,7 +144,6 @@ struct ArticleView: View { do { let updated = try await ReaderAPI().fetchFullContent(entryId: currentEntry.id) articleContent = updated.articleHTML - // Preserve local status var merged = updated if let local = vm.entries.first(where: { $0.id == updated.id }) { merged.status = local.status @@ -166,23 +160,75 @@ struct ArticleView: View { savedToBrain = true } } +} - // MARK: - HTML Builder (header + body in one document) +// MARK: - HTML Builder (stateless, reusable CSS template) - private func buildArticleHTML() -> String { - let title = currentEntry.displayTitle +enum ArticleHTMLBuilder { + // Pre-built CSS — avoids rebuilding the same string every article open + private static let css = """ + * { box-sizing: border-box; } + body { + font-family: -apple-system, system-ui, sans-serif; + font-size: 17px; + line-height: 1.6; + color: #1f1f1f; + padding: 0 16px 60px; + margin: 0; + background: transparent; + -webkit-text-size-adjust: 100%; + } + @media (prefers-color-scheme: dark) { + body { color: #ede8e1; } + a { color: #c79e40 !important; } + pre, code { background: #1e1c1a !important; } + blockquote { border-left-color: #c79e40; color: #9a9590; } + td, th { border-color: #333; } + .article-meta { color: #8a8580; } + } + .article-header { + padding: 16px 0 12px; + border-bottom: 1px solid rgba(128,128,128,0.2); + margin-bottom: 16px; + } + .article-title { + font-size: 24px; font-weight: 700; + line-height: 1.25; margin: 0 0 8px; + } + .article-meta { + font-size: 13px; color: #8B6914; line-height: 1.4; + } + img { max-width: 100%; height: auto; border-radius: 8px; margin: 12px 0; } + a { color: #8B6914; } + h1, h2, h3, h4 { line-height: 1.3; 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 code { padding: 0; background: none; } + blockquote { border-left: 3px solid #8B6914; margin-left: 0; padding-left: 16px; color: #666; } + figure { margin: 16px 0; } + figcaption { font-size: 14px; color: #888; text-align: center; margin-top: 4px; } + table { width: 100%; border-collapse: collapse; margin: 12px 0; } + td, th { border: 1px solid #ddd; padding: 8px; text-align: left; } + """ + + static func build( + title: String, + feedName: String, + author: String?, + timeAgo: String, + readingTime: String, + body: String + ) -> String { + let safeTitle = title .replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">") - let feed = currentEntry.feedName + let safeFeed = 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") + 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 """ @@ -191,94 +237,14 @@ struct ArticleView: View { - + -
-

\(title)

-
\(meta)
-
- \(articleContent) +
+

\(safeTitle)

+
\(meta)
+
+ \(body) """