diff --git a/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift b/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift index 01c9ff3..a259310 100644 --- a/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift @@ -6,7 +6,6 @@ struct ArticleView: View { @State private var currentEntry: ReaderEntry @State private var isFetchingFull = false @State private var savedToBrain = false - @State private var webViewHeight: CGFloat = 400 @State private var isContentReady = false @State private var articleContent = "" @@ -17,95 +16,47 @@ struct ArticleView: View { } var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 12) { - // Article header - VStack(alignment: .leading, spacing: 8) { - Text(currentEntry.displayTitle) - .font(.title2.weight(.bold)) - .foregroundStyle(Color.textPrimary) - - HStack(spacing: 8) { - Text(currentEntry.feedName) - .font(.subheadline.weight(.medium)) - .foregroundStyle(Color.accentWarm) - - if let author = currentEntry.author, !author.isEmpty { - Text("\u{2022} \(author)") - .font(.subheadline) - .foregroundStyle(Color.textSecondary) - } - } - - HStack(spacing: 12) { - if !currentEntry.timeAgo.isEmpty { - Label(currentEntry.timeAgo, systemImage: "clock") - .font(.caption) - .foregroundStyle(Color.textTertiary) - } - Label(currentEntry.readingTimeText, systemImage: "book") - .font(.caption) - .foregroundStyle(Color.textTertiary) - } + Group { + if !isContentReady { + VStack { + Spacer() + ProgressView() + .controlSize(.regular) + Text("Loading article...") + .font(.caption) + .foregroundStyle(Color.textTertiary) + .padding(.top, 8) + Spacer() } - .padding(.horizontal, 16) - .padding(.top, 8) - - Divider() - .padding(.horizontal, 16) - - // Article body - if !isContentReady { - HStack { - ProgressView() - .controlSize(.small) - Text("Loading article...") - .font(.caption) - .foregroundStyle(Color.textTertiary) + .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() } } - .frame(maxWidth: .infinity) - .padding(.vertical, 40) - } else if articleContent.isEmpty { - if isFetchingFull { - HStack { - ProgressView() - .controlSize(.small) - Text("Fetching article...") - .font(.caption) - .foregroundStyle(Color.textTertiary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 40) - } else { - VStack(spacing: 12) { - 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) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 40) - } - } else { - ArticleWebView(html: wrapHTML(articleContent), contentHeight: $webViewHeight) - .frame(height: webViewHeight) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.accentWarm) + Spacer() } - - Spacer(minLength: 80) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + // WKWebView handles all scrolling — header is in the HTML + ArticleWebView(html: buildArticleHTML()) + .ignoresSafeArea(edges: .bottom) } } + .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.canvas) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { - // Star toggle Button { Task { await toggleStar() } } label: { @@ -113,7 +64,6 @@ struct ArticleView: View { .foregroundStyle(currentEntry.starred ? .orange : Color.textTertiary) } - // Read/unread toggle Button { Task { await toggleRead() } } label: { @@ -121,7 +71,6 @@ struct ArticleView: View { .foregroundStyle(Color.textTertiary) } - // More actions Menu { if currentEntry.url != nil { Button { @@ -154,19 +103,27 @@ struct ArticleView: View { } } .task { - // Auto-mark as read (fire-and-forget) - Task { await vm.markAsRead(entry) } + // 1. Mark as read IMMEDIATELY — before any network call + await vm.markAsRead(entry) - // Fetch full entry content on demand + // 2. Sync local state with the read mutation + if let updated = vm.entries.first(where: { $0.id == entry.id }) { + currentEntry = updated + } + + // 3. Fetch full content — do NOT overwrite status/starred from server do { - let fullEntry = try await ReaderAPI().getEntry(id: currentEntry.id) - currentEntry = fullEntry + let fullEntry = try await ReaderAPI().getEntry(id: entry.id) articleContent = fullEntry.articleHTML - if let idx = vm.entries.firstIndex(where: { $0.id == fullEntry.id }) { - vm.entries[idx] = fullEntry + + // Preserve local status/starred (may differ from server due to race) + var merged = fullEntry + if let local = vm.entries.first(where: { $0.id == entry.id }) { + merged.status = local.status + merged.starred = local.starred } + currentEntry = merged } catch { - // Fall back to whatever content we already have articleContent = currentEntry.articleHTML } isContentReady = true @@ -191,11 +148,14 @@ struct ArticleView: View { isFetchingFull = true do { let updated = try await ReaderAPI().fetchFullContent(entryId: currentEntry.id) - currentEntry = updated articleContent = updated.articleHTML - if let idx = vm.entries.firstIndex(where: { $0.id == updated.id }) { - vm.entries[idx] = updated + // Preserve local status + var merged = updated + if let local = vm.entries.first(where: { $0.id == updated.id }) { + merged.status = local.status + merged.starred = local.starred } + currentEntry = merged } catch {} isFetchingFull = false } @@ -207,8 +167,25 @@ struct ArticleView: View { } } - private func wrapHTML(_ body: String) -> String { - """ + // MARK: - HTML Builder (header + body in one document) + + private func buildArticleHTML() -> String { + let title = currentEntry.displayTitle + .replacingOccurrences(of: "<", with: "<") + .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 """
@@ -221,7 +198,7 @@ struct ArticleView: View { font-size: 17px; line-height: 1.6; color: #1f1f1f; - padding: 0 16px 40px; + padding: 0 16px 60px; margin: 0; background: transparent; -webkit-text-size-adjust: 100%; @@ -232,6 +209,23 @@ struct ArticleView: View { 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%; @@ -279,7 +273,13 @@ struct ArticleView: View { } - \(body) + +