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 {