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