|
| 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