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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,20 +19,26 @@ struct ArticleWebView: UIViewRepresentable {
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
if context.coordinator.lastHTML != html {
|
||||
context.coordinator.lastHTML = html
|
||||
context.coordinator.heightBinding = $contentHeight
|
||||
webView.loadHTMLString(html, baseURL: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user