diff --git a/app/Shared/Localization/zh-Hans.lproj/Localizable.strings b/app/Shared/Localization/zh-Hans.lproj/Localizable.strings index 51973861..367ea548 100644 --- a/app/Shared/Localization/zh-Hans.lproj/Localizable.strings +++ b/app/Shared/Localization/zh-Hans.lproj/Localizable.strings @@ -314,6 +314,12 @@ "Unnamed Folder" = "未命名的收藏夹"; "Add to New Folder" = "添加到新收藏夹"; "Resume Reading Progress" = "恢复阅读进度"; +"Open Cached Topics Instantly" = "秒开已缓存的帖子"; +"Prefetch Topics in List" = "预加载列表中的帖子"; +"Prefetch Scroll Idle: %.1fs" = "滚动停顿后预加载:%.1f秒"; +"Prefetch Batch Size: %lld" = "每批预加载数量:%lld"; +"Prefetch Concurrency: %lld" = "预加载并发数:%lld"; +"Prefetch Interval: %.1fs" = "预加载间隔:%.1f秒"; "Refresh Button" = "刷新按钮"; "Blocked Topics Style" = "屏蔽话题样式"; "Redact Subject" = "遮盖标题"; diff --git a/app/Shared/Models/PagingDataSource.swift b/app/Shared/Models/PagingDataSource.swift index 2d3528fb..9fcb92a8 100644 --- a/app/Shared/Models/PagingDataSource.swift +++ b/app/Shared/Models/PagingDataSource.swift @@ -202,6 +202,24 @@ class PagingDataSource: ObservableObject { } } + /// Populate the source from an already-fetched response (e.g. a synchronous + /// local-cache read) without issuing a network request, so the content shows + /// instantly. Marks the page as loaded so a subsequent `initialLoad()` is a + /// no-op. Returns whether any item was populated. + @discardableResult + func injectCachedResponse(_ response: Res, page: Int) -> Bool { + let (newItems, newTotalPages) = onResponse(response) + guard !newItems.isEmpty else { return false } + + latestResponse = response + latestError = nil + replaceItems(newItems, page: page) + totalPages = newTotalPages ?? totalPages + loadedPage = page + lastRefreshTime = Date() + return true + } + func reloadLastPage( evenIfNotLoaded: Bool, animated: Bool = true, diff --git a/app/Shared/Storage/PreferencesStorage.swift b/app/Shared/Storage/PreferencesStorage.swift index 115a019f..59cb26e0 100644 --- a/app/Shared/Storage/PreferencesStorage.swift +++ b/app/Shared/Storage/PreferencesStorage.swift @@ -59,6 +59,15 @@ class PreferencesStorage: ObservableObject { @AppStorage("autoOpenInBrowserWhenBannedNew") var autoOpenInBrowserWhenBanned = false @AppStorage("topicDetailsWebApiStrategyNew") var topicDetailsWebApiStrategy = TopicDetailsRequest.WebApiStrategy.secondary @AppStorage("alwaysShareImageAsFile") var alwaysShareImageAsFile = false + @AppStorage("topicDetailsCacheFirst") var topicDetailsCacheFirst = false + + // Background prefetch of topic details. All tunables are adjustable at + // runtime to throttle requests and avoid rate limiting. + @AppStorage("topicDetailsPrefetch") var topicDetailsPrefetch = false + @AppStorage("prefetchScrollIdleSeconds") var prefetchScrollIdleSeconds = 0.8 + @AppStorage("prefetchBatchSize") var prefetchBatchSize = 5 + @AppStorage("prefetchMaxConcurrency") var prefetchMaxConcurrency = 2 + @AppStorage("prefetchIntervalSeconds") var prefetchIntervalSeconds = 1.0 // MARK: - Debug diff --git a/app/Shared/Views/HotTopicListView.swift b/app/Shared/Views/HotTopicListView.swift index ef7b2619..c12c8c11 100644 --- a/app/Shared/Views/HotTopicListView.swift +++ b/app/Shared/Views/HotTopicListView.swift @@ -16,6 +16,7 @@ struct HotTopicListInnerView: View { let range: DateRange @StateObject var dataSource: DataSource + @StateObject private var prefs = PreferencesStorage.shared static func build(forum: Forum, range: DateRange) -> Self { let dataSource = DataSource( @@ -49,6 +50,7 @@ struct HotTopicListInnerView: View { } } }.mayGroupedListStyle() + .prefetchTopicDetails(for: $dataSource.items, enabled: prefs.topicDetailsPrefetch && prefs.topicDetailsCacheFirst) } } .refreshable(dataSource: dataSource) diff --git a/app/Shared/Views/PreferencesView.swift b/app/Shared/Views/PreferencesView.swift index 7c53692b..8a1ccdf8 100644 --- a/app/Shared/Views/PreferencesView.swift +++ b/app/Shared/Views/PreferencesView.swift @@ -243,6 +243,33 @@ struct PreferencesInnerView: View { Label("Resume Reading Progress", systemImage: "clock.arrow.circlepath") }.disableWithPlusCheck(.resumeProgress) + Toggle(isOn: $pref.topicDetailsCacheFirst.animation()) { + Label("Open Cached Topics Instantly", systemImage: "bolt") + } + + if pref.topicDetailsCacheFirst { + Toggle(isOn: $pref.topicDetailsPrefetch.animation()) { + Label("Prefetch Topics in List", systemImage: "square.and.arrow.down.on.square") + } + + if pref.topicDetailsPrefetch { + VStack(alignment: .leading) { + Label("Prefetch Scroll Idle: \(pref.prefetchScrollIdleSeconds, specifier: "%.1f")s", systemImage: "timer") + Slider(value: $pref.prefetchScrollIdleSeconds, in: 0.3 ... 2.0, step: 0.1) + } + Stepper(value: $pref.prefetchBatchSize.animation(), in: 1 ... 10) { + Label("Prefetch Batch Size: \(pref.prefetchBatchSize)", systemImage: "number") + } + Stepper(value: $pref.prefetchMaxConcurrency.animation(), in: 1 ... 4) { + Label("Prefetch Concurrency: \(pref.prefetchMaxConcurrency)", systemImage: "arrow.triangle.branch") + } + VStack(alignment: .leading) { + Label("Prefetch Interval: \(pref.prefetchIntervalSeconds, specifier: "%.1f")s", systemImage: "hourglass") + Slider(value: $pref.prefetchIntervalSeconds, in: 0.2 ... 3.0, step: 0.1) + } + } + } + Toggle(isOn: $pref.hideNotificationToolbarShortcut) { Label("Hide Notification Shortcut", systemImage: "bell.slash") } diff --git a/app/Shared/Views/TopicDetailsPrefetcher.swift b/app/Shared/Views/TopicDetailsPrefetcher.swift new file mode 100644 index 00000000..7393f56e --- /dev/null +++ b/app/Shared/Views/TopicDetailsPrefetcher.swift @@ -0,0 +1,158 @@ +// +// TopicDetailsPrefetcher.swift +// MNGA +// +// Background-prefetches topic details for the topics the user is looking at, +// so opening them is instant via the cache-first path. +// +// Throttling is intentionally conservative to avoid looking like a crawler and +// triggering NGA rate limiting: +// - only fires after scrolling has been idle for a moment (no prefetch while +// flinging through the list), +// - only the first N currently-visible topics per batch, +// - a hard concurrency cap and a randomized delay between requests. +// +// Foreground requests (tapping into a topic) never go through this gate and +// use a higher QoS, so prefetch never slows down what the user actually does. +// + +import SwiftUI + +/// Serializes background prefetch requests: caps concurrency and spaces requests +/// out. Foreground requests do not use this, so they are never throttled. +actor PrefetchGate { + static let shared = PrefetchGate() + + private var inFlight = 0 + private var waiters = [CheckedContinuation]() + + /// Wait until a slot is free under the current concurrency cap, honoring the + /// inter-request delay. `maxConcurrency` and `intervalSeconds` are read live + /// from preferences so changes take effect without a rebuild. + func acquire(maxConcurrency: Int, intervalSeconds: Double) async { + while inFlight >= max(1, maxConcurrency) { + await withCheckedContinuation { waiters.append($0) } + } + inFlight += 1 + + // Space requests out with a randomized delay to break machine-like regularity. + let jitter = Double.random(in: 0.5 ... 1.5) + let delay = max(0, intervalSeconds) * jitter + try? await Task.sleep(for: .seconds(delay)) + } + + func release() { + inFlight = max(0, inFlight - 1) + if !waiters.isEmpty { + waiters.removeFirst().resume() + } + } +} + +private struct TopicDetailsPrefetcherModifier: ViewModifier { + @Binding var items: [Topic] + let enabled: Bool + + @StateObject private var prefs = PreferencesStorage.shared + + // Topics already requested this session, so we never prefetch the same one twice. + @State private var requestedIDs = Set() + // Topics currently on screen, accumulated via row `onAppear`. + @State private var visibleIDs = [String]() + // Debounce task: reset on every visibility change; fires once scrolling stops. + @State private var idleTask: Task? + + func body(content: Content) -> some View { + content + .environment(\.reportTopicVisible) { id in + guard enabled else { return } + if !visibleIDs.contains(id) { visibleIDs.append(id) } + scheduleAfterIdle() + } + .onChange(of: enabled) { _, isOn in if !isOn { cancel() } } + .onDisappear { cancel() } + } + + private func cancel() { + idleTask?.cancel() + idleTask = nil + } + + /// Restart the idle timer; only when it elapses (i.e. scrolling has settled) + /// do we actually kick off prefetching. + private func scheduleAfterIdle() { + idleTask?.cancel() + let idle = max(0.1, prefs.prefetchScrollIdleSeconds) + idleTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(idle)) + guard !Task.isCancelled else { return } + prefetchVisible() + } + } + + @MainActor + private func prefetchVisible() { + guard enabled else { return } + + // Snapshot visibility order, then keep only fresh, cacheable topics. + let batchSize = max(1, prefs.prefetchBatchSize) + let candidates = visibleIDs + .filter { id in + guard let topic = items.first(where: { $0.id == id }) else { return false } + return !topic.hasShortcutForum && !id.isMNGAMockID && !requestedIDs.contains(id) + } + .prefix(batchSize) + + let strategy = prefs.topicDetailsWebApiStrategy + let maxConcurrency = prefs.prefetchMaxConcurrency + let interval = prefs.prefetchIntervalSeconds + + for id in candidates { + requestedIDs.insert(id) + Task.detached(priority: .utility) { + await PrefetchGate.shared.acquire(maxConcurrency: maxConcurrency, intervalSeconds: interval) + defer { Task { await PrefetchGate.shared.release() } } + + // Background first-page load; the Rust service writes the cache on + // success while `background` keeps it out of the browsing history and + // the unread-replies baseline. The response is discarded — the only + // goal is warming the cache. Low QoS so foreground taps are never + // delayed by this. Best-effort: if it fails, opening the topic simply + // falls back to a normal network load. + let request = AsyncRequest.OneOf_Value.topicDetails(.with { + $0.webApiStrategy = strategy + $0.topicID = id + $0.localCache = false + $0.background = true + $0.page = 1 + }) + let _: Result = await logicCallAsync( + request, + requestDispatchQueue: .global(qos: .utility), + errorToastModel: nil, + ) + } + } + } +} + +extension View { + /// Background-prefetch topic details for the given items when `enabled`. + func prefetchTopicDetails(for items: Binding<[Topic]>, enabled: Bool) -> some View { + modifier(TopicDetailsPrefetcherModifier(items: items, enabled: enabled)) + } +} + +// MARK: - Visibility reporting + +private struct ReportTopicVisibleKey: EnvironmentKey { + static let defaultValue: (String) -> Void = { _ in } +} + +extension EnvironmentValues { + /// Closure a topic row calls (via `.onAppear`) to report it became visible. + var reportTopicVisible: (String) -> Void { + get { self[ReportTopicVisibleKey.self] } + set { self[ReportTopicVisibleKey.self] = newValue } + } +} diff --git a/app/Shared/Views/TopicDetailsView.swift b/app/Shared/Views/TopicDetailsView.swift index 4e70a521..6537b381 100644 --- a/app/Shared/Views/TopicDetailsView.swift +++ b/app/Shared/Views/TopicDetailsView.swift @@ -832,7 +832,7 @@ struct TopicDetailsView: View { } } .mayGroupedListStyle() - .onAppear { dataSource.initialLoad() } + .onAppear { onInitialAppear() } .onChange(of: dataSource.latestResponse) { updateTopicOnNewResponse(response: $1) } } @@ -840,6 +840,71 @@ struct TopicDetailsView: View { Int((dataSource.latestResponse?.topic ?? topic).repliesNum) } + /// Whether the cache-first fast path applies: only for a plain first-page + /// browse (the case that has a cache key on the Rust side), and only when the + /// user opted in. Jump-to-floor / only-post / author-only / local mode are + /// excluded because they don't share that cache. + var cacheFirstApplicable: Bool { + prefs.topicDetailsCacheFirst + && !previewMode + && !forceLocalMode + && !mock + && onlyPost.id == nil + && postIdToJump == nil + && floorToJump == nil + && !topic.id.isEmpty + } + + func onInitialAppear() { + guard cacheFirstApplicable else { + dataSource.initialLoad() + return + } + + // 1. Read the local cache first (no network on the Rust side, so this is + // fast) to show content instantly. + let cacheRequest = TopicDetailsRequest.with { + $0.topicID = topic.id + if topic.hasFav { $0.fav = topic.fav } + $0.localCache = true + $0.page = 1 + } + logicCallAsync(.topicDetails(cacheRequest), errorToastModel: nil) { (cached: TopicDetailsResponse) in + // `injectCachedResponse` sets `latestResponse`, which drives + // `updateTopicOnNewResponse` via the existing `.onChange` in `body`. + let shown = dataSource.injectCachedResponse(cached, page: 1) + if shown { + // 2. Silently refresh the cache in the background for next time. The + // response is discarded on purpose so the current view isn't replaced. + refreshCacheInBackground() + } else { + dataSource.initialLoad() + } + } onError: { _ in + // Cache miss (no local cache): fall back to the normal network load, + // which also writes the cache for next time. + dataSource.initialLoad() + } + } + + /// Fire a network request whose only effect is updating the on-disk cache + /// (the Rust service writes the cache on every successful load). The response + /// is intentionally ignored so the visible content stays stable. + func refreshCacheInBackground() { + let request = TopicDetailsRequest.with { + $0.webApiStrategy = prefs.topicDetailsWebApiStrategy + $0.topicID = topic.id + if topic.hasFav { $0.fav = topic.fav } + $0.localCache = false + $0.page = 1 + } + logicCallAsync(.topicDetails(request), errorToastModel: nil) { (_: TopicDetailsResponse) in + // Discard: the cache has been refreshed on the Rust side for next time. + } onError: { _ in + // Silent: stale cache remains usable until a later refresh succeeds. + } + } + func mayScrollToJumpFloor() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { withAnimation { diff --git a/app/Shared/Views/TopicListView.swift b/app/Shared/Views/TopicListView.swift index 0408ba63..c1cbb432 100644 --- a/app/Shared/Views/TopicListView.swift +++ b/app/Shared/Views/TopicListView.swift @@ -333,6 +333,7 @@ struct TopicListView: View { } .refreshable(dataSource: dataSource, refreshAfterIdle: true, triggerRefresh: triggerRefresh) .mayGroupedListStyle() + .prefetchTopicDetails(for: itemBindings, enabled: prefs.topicDetailsPrefetch && prefs.topicDetailsCacheFirst) } var body: some View { diff --git a/app/Shared/Views/TopicRowView.swift b/app/Shared/Views/TopicRowView.swift index 233c5adc..6bacdb3c 100644 --- a/app/Shared/Views/TopicRowView.swift +++ b/app/Shared/Views/TopicRowView.swift @@ -48,6 +48,8 @@ struct TopicRowView: View { let dimmedSubject: Bool let showIndicators: Bool + @Environment(\.reportTopicVisible) private var reportTopicVisible + init(topic: Topic, useTopicPostDate: Bool = false, dimmedSubject: Bool = true, showIndicators: Bool = true) { self.topic = topic self.useTopicPostDate = useTopicPostDate @@ -69,6 +71,7 @@ struct TopicRowView: View { var body: some View { TopicLikeRowInnerView(subjectView: { subject }, num: topic.repliesNum, lastNum: topic.hasRepliesNumLastVisit ? topic.repliesNumLastVisit : nil, names: [topic.authorNameCompat], date: useTopicPostDate ? topic.postDate : topic.lastPostDate) + .onAppear { reportTopicVisible(topic.id) } } } diff --git a/app/Shared/Views/TopicSearchView.swift b/app/Shared/Views/TopicSearchView.swift index 326b43f3..cf8cd580 100644 --- a/app/Shared/Views/TopicSearchView.swift +++ b/app/Shared/Views/TopicSearchView.swift @@ -38,6 +38,7 @@ class TopicSearchModel: SearchModel struct TopicSearchView: View { @ObservedObject var dataSource: TopicSearchModel.DataSource + @StateObject private var prefs = PreferencesStorage.shared var body: some View { if dataSource.notLoaded { @@ -55,6 +56,7 @@ struct TopicSearchView: View { } } .mayGroupedListStyle() + .prefetchTopicDetails(for: $dataSource.items, enabled: prefs.topicDetailsPrefetch && prefs.topicDetailsCacheFirst) } } } diff --git a/logic/service/src/topic.rs b/logic/service/src/topic.rs index 157139b8..c90bf0cb 100644 --- a/logic/service/src/topic.rs +++ b/logic/service/src/topic.rs @@ -595,8 +595,14 @@ pub async fn get_topic_details( return get_local_cache(); } - let save_history = |response: &TopicDetailsResponse| { - insert_topic_history(response.get_topic().to_owned()); // save history + let save_results = |response: &TopicDetailsResponse| { + // Background (prefetch) loads still refresh the cache -- that is their + // whole point -- but must stay out of the browsing history: recording + // them would flood the history list and overwrite the unread-replies + // baseline, making never-opened topics look read. + if !request.get_background() { + insert_topic_history(response.get_topic().to_owned()); + } if let Some(key) = key.as_ref() { let _ = CACHE.insert_msg(key, response); } @@ -604,7 +610,7 @@ pub async fn get_topic_details( if request.is_mock() { let response = fetch_mock(&request).await?; - save_history(&response); + save_results(&response); return Ok(response); } @@ -722,7 +728,7 @@ pub async fn get_topic_details( ..Default::default() }; - save_history(&response); + save_results(&response); Ok(response) } diff --git a/protos/Service.proto b/protos/Service.proto index 73d2dd40..9effc010 100644 --- a/protos/Service.proto +++ b/protos/Service.proto @@ -166,6 +166,8 @@ message TopicDetailsRequest { uint32 page = 2; bool local_cache = 6; // Whether to only request cached version of the topic. WebApiStrategy web_api_strategy = 8; // Whether or in what circumstances to use web API. + bool background = 9; // Background (prefetch) load: still refreshes the cache, + // but stays out of the browsing history. } message TopicDetailsResponse { Topic topic = 1;