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() {
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 {

View File

@@ -49,20 +49,25 @@ struct ReaderEntry: Codable, Identifiable, Hashable {
}
var thumbnailURL: URL? {
// Extract first <img src="..."> from content
let html = content ?? fullContent ?? ""
guard let range = html.range(of: #"<img[^>]+src=["\']([^"\']+)["\']"#, options: .regularExpression) else {
ReaderEntry.extractThumbnail(from: content ?? fullContent ?? "")
}
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
}
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)
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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<CGFloat>?
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)
}
}

View File

@@ -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

View File

@@ -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))
}
}
}