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