fix: resolve Reader freezing, article layout loop, and feedback issues
All checks were successful
Security Checks / dependency-audit (push) Successful in 14s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s

- ArticleWebView: remove dangerous intrinsicContentSize override, use
  height binding measured via JS after render
- ReaderViewModel: add @MainActor, replace didSet filter with explicit
  applyFilter() to avoid property observer reentrancy
- Thumbnail extraction: use precompiled NSRegularExpression, limit search
  to first 2000 chars, skip placeholder when no image found
- Card view: only show thumbnail when image exists (no placeholder)
- Feedback: add guard against double-tap, @MainActor on Task

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 19:16:57 -05:00
parent a496c3520b
commit 798ba17a93
7 changed files with 56 additions and 61 deletions

View File

@@ -143,10 +143,11 @@ struct FeedbackSheet: View {
} }
private func sendFeedback() { private func sendFeedback() {
guard !isSending else { return }
isSending = true isSending = true
error = nil error = nil
Task { Task { @MainActor in
do { do {
var body: [String: Any] = ["text": text.trimmingCharacters(in: .whitespaces), "source": "ios"] var body: [String: Any] = ["text": text.trimmingCharacters(in: .whitespaces), "source": "ios"]
if let imgData = photoData { if let imgData = photoData {

View File

@@ -49,20 +49,25 @@ struct ReaderEntry: Codable, Identifiable, Hashable {
} }
var thumbnailURL: URL? { var thumbnailURL: URL? {
// Extract first <img src="..."> from content ReaderEntry.extractThumbnail(from: content ?? fullContent ?? "")
let html = content ?? fullContent ?? "" }
guard let range = html.range(of: #"<img[^>]+src=["\']([^"\']+)["\']"#, options: .regularExpression) else {
private static let imgRegex = try! NSRegularExpression(
pattern: #"<img[^>]+src=["']([^"']+)["']"#,
options: .caseInsensitive
)
static func extractThumbnail(from html: String) -> URL? {
guard !html.isEmpty else { return nil }
// Only search first 2000 chars for performance
let searchRange = html.prefix(2000)
let nsRange = NSRange(searchRange.startIndex..., in: searchRange)
guard let match = imgRegex.firstMatch(in: String(searchRange), range: nsRange),
match.numberOfRanges > 1,
let srcRange = Range(match.range(at: 1), in: searchRange) else {
return nil return nil
} }
let match = String(html[range]) let src = String(searchRange[srcRange])
guard let srcRange = match.range(of: #"src=["\']([^"\']+)["\']"#, options: .regularExpression) else {
return nil
}
var src = String(match[srcRange])
src = src.replacingOccurrences(of: "src=\"", with: "")
.replacingOccurrences(of: "src='", with: "")
.replacingOccurrences(of: "\"", with: "")
.replacingOccurrences(of: "'", with: "")
return URL(string: src) return URL(string: src)
} }

View File

@@ -1,5 +1,6 @@
import Foundation import Foundation
@MainActor
@Observable @Observable
final class ReaderViewModel { final class ReaderViewModel {
// MARK: - State // MARK: - State
@@ -24,8 +25,11 @@ final class ReaderViewModel {
case category(Int) case category(Int)
} }
var currentFilter: ReaderFilter = .unread { var currentFilter: ReaderFilter = .unread
didSet { Task { await loadEntries(reset: true) } }
func applyFilter(_ filter: ReaderFilter) {
currentFilter = filter
Task { await loadEntries(reset: true) }
} }
// MARK: - Private // MARK: - Private

View File

@@ -6,6 +6,7 @@ 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 webViewHeight: CGFloat = 400
init(entry: ReaderEntry, vm: ReaderViewModel) { init(entry: ReaderEntry, vm: ReaderViewModel) {
self.entry = entry self.entry = entry
@@ -81,8 +82,8 @@ struct ArticleView: View {
.padding(.vertical, 40) .padding(.vertical, 40)
} }
} else { } else {
ArticleWebView(html: wrapHTML(currentEntry.articleHTML)) ArticleWebView(html: wrapHTML(currentEntry.articleHTML), contentHeight: $webViewHeight)
.frame(minHeight: 400) .frame(height: webViewHeight)
} }
Spacer(minLength: 80) Spacer(minLength: 80)

View File

@@ -3,6 +3,7 @@ import WebKit
struct ArticleWebView: UIViewRepresentable { struct ArticleWebView: UIViewRepresentable {
let html: String let html: String
@Binding var contentHeight: CGFloat
func makeUIView(context: Context) -> WKWebView { func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration() let config = WKWebViewConfiguration()
@@ -18,7 +19,11 @@ struct ArticleWebView: UIViewRepresentable {
} }
func updateUIView(_ webView: WKWebView, context: Context) { func updateUIView(_ webView: WKWebView, context: Context) {
webView.loadHTMLString(html, baseURL: nil) if context.coordinator.lastHTML != html {
context.coordinator.lastHTML = html
context.coordinator.heightBinding = $contentHeight
webView.loadHTMLString(html, baseURL: nil)
}
} }
func makeCoordinator() -> Coordinator { func makeCoordinator() -> Coordinator {
@@ -26,12 +31,14 @@ struct ArticleWebView: UIViewRepresentable {
} }
class Coordinator: NSObject, WKNavigationDelegate { class Coordinator: NSObject, WKNavigationDelegate {
var lastHTML: String?
var heightBinding: Binding<CGFloat>?
func webView( func webView(
_ webView: WKWebView, _ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction, decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) { ) {
// Allow initial HTML load, open external links in Safari
if navigationAction.navigationType == .linkActivated, if navigationAction.navigationType == .linkActivated,
let url = navigationAction.request.url { let url = navigationAction.request.url {
UIApplication.shared.open(url) UIApplication.shared.open(url)
@@ -42,20 +49,16 @@ struct ArticleWebView: UIViewRepresentable {
} }
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// Resize to fit content // Measure content height after render
webView.evaluateJavaScript("document.body.scrollHeight") { result, _ in DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if let height = result as? CGFloat { webView.evaluateJavaScript("document.body.scrollHeight") { [weak self] result, _ in
webView.frame.size.height = height if let height = result as? CGFloat, height > 0 {
webView.invalidateIntrinsicContentSize() DispatchQueue.main.async {
self?.heightBinding?.wrappedValue = height
}
}
} }
} }
} }
} }
} }
// Make WKWebView report intrinsic content size
extension WKWebView {
override open var intrinsicContentSize: CGSize {
CGSize(width: UIView.noIntrinsicMetric, height: scrollView.contentSize.height)
}
}

View File

@@ -95,7 +95,7 @@ struct EntryCardView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Thumbnail // Thumbnail only show if available
if let thumbURL = entry.thumbnailURL { if let thumbURL = entry.thumbnailURL {
AsyncImage(url: thumbURL) { phase in AsyncImage(url: thumbURL) { phase in
switch phase { switch phase {
@@ -105,19 +105,10 @@ struct EntryCardView: View {
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.frame(height: 180) .frame(height: 180)
.clipped() .clipped()
case .failure: default:
cardPlaceholder EmptyView()
case .empty:
ProgressView()
.frame(maxWidth: .infinity)
.frame(height: 180)
.background(Color.accentWarm.opacity(0.06))
@unknown default:
cardPlaceholder
} }
} }
} else {
cardPlaceholder
} }
// Content // Content
@@ -182,16 +173,6 @@ struct EntryCardView: View {
} }
} }
private var cardPlaceholder: some View {
HStack(spacing: 8) {
Image(systemName: "newspaper.fill")
.font(.title2)
.foregroundStyle(Color.accentWarm.opacity(0.3))
}
.frame(maxWidth: .infinity)
.frame(height: 80)
.background(Color.accentWarm.opacity(0.06))
}
} }
// MARK: - List Row View // MARK: - List Row View

View File

@@ -17,9 +17,9 @@ struct ReaderTabView: View {
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: 0.2)) {
selectedSubTab = index selectedSubTab = index
switch index { switch index {
case 0: vm.currentFilter = .unread case 0: vm.applyFilter(.unread)
case 1: vm.currentFilter = .starred case 1: vm.applyFilter(.starred)
case 2: vm.currentFilter = .all case 2: vm.applyFilter(.all)
default: break default: break
} }
} }
@@ -59,10 +59,10 @@ struct ReaderTabView: View {
feedFilterChip("All", isSelected: isAllSelected) { feedFilterChip("All", isSelected: isAllSelected) {
let tab = selectedSubTab let tab = selectedSubTab
switch tab { switch tab {
case 0: vm.currentFilter = .unread case 0: vm.applyFilter(.unread)
case 1: vm.currentFilter = .starred case 1: vm.applyFilter(.starred)
case 2: vm.currentFilter = .all case 2: vm.applyFilter(.all)
default: vm.currentFilter = .unread default: vm.applyFilter(.unread)
} }
} }
@@ -73,7 +73,7 @@ struct ReaderTabView: View {
count: selectedSubTab == 0 ? count : nil, count: selectedSubTab == 0 ? count : nil,
isSelected: vm.currentFilter == .feed(feed.id) isSelected: vm.currentFilter == .feed(feed.id)
) { ) {
vm.currentFilter = .feed(feed.id) vm.applyFilter(.feed(feed.id))
} }
} }
} }