Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/Shared/Localization/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@
"Unnamed Folder" = "未命名的收藏夹";
"Add to New Folder" = "添加到新收藏夹";
"Resume Reading Progress" = "恢复阅读进度";
"Open Cached Topics Instantly" = "秒开已缓存的帖子";
"Refresh Button" = "刷新按钮";
"Blocked Topics Style" = "屏蔽话题样式";
"Redact Subject" = "遮盖标题";
Expand Down
18 changes: 18 additions & 0 deletions app/Shared/Models/PagingDataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,24 @@ class PagingDataSource<Res: SwiftProtobuf.Message, Item>: 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,
Expand Down
1 change: 1 addition & 0 deletions app/Shared/Storage/PreferencesStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions app/Shared/Views/PreferencesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
67 changes: 66 additions & 1 deletion app/Shared/Views/TopicDetailsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -832,14 +832,79 @@ struct TopicDetailsView: View {
}
}
.mayGroupedListStyle()
.onAppear { dataSource.initialLoad() }
.onAppear { onInitialAppear() }
.onChange(of: dataSource.latestResponse) { updateTopicOnNewResponse(response: $1) }
}

var maxFloor: Int {
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
}
Comment on lines +858 to +862

// 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)
Comment on lines +872 to +875
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 {
Expand Down