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
6 changes: 6 additions & 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,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" = "遮盖标题";
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
9 changes: 9 additions & 0 deletions app/Shared/Storage/PreferencesStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions app/Shared/Views/HotTopicListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -49,6 +50,7 @@ struct HotTopicListInnerView: View {
}
}
}.mayGroupedListStyle()
.prefetchTopicDetails(for: $dataSource.items, enabled: prefs.topicDetailsPrefetch && prefs.topicDetailsCacheFirst)
}
}
.refreshable(dataSource: dataSource)
Expand Down
27 changes: 27 additions & 0 deletions app/Shared/Views/PreferencesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
158 changes: 158 additions & 0 deletions app/Shared/Views/TopicDetailsPrefetcher.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>]()

/// 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<String>()
// 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<Void, Never>?

func body(content: Content) -> some View {
content
.environment(\.reportTopicVisible) { id in
guard enabled else { return }
if !visibleIDs.contains(id) { visibleIDs.append(id) }
scheduleAfterIdle()
}
Comment on lines +67 to +71
.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<TopicDetailsResponse, LogicError> = 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 }
}
}
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)
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
1 change: 1 addition & 0 deletions app/Shared/Views/TopicListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions app/Shared/Views/TopicRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) }
}
}

Expand Down
2 changes: 2 additions & 0 deletions app/Shared/Views/TopicSearchView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class TopicSearchModel: SearchModel<PagingDataSource<TopicSearchResponse, Topic>

struct TopicSearchView: View {
@ObservedObject var dataSource: TopicSearchModel.DataSource
@StateObject private var prefs = PreferencesStorage.shared

var body: some View {
if dataSource.notLoaded {
Expand All @@ -55,6 +56,7 @@ struct TopicSearchView: View {
}
}
.mayGroupedListStyle()
.prefetchTopicDetails(for: $dataSource.items, enabled: prefs.topicDetailsPrefetch && prefs.topicDetailsCacheFirst)
}
}
}
Loading