Skip to content

Commit fd7b9bf

Browse files
Implement AsyncRefresh for thread view
This implements the API calls to interact with the new AsyncRefresh feature and creates an AsyncRefreshViewModel that any view needing this functionality can use to manage the timing of refresh and reload calls and the display of an indicator UI when new results may be available. Contributes to IOS-575
1 parent 57d31bd commit fd7b9bf

File tree

13 files changed

+609
-169
lines changed

13 files changed

+609
-169
lines changed

Mastodon.xcodeproj/project.pbxproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,6 +1126,7 @@
11261126
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
11271127
membershipExceptions = (
11281128
AccountInfo.swift,
1129+
AsyncRefreshViewModel.swift,
11291130
"Common Components/AlignmentGuides.swift",
11301131
"Common Components/Common Actions on Statuses and Accounts/ContentConcealViewModel.swift",
11311132
"Common Components/Common Actions on Statuses and Accounts/MastodonPostMenuAction.swift",
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// Copyright © 2025 Mastodon gGmbH. All rights reserved.
2+
3+
import SwiftUI
4+
import MastodonSDK
5+
import MastodonCore
6+
7+
/// AsyncRefreshViewModel
8+
/// For use with the AsyncRefresh API, this view model manages polling for async updates at the server-requested rate and reporting them in a way that is easy for SwfitUI views to consume.
9+
/// You can create an AsyncRefreshViewModel for any view, whether you expect to receive an AsyncRefresh header or not, because it will remain in the default .noAsyncRefresh state (reporting refreshButtonState of .hidden) until you call beginPollingForResults(asyncRefresh: authenticationBox:). You can call beginPollingForResults(asyncRefresh: authenticationBox:) multiple times. Calls with the same asyncRefresh id will be ignored, a call with a new one will reset the model to poll the new id.
10+
/// Before manually refreshing from the original endpoint that returned the AsyncRefreshAvailable, always call willRefreshFromOriginalEndpoint() and do not do the refresh if this returns false (it will always return true if beginPollingForResults has not been called). This will prevent exceeding the server-requested rate.
11+
/// You must then call didRefreshFromOriginalEndpoint() to resume polling for new results and update the button state.
12+
13+
@Observable public class AsyncRefreshViewModel {
14+
15+
public enum RefreshButtonState {
16+
case hidden
17+
case newResultsExpected
18+
case fetching
19+
}
20+
public private(set) var refreshButtonState: RefreshButtonState = .hidden
21+
private var publishTimer: Timer?
22+
private var publishInterval: TimeInterval?
23+
24+
private enum AsyncRefreshState {
25+
case noAsyncRefresh
26+
case running(resultCount: Int?)
27+
case finished(resultCount: Int?)
28+
case error(Error)
29+
30+
var expectedResultCount: Int? {
31+
switch self {
32+
case .noAsyncRefresh:
33+
return nil
34+
case .running(let resultCount), .finished(let resultCount):
35+
return resultCount
36+
case .error(_):
37+
return nil
38+
}
39+
}
40+
}
41+
private var state: AsyncRefreshState = .noAsyncRefresh
42+
private var pollingTimer: Timer?
43+
private var pollingInterval: TimeInterval?
44+
45+
private var asyncRefreshId: String?
46+
private var authBox: MastodonAuthenticationBox?
47+
48+
private struct ManualRefresh {
49+
let timestamp: Date
50+
let expectedResultCount: Int?
51+
}
52+
private var lastManualRefresh: ManualRefresh?
53+
54+
public func beginPollingForResults(_ asyncRefresh: Mastodon.Response.AsyncRefreshAvailable, withSecondsBetweenButtonUpdate publishInterval: Double, authenticationBox: MastodonAuthenticationBox) {
55+
guard asyncRefreshId != asyncRefresh.id else { return }
56+
if asyncRefreshId != nil {
57+
resetForNewAsyncRefresh()
58+
}
59+
60+
authBox = authenticationBox
61+
pollingInterval = TimeInterval(asyncRefresh.retryInterval)
62+
self.publishInterval = TimeInterval(publishInterval)
63+
asyncRefreshId = asyncRefresh.id
64+
state = .running(resultCount: asyncRefresh.resultCount)
65+
lastManualRefresh = ManualRefresh(timestamp: .now, expectedResultCount: asyncRefresh.resultCount)
66+
resetPollingTimer()
67+
resetPublishingTimer()
68+
}
69+
70+
private func resetForNewAsyncRefresh() {
71+
state = .noAsyncRefresh
72+
asyncRefreshId = nil
73+
pollingInterval = nil
74+
publishInterval = nil
75+
lastManualRefresh = nil
76+
pollingTimer?.invalidate()
77+
pollingTimer = nil
78+
publishTimer?.invalidate()
79+
publishTimer = nil
80+
refreshButtonState = .hidden
81+
}
82+
83+
private var isOkToRefresh: Bool {
84+
switch state {
85+
case .noAsyncRefresh:
86+
return true
87+
case .running, .finished:
88+
guard let lastManualRefresh, let pollingInterval else { return true }
89+
return Date.now.timeIntervalSince(lastManualRefresh.timestamp) > pollingInterval
90+
case .error:
91+
return false
92+
}
93+
}
94+
95+
public func willRefreshFromOriginalEndpoint() -> Bool {
96+
guard isOkToRefresh else { return false }
97+
98+
pollingTimer?.invalidate()
99+
publishTimer?.invalidate()
100+
lastManualRefresh = ManualRefresh(timestamp: .now, expectedResultCount: state.expectedResultCount)
101+
switch refreshButtonState {
102+
case .hidden, .fetching:
103+
break
104+
case .newResultsExpected:
105+
refreshButtonState = .fetching
106+
}
107+
return true
108+
}
109+
110+
public func didRefreshFromOriginalEndpoint() {
111+
resetPollingTimer()
112+
resetPublishingTimer()
113+
updateButtonState()
114+
}
115+
116+
private func updateButtonState() {
117+
switch state {
118+
case .noAsyncRefresh:
119+
refreshButtonState = .hidden
120+
121+
case .running(let resultCount):
122+
guard let lastManualRefresh, let publishInterval else { return }
123+
let newResultsExpected = (resultCount ?? 0) > (lastManualRefresh.expectedResultCount ?? 0)
124+
let sufficientTimeElapsed = Date.now.timeIntervalSince(lastManualRefresh.timestamp) > publishInterval
125+
refreshButtonState = newResultsExpected && sufficientTimeElapsed ? .newResultsExpected : .hidden
126+
127+
case .finished(let resultCount):
128+
let newResultsExpected = (resultCount ?? 0) > (lastManualRefresh?.expectedResultCount ?? 0)
129+
refreshButtonState = newResultsExpected ? .newResultsExpected : .hidden
130+
131+
case .error:
132+
refreshButtonState = .hidden
133+
}
134+
}
135+
136+
private func resetPublishingTimer() {
137+
publishTimer?.invalidate()
138+
guard let publishInterval else { return }
139+
140+
switch state {
141+
case .noAsyncRefresh, .finished, .error:
142+
return
143+
case .running:
144+
publishTimer = Timer.scheduledTimer(withTimeInterval: publishInterval, repeats: false) { [weak self] _ in
145+
self?.updateButtonState()
146+
self?.resetPublishingTimer()
147+
}
148+
}
149+
}
150+
151+
private func resetPollingTimer() {
152+
153+
pollingTimer?.invalidate()
154+
guard let pollingInterval else { return }
155+
156+
switch state {
157+
case .noAsyncRefresh, .finished, .error:
158+
return
159+
case .running:
160+
pollingTimer = Timer.scheduledTimer(withTimeInterval: pollingInterval, repeats: false) { _ in
161+
Task { [weak self] in
162+
guard let asyncRefreshId = self?.asyncRefreshId, let authBox = self?.authBox else {
163+
return
164+
}
165+
guard self?.isOkToRefresh == true else {
166+
assertionFailure("retried too soon")
167+
self?.resetPollingTimer()
168+
return
169+
}
170+
do {
171+
let result = try await APIService.shared.fetchAsyncRefreshUpdate(forAsyncRefreshID: asyncRefreshId, authenticationBox: authBox)
172+
switch result.status {
173+
case .running:
174+
self?.state = .running(resultCount: result.resultCount)
175+
case .finished:
176+
self?.state = .finished(resultCount: result.resultCount)
177+
self?.updateButtonState()
178+
case ._other(let otherValue):
179+
assertionFailure("unexpected AsyncRefresh state value \(otherValue); will stop")
180+
self?.state = .finished(resultCount: result.resultCount)
181+
self?.updateButtonState()
182+
}
183+
} catch {
184+
#if DEBUG
185+
print("Error fetching async refresh update: \(error)")
186+
#endif
187+
self?.state = .error(error)
188+
self?.updateButtonState()
189+
}
190+
self?.resetPollingTimer()
191+
}
192+
}
193+
}
194+
}
195+
}

0 commit comments

Comments
 (0)