Watch: review fixes for action errors that vanished, feed refreshes dropped or overwritten around a change, a fresh thread screen per route, errors cleared on sign out, notification actions only while paired, cache writes off the main thread and capped at 30 threads, Ask before acting and Load earlier races, two-letter search, C# headings, and strictly encoded query values

This commit is contained in:
gib committed 2026-11-02 15:29:15 -05:00
1 parent 99b300855e
commit 25be69fa30
14 files changed
+591 -94

No files matched your search

+107 -25
View File
@@ -51,18 +51,31 @@ final class AppModel {
var snoozing: FeedItem?
/// Where the feed and thread details are saved. Only used while paired, never in demo mode.
private let cache: FeedCache?
/// Answers every call in place of the mode's gateway, for tests.
private let gateway: (any GatewayAPI)?
private var apnsToken: String?
private var refreshing = false
/// The feed refresh in flight. A refresh asked for meanwhile waits on it, and it runs once more.
private var refreshTask: Task<Void, Never>?
/// Bumped when a change to threads starts, when a refresh is asked for during another, and on
/// reset. A feed answer to a request sent before the bump is dropped, and the refresh runs again.
private var feedGeneration = 0
/// What was last written to disk, so an unchanged feed or thread is not written again.
private var savedFeed: Feed?
private var savedThread: ThreadDetail?
/// The latest disk write. Writes run off the main actor, one after another.
private(set) var cacheWrite: Task<Void, Never>?
/// The counts the complication was last told about.
private var sharedSummary: Summary?
/// With a `cache` and no `feed`, a paired model starts on the saved feed.
init(
mode: Mode, feed: Feed? = nil, allRecent: [FeedItem]? = nil, snoozed: [FeedItem]? = nil,
projects: ProjectsResponse? = nil, path: [Route] = [], cache: FeedCache? = nil
projects: ProjectsResponse? = nil, path: [Route] = [], cache: FeedCache? = nil,
gateway: (any GatewayAPI)? = nil
) {
self.mode = mode
self.cache = cache
self.gateway = gateway
self.allRecent = allRecent
self.snoozed = snoozed
self.projects = projects
@@ -115,7 +128,8 @@ final class AppModel {
private var pairedCache: FeedCache? { isPaired ? cache : nil }
var api: any GatewayAPI {
switch mode {
if let gateway { return gateway }
return switch mode {
case let .paired(credentials):
GatewayClient(baseURL: credentials.gatewayURL, token: credentials.token)
case .demo:
@@ -146,20 +160,32 @@ final class AppModel {
/// gateway URL is kept.
func signOut() {
if isPaired { DeviceStore.clearToken() }
cache?.clear()
// After any write still queued, so none puts a file back.
if let cache { write { cache.clear() } }
mode = .unpaired
resetContent()
}
/// Forgets what the screens loaded, for a new pairing, demo mode or signing out.
/// Forgets what the screens loaded, for a new pairing, demo mode or signing out. A refresh still
/// in flight drops its answer.
private func resetContent() {
feedGeneration += 1
feed = nil
feedError = nil
feedIsCached = false
settling = []
settleErrors = [:]
settlingAll = false
settleAllError = nil
allRecent = nil
allRecentError = nil
snoozed = nil
snoozedError = nil
snoozing = nil
projects = nil
projectsError = nil
savedFeed = nil
savedThread = nil
path = []
}
@@ -189,22 +215,55 @@ final class AppModel {
// MARK: - Feed
/// Loads the feed. Asked for while a refresh is in flight, it waits for that one, which then
/// runs again, since its answer may predate the caller's change.
func refreshFeed() async {
guard mode != .unpaired, !refreshing else { return }
refreshing = true
defer { refreshing = false }
do {
let feed = try await api.feed()
self.feed = feed
feedError = nil
feedIsCached = false
share(Summary(feed))
pairedCache?.save(feed)
} catch {
// A cancelled refresh changes nothing; the next one answers.
guard let message = message(for: error) else { return }
feedError = message
feedIsCached = false
guard mode != .unpaired else { return }
if let refreshTask {
feedGeneration += 1
await refreshTask.value
return
}
let task = Task { await runRefresh() }
refreshTask = task
await task.value
}
/// Call as a change to threads goes out, such as a settle or an answer. A feed answer to a
/// request sent before it is dropped, so a poll cannot put back what the change removed.
func beginChange() {
feedGeneration += 1
}
/// Fetches the feed until an answer arrives with no change, reset or new request since it was
/// sent, then applies it. An answer from before signing out or switching mode is never applied.
private func runRefresh() async {
defer { refreshTask = nil }
while mode != .unpaired {
let generation = feedGeneration
let result: Result<Feed, any Error>
do {
result = .success(try await api.feed())
} catch {
result = .failure(error)
}
guard generation == feedGeneration else { continue }
switch result {
case let .success(feed):
self.feed = feed
feedError = nil
feedIsCached = false
let listed = Set((feed.needsYou + feed.working + feed.recent + (allRecent ?? [])).map(\.id))
settleErrors = settleErrors.filter { listed.contains($0.key) }
share(Summary(feed))
saveFeed(feed)
case let .failure(error):
// A cancelled refresh changes nothing; the next one answers.
guard let message = message(for: error) else { return }
feedError = message
feedIsCached = false
}
return
}
}
@@ -213,15 +272,34 @@ final class AppModel {
pairedCache?.thread(hostId: hostId, threadId: threadId)
}
/// Saves a thread's detail for the next time it opens.
/// Saves a thread's detail for the next time it opens, unless it is what was saved last.
func saveThread(_ detail: ThreadDetail) {
pairedCache?.save(detail)
guard let cache = pairedCache, detail != savedThread else { return }
savedThread = detail
write { cache.save(detail) }
}
/// Saves the feed for the next launch, unless it is what was saved last.
private func saveFeed(_ feed: Feed) {
guard let cache = pairedCache, feed != savedFeed else { return }
savedFeed = feed
write { cache.save(feed) }
}
/// Runs a disk write off the main actor, after the writes before it.
private func write(_ body: @escaping @Sendable () -> Void) {
let previous = cacheWrite
cacheWrite = Task.detached(priority: .utility) {
await previous?.value
body()
}
}
/// Settles items concurrently. Each one leaves the feed at once and comes back with its error
/// if its call fails. Refreshes once every call has answered.
func settle(_ items: [FeedItem]) async {
let ids = Set(items.map(\.id))
beginChange()
settling.formUnion(ids)
for id in ids { settleErrors[id] = nil }
let api = api
@@ -270,6 +348,7 @@ final class AppModel {
/// Settles every completed thread in Recent with one call, including those past the feed's first
/// 10. They leave at once. Then the feed, and the full list when it was loaded, refresh.
func settleAllRecent() async {
beginChange()
settlingAll = true
settleAllError = nil
do {
@@ -291,6 +370,7 @@ final class AppModel {
/// Snoozes a finished thread until `until`. Once the gateway agrees, it leaves Recent, the picker
/// closes, and its screen does too if it is on top. Then the feed refreshes.
func snooze(_ item: FeedItem, until: Date) async throws {
beginChange()
try await api.snooze(hostId: item.hostId, threadId: item.threadId, until: until)
snoozing = nil
removeFromRecent(1) { $0.id == item.id }
@@ -311,17 +391,19 @@ final class AppModel {
}
/// Puts a snoozed thread back in Recent. It leaves the Snoozed list at once, then the feed and
/// the list refresh. On failure the list shows why.
/// the list refresh. On failure the list shows why, once the reload has put the thread back.
func unsnooze(_ item: FeedItem) async {
beginChange()
snoozed?.removeAll { $0.id == item.id }
var failure: String?
do {
try await api.unsnooze(hostId: item.hostId, threadId: item.threadId)
snoozedError = nil
} catch {
snoozedError = message(for: error)
failure = message(for: error)
}
await refreshFeed()
await loadSnoozed()
if let failure { snoozedError = failure }
}
/// Drops settled threads from the feed and the full list until the next refresh confirms it.