From 2a0b773f62b17d811899b39a3f2ea25519b95505 Mon Sep 17 00:00:00 2001 From: Mufan Qiu Date: Tue, 30 Jun 2026 12:28:24 -0700 Subject: [PATCH 1/3] feat: optionally open cached topics instantly Add an opt-in 'Open Cached Topics Instantly' setting (off by default) under Reading. When enabled, opening a topic reads the on-disk cache first and shows it immediately (no network wait), then silently refreshes the cache in the background so the next visit is up to date. Pull-to-refresh still fetches live content on demand. - PagingDataSource gains `injectCachedResponse` to populate items from an already-fetched response without a network round-trip. - TopicDetailsView orchestrates the two stages: a local-cache read (fast, no network on the Rust side) shows content instantly; on a cache miss it falls back to the normal load. Only applies to a plain first-page browse (the case that has a cache key); jump-to-floor / only-post / author-only are excluded. - Reuses the existing `local_cache` service flag; no proto or Rust changes. --- .../zh-Hans.lproj/Localizable.strings | 1 + app/Shared/Models/PagingDataSource.swift | 18 +++++ app/Shared/Storage/PreferencesStorage.swift | 1 + app/Shared/Views/PreferencesView.swift | 4 ++ app/Shared/Views/TopicDetailsView.swift | 67 ++++++++++++++++++- 5 files changed, 90 insertions(+), 1 deletion(-) diff --git a/app/Shared/Localization/zh-Hans.lproj/Localizable.strings b/app/Shared/Localization/zh-Hans.lproj/Localizable.strings index 51973861..17416404 100644 --- a/app/Shared/Localization/zh-Hans.lproj/Localizable.strings +++ b/app/Shared/Localization/zh-Hans.lproj/Localizable.strings @@ -314,6 +314,7 @@ "Unnamed Folder" = "未命名的收藏夹"; "Add to New Folder" = "添加到新收藏夹"; "Resume Reading Progress" = "恢复阅读进度"; +"Open Cached Topics Instantly" = "秒开已缓存的帖子"; "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..a624b75c 100644 --- a/app/Shared/Storage/PreferencesStorage.swift +++ b/app/Shared/Storage/PreferencesStorage.swift @@ -59,6 +59,7 @@ 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 // MARK: - Debug diff --git a/app/Shared/Views/PreferencesView.swift b/app/Shared/Views/PreferencesView.swift index 7c53692b..0a9a2ce8 100644 --- a/app/Shared/Views/PreferencesView.swift +++ b/app/Shared/Views/PreferencesView.swift @@ -243,6 +243,10 @@ struct PreferencesInnerView: View { Label("Resume Reading Progress", systemImage: "clock.arrow.circlepath") }.disableWithPlusCheck(.resumeProgress) + Toggle(isOn: $pref.topicDetailsCacheFirst) { + Label("Open Cached Topics Instantly", systemImage: "bolt") + } + Toggle(isOn: $pref.hideNotificationToolbarShortcut) { Label("Hide Notification Shortcut", systemImage: "bell.slash") } 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 { From 8c61af57f7f6e5755c36ecb60eb7279f39bc1df2 Mon Sep 17 00:00:00 2001 From: Mufan Qiu Date: Tue, 30 Jun 2026 14:53:33 -0700 Subject: [PATCH 2/3] feat: background prefetch of topic details in lists When 'Open Cached Topics Instantly' is on, optionally prefetch topic details for the topics the user is viewing so opening them is instant via the cache-first path. Opt-in and off by default. Throttling is intentionally conservative to avoid looking like a crawler: - only fires after scrolling stays idle for a tunable interval (no prefetch while flinging through the list), - only the first N currently-visible topics per batch, - a PrefetchGate actor caps concurrency and spaces requests with jitter. Foreground taps never go through the gate and use a higher QoS, so prefetch never delays what the user actually does. Idle delay, batch size, concurrency and interval are all adjustable at runtime from settings. --- .../zh-Hans.lproj/Localizable.strings | 5 + app/Shared/Storage/PreferencesStorage.swift | 8 + app/Shared/Views/HotTopicListView.swift | 2 + app/Shared/Views/PreferencesView.swift | 25 ++- app/Shared/Views/TopicDetailsPrefetcher.swift | 155 ++++++++++++++++++ app/Shared/Views/TopicListView.swift | 1 + app/Shared/Views/TopicRowView.swift | 3 + app/Shared/Views/TopicSearchView.swift | 2 + 8 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 app/Shared/Views/TopicDetailsPrefetcher.swift diff --git a/app/Shared/Localization/zh-Hans.lproj/Localizable.strings b/app/Shared/Localization/zh-Hans.lproj/Localizable.strings index 17416404..367ea548 100644 --- a/app/Shared/Localization/zh-Hans.lproj/Localizable.strings +++ b/app/Shared/Localization/zh-Hans.lproj/Localizable.strings @@ -315,6 +315,11 @@ "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/Storage/PreferencesStorage.swift b/app/Shared/Storage/PreferencesStorage.swift index a624b75c..59cb26e0 100644 --- a/app/Shared/Storage/PreferencesStorage.swift +++ b/app/Shared/Storage/PreferencesStorage.swift @@ -61,6 +61,14 @@ class PreferencesStorage: ObservableObject { @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 @AppStorage("debugResetTips") var debugResetTips = false 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 0a9a2ce8..8a1ccdf8 100644 --- a/app/Shared/Views/PreferencesView.swift +++ b/app/Shared/Views/PreferencesView.swift @@ -243,10 +243,33 @@ struct PreferencesInnerView: View { Label("Resume Reading Progress", systemImage: "clock.arrow.circlepath") }.disableWithPlusCheck(.resumeProgress) - Toggle(isOn: $pref.topicDetailsCacheFirst) { + 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..7e107d32 --- /dev/null +++ b/app/Shared/Views/TopicDetailsPrefetcher.swift @@ -0,0 +1,155 @@ +// +// 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 && !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() } } + + // Plain first-page load; the Rust service writes the cache on success. + // 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.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/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) } } } From 3c1b44e97576e335437fb734a7e6085ce16f0702 Mon Sep 17 00:00:00 2001 From: Mufan Qiu Date: Tue, 14 Jul 2026 12:14:18 -0700 Subject: [PATCH 3/3] fix: keep background prefetch out of browsing history Background prefetch went through the same path as a real visit: it flooded the browsing history and overwrote the unread-replies baseline, making never-opened topics look read (dimmed subject, no new-replies badge). Add TopicDetailsRequest.background: the load still refreshes the cache (its whole point) but skips insert_topic_history. The details prefetcher sets it, and also skips MNGA mock topics. --- app/Shared/Views/TopicDetailsPrefetcher.swift | 13 ++++++++----- logic/service/src/topic.rs | 14 ++++++++++---- protos/Service.proto | 2 ++ 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/app/Shared/Views/TopicDetailsPrefetcher.swift b/app/Shared/Views/TopicDetailsPrefetcher.swift index 7e107d32..7393f56e 100644 --- a/app/Shared/Views/TopicDetailsPrefetcher.swift +++ b/app/Shared/Views/TopicDetailsPrefetcher.swift @@ -99,7 +99,7 @@ private struct TopicDetailsPrefetcherModifier: ViewModifier { let candidates = visibleIDs .filter { id in guard let topic = items.first(where: { $0.id == id }) else { return false } - return !topic.hasShortcutForum && !requestedIDs.contains(id) + return !topic.hasShortcutForum && !id.isMNGAMockID && !requestedIDs.contains(id) } .prefix(batchSize) @@ -113,14 +113,17 @@ private struct TopicDetailsPrefetcherModifier: ViewModifier { await PrefetchGate.shared.acquire(maxConcurrency: maxConcurrency, intervalSeconds: interval) defer { Task { await PrefetchGate.shared.release() } } - // Plain first-page load; the Rust service writes the cache on success. - // 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. + // 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( 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;