From 798ba17a93158d227807d0abaddf4b1bf8b47e35 Mon Sep 17 00:00:00 2001 From: Yusuf Suleman Date: Fri, 3 Apr 2026 19:16:57 -0500 Subject: [PATCH] fix: resolve Reader freezing, article layout loop, and feedback issues - 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) --- .../Features/Feedback/FeedbackView.swift | 3 +- .../Features/Reader/Models/ReaderModels.swift | 29 ++++++++++------- .../Reader/ViewModels/ReaderViewModel.swift | 8 +++-- .../Features/Reader/Views/ArticleView.swift | 5 +-- .../Reader/Views/ArticleWebView.swift | 31 ++++++++++--------- .../Features/Reader/Views/EntryListView.swift | 25 ++------------- .../Features/Reader/Views/ReaderTabView.swift | 16 +++++----- 7 files changed, 56 insertions(+), 61 deletions(-) diff --git a/ios/Platform/Platform/Features/Feedback/FeedbackView.swift b/ios/Platform/Platform/Features/Feedback/FeedbackView.swift index 091fd69..54c16cc 100644 --- a/ios/Platform/Platform/Features/Feedback/FeedbackView.swift +++ b/ios/Platform/Platform/Features/Feedback/FeedbackView.swift @@ -143,10 +143,11 @@ struct FeedbackSheet: View { } private func sendFeedback() { + guard !isSending else { return } isSending = true error = nil - Task { + Task { @MainActor in do { var body: [String: Any] = ["text": text.trimmingCharacters(in: .whitespaces), "source": "ios"] if let imgData = photoData { diff --git a/ios/Platform/Platform/Features/Reader/Models/ReaderModels.swift b/ios/Platform/Platform/Features/Reader/Models/ReaderModels.swift index 84abd17..699d877 100644 --- a/ios/Platform/Platform/Features/Reader/Models/ReaderModels.swift +++ b/ios/Platform/Platform/Features/Reader/Models/ReaderModels.swift @@ -49,20 +49,25 @@ struct ReaderEntry: Codable, Identifiable, Hashable { } var thumbnailURL: URL? { - // Extract first from content - let html = content ?? fullContent ?? "" - guard let range = html.range(of: #"]+src=["\']([^"\']+)["\']"#, options: .regularExpression) else { + ReaderEntry.extractThumbnail(from: content ?? fullContent ?? "") + } + + private static let imgRegex = try! NSRegularExpression( + pattern: #"]+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 } - let match = String(html[range]) - 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: "") + let src = String(searchRange[srcRange]) return URL(string: src) } diff --git a/ios/Platform/Platform/Features/Reader/ViewModels/ReaderViewModel.swift b/ios/Platform/Platform/Features/Reader/ViewModels/ReaderViewModel.swift index 669935f..0da0460 100644 --- a/ios/Platform/Platform/Features/Reader/ViewModels/ReaderViewModel.swift +++ b/ios/Platform/Platform/Features/Reader/ViewModels/ReaderViewModel.swift @@ -1,5 +1,6 @@ import Foundation +@MainActor @Observable final class ReaderViewModel { // MARK: - State @@ -24,8 +25,11 @@ final class ReaderViewModel { case category(Int) } - var currentFilter: ReaderFilter = .unread { - didSet { Task { await loadEntries(reset: true) } } + var currentFilter: ReaderFilter = .unread + + func applyFilter(_ filter: ReaderFilter) { + currentFilter = filter + Task { await loadEntries(reset: true) } } // MARK: - Private diff --git a/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift b/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift index 13c1890..8de0cc2 100644 --- a/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift @@ -6,6 +6,7 @@ struct ArticleView: View { @State private var currentEntry: ReaderEntry @State private var isFetchingFull = false @State private var savedToBrain = false + @State private var webViewHeight: CGFloat = 400 init(entry: ReaderEntry, vm: ReaderViewModel) { self.entry = entry @@ -81,8 +82,8 @@ struct ArticleView: View { .padding(.vertical, 40) } } else { - ArticleWebView(html: wrapHTML(currentEntry.articleHTML)) - .frame(minHeight: 400) + ArticleWebView(html: wrapHTML(currentEntry.articleHTML), contentHeight: $webViewHeight) + .frame(height: webViewHeight) } Spacer(minLength: 80) diff --git a/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift b/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift index d8b65f6..9dbcf1f 100644 --- a/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift @@ -3,6 +3,7 @@ import WebKit struct ArticleWebView: UIViewRepresentable { let html: String + @Binding var contentHeight: CGFloat func makeUIView(context: Context) -> WKWebView { let config = WKWebViewConfiguration() @@ -18,7 +19,11 @@ struct ArticleWebView: UIViewRepresentable { } 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 { @@ -26,12 +31,14 @@ struct ArticleWebView: UIViewRepresentable { } class Coordinator: NSObject, WKNavigationDelegate { + var lastHTML: String? + var heightBinding: Binding? + func webView( _ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void ) { - // Allow initial HTML load, open external links in Safari if navigationAction.navigationType == .linkActivated, let url = navigationAction.request.url { UIApplication.shared.open(url) @@ -42,20 +49,16 @@ struct ArticleWebView: UIViewRepresentable { } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - // Resize to fit content - webView.evaluateJavaScript("document.body.scrollHeight") { result, _ in - if let height = result as? CGFloat { - webView.frame.size.height = height - webView.invalidateIntrinsicContentSize() + // Measure content height after render + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + webView.evaluateJavaScript("document.body.scrollHeight") { [weak self] result, _ in + if let height = result as? CGFloat, height > 0 { + 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) - } -} diff --git a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift index 09e18a5..3606b5a 100644 --- a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift @@ -95,7 +95,7 @@ struct EntryCardView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - // Thumbnail + // Thumbnail — only show if available if let thumbURL = entry.thumbnailURL { AsyncImage(url: thumbURL) { phase in switch phase { @@ -105,19 +105,10 @@ struct EntryCardView: View { .aspectRatio(contentMode: .fill) .frame(height: 180) .clipped() - case .failure: - cardPlaceholder - case .empty: - ProgressView() - .frame(maxWidth: .infinity) - .frame(height: 180) - .background(Color.accentWarm.opacity(0.06)) - @unknown default: - cardPlaceholder + default: + EmptyView() } } - } else { - cardPlaceholder } // 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 diff --git a/ios/Platform/Platform/Features/Reader/Views/ReaderTabView.swift b/ios/Platform/Platform/Features/Reader/Views/ReaderTabView.swift index ed0dcae..84883d9 100644 --- a/ios/Platform/Platform/Features/Reader/Views/ReaderTabView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/ReaderTabView.swift @@ -17,9 +17,9 @@ struct ReaderTabView: View { withAnimation(.easeInOut(duration: 0.2)) { selectedSubTab = index switch index { - case 0: vm.currentFilter = .unread - case 1: vm.currentFilter = .starred - case 2: vm.currentFilter = .all + case 0: vm.applyFilter(.unread) + case 1: vm.applyFilter(.starred) + case 2: vm.applyFilter(.all) default: break } } @@ -59,10 +59,10 @@ struct ReaderTabView: View { feedFilterChip("All", isSelected: isAllSelected) { let tab = selectedSubTab switch tab { - case 0: vm.currentFilter = .unread - case 1: vm.currentFilter = .starred - case 2: vm.currentFilter = .all - default: vm.currentFilter = .unread + case 0: vm.applyFilter(.unread) + case 1: vm.applyFilter(.starred) + case 2: vm.applyFilter(.all) + default: vm.applyFilter(.unread) } } @@ -73,7 +73,7 @@ struct ReaderTabView: View { count: selectedSubTab == 0 ? count : nil, isSelected: vm.currentFilter == .feed(feed.id) ) { - vm.currentFilter = .feed(feed.id) + vm.applyFilter(.feed(feed.id)) } } }