Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 +877 to +880
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