Watch: snooze finished threads, name the files a file approval would edit, Settle on finished notifications, an instant launch from the saved feed, plain Loading states, and no more cancelled errors
Gateway: an approval push for a file change says which files it would edit
This commit is contained in:
1 parent
4dc4e57dac
commit
edc4b88990
35 files changed
+1162
-144
No files matched your search
+100
-20
@@ -1,5 +1,6 @@
|
||||
// App-wide state: whether the watch is paired, the navigation path, the latest feed, and the hosts
|
||||
// and projects All threads browses. It also
|
||||
// App-wide state: whether the watch is paired, the navigation path, the latest feed, the hosts
|
||||
// and projects All threads browses, and the thread being snoozed. The last good feed is saved to
|
||||
// disk, so launch shows it at once while the first refresh runs. It also
|
||||
// keeps the complication current: it shares the mode and latest counts through the shared Keychain
|
||||
// group and reloads the complication's timeline when they change.
|
||||
|
||||
@@ -27,6 +28,8 @@ final class AppModel {
|
||||
var path: [Route] = []
|
||||
private(set) var feed: Feed?
|
||||
private(set) var feedError: String?
|
||||
/// True while the feed on screen came from the cache and the first refresh has not answered.
|
||||
private(set) var feedIsCached = false
|
||||
/// Ids of feed items whose settle call is still out. The feed hides them meanwhile.
|
||||
private(set) var settling: Set<String> = []
|
||||
/// Why settling an item failed, by item id. Its row shows the message.
|
||||
@@ -41,20 +44,35 @@ final class AppModel {
|
||||
/// Hosts and their projects for All threads, loaded when its screens appear.
|
||||
private(set) var projects: ProjectsResponse?
|
||||
private(set) var projectsError: String?
|
||||
/// Snoozed threads, for the Snoozed screen, which loads them when it appears.
|
||||
private(set) var snoozed: [FeedItem]?
|
||||
private(set) var snoozedError: String?
|
||||
/// The thread the Snooze until picker is open for.
|
||||
var snoozing: FeedItem?
|
||||
/// Where the feed and thread details are saved. Only used while paired, never in demo mode.
|
||||
private let cache: FeedCache?
|
||||
private var apnsToken: String?
|
||||
private var refreshing = false
|
||||
/// 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, projects: ProjectsResponse? = nil,
|
||||
path: [Route] = []
|
||||
mode: Mode, feed: Feed? = nil, allRecent: [FeedItem]? = nil, snoozed: [FeedItem]? = nil,
|
||||
projects: ProjectsResponse? = nil, path: [Route] = [], cache: FeedCache? = nil
|
||||
) {
|
||||
self.mode = mode
|
||||
self.feed = feed
|
||||
self.cache = cache
|
||||
self.allRecent = allRecent
|
||||
self.snoozed = snoozed
|
||||
self.projects = projects
|
||||
self.path = path
|
||||
if feed == nil, case .paired = mode, let saved = cache?.feed() {
|
||||
self.feed = saved
|
||||
feedIsCached = true
|
||||
} else {
|
||||
self.feed = feed
|
||||
}
|
||||
}
|
||||
|
||||
/// `-demo` starts in demo mode. `-screen new|recent|status|notifications|threads`, `-host <hostId>` or
|
||||
@@ -84,7 +102,7 @@ final class AppModel {
|
||||
let parts = thread.split(separator: "/", maxSplits: 1).map(String.init)
|
||||
if parts.count == 2 { path = [.thread(hostId: parts[0], threadId: parts[1])] }
|
||||
}
|
||||
self.init(mode: mode, path: path)
|
||||
self.init(mode: mode, path: path, cache: .standard)
|
||||
shareMode()
|
||||
}
|
||||
|
||||
@@ -93,6 +111,9 @@ final class AppModel {
|
||||
return false
|
||||
}
|
||||
|
||||
/// The cache while paired. Demo mode keeps off it.
|
||||
private var pairedCache: FeedCache? { isPaired ? cache : nil }
|
||||
|
||||
var api: any GatewayAPI {
|
||||
switch mode {
|
||||
case let .paired(credentials):
|
||||
@@ -112,27 +133,32 @@ final class AppModel {
|
||||
let credentials = Credentials(gatewayURL: gatewayURL, token: response.deviceToken)
|
||||
DeviceStore.save(credentials)
|
||||
mode = .paired(credentials)
|
||||
feed = nil
|
||||
feedError = nil
|
||||
projects = nil
|
||||
path = []
|
||||
resetContent()
|
||||
await enableNotifications()
|
||||
}
|
||||
|
||||
func startDemo() {
|
||||
mode = .demo
|
||||
feed = nil
|
||||
feedError = nil
|
||||
projects = nil
|
||||
path = []
|
||||
resetContent()
|
||||
}
|
||||
|
||||
/// Back to the pairing screen. A real token is forgotten; the gateway URL is kept.
|
||||
/// Back to the pairing screen. A real token and the saved feed and threads are forgotten; the
|
||||
/// gateway URL is kept.
|
||||
func signOut() {
|
||||
if isPaired { DeviceStore.clearToken() }
|
||||
cache?.clear()
|
||||
mode = .unpaired
|
||||
resetContent()
|
||||
}
|
||||
|
||||
/// Forgets what the screens loaded, for a new pairing, demo mode or signing out.
|
||||
private func resetContent() {
|
||||
feed = nil
|
||||
feedError = nil
|
||||
feedIsCached = false
|
||||
allRecent = nil
|
||||
snoozed = nil
|
||||
snoozing = nil
|
||||
projects = nil
|
||||
path = []
|
||||
}
|
||||
@@ -151,8 +177,9 @@ final class AppModel {
|
||||
}
|
||||
|
||||
/// The message to show for a failed call. A 401 sends the app back to pairing and returns nil.
|
||||
/// A cancelled call, as when a screen's `.task` ends or a refresh restarts, returns nil too.
|
||||
func message(for error: any Error) -> String? {
|
||||
if error is CancellationError { return nil }
|
||||
if error is CancellationError || (error as? URLError)?.code == .cancelled { return nil }
|
||||
if let error = error as? GatewayError, error == .unauthorized {
|
||||
signOut()
|
||||
return nil
|
||||
@@ -170,12 +197,27 @@ final class AppModel {
|
||||
let feed = try await api.feed()
|
||||
self.feed = feed
|
||||
feedError = nil
|
||||
feedIsCached = false
|
||||
share(Summary(feed))
|
||||
pairedCache?.save(feed)
|
||||
} catch {
|
||||
feedError = message(for: error)
|
||||
// A cancelled refresh changes nothing; the next one answers.
|
||||
guard let message = message(for: error) else { return }
|
||||
feedError = message
|
||||
feedIsCached = false
|
||||
}
|
||||
}
|
||||
|
||||
/// The detail saved when the thread last loaded, to show while it loads again. Nil in demo mode.
|
||||
func cachedThread(hostId: String, threadId: String) -> ThreadDetail? {
|
||||
pairedCache?.thread(hostId: hostId, threadId: threadId)
|
||||
}
|
||||
|
||||
/// Saves a thread's detail for the next time it opens.
|
||||
func saveThread(_ detail: ThreadDetail) {
|
||||
pairedCache?.save(detail)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -221,7 +263,7 @@ final class AppModel {
|
||||
allRecent = try await api.recent().items
|
||||
allRecentError = nil
|
||||
} catch {
|
||||
allRecentError = message(for: error)
|
||||
if let message = message(for: error) { allRecentError = message }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +286,44 @@ final class AppModel {
|
||||
if allRecent != nil { await loadAllRecent() }
|
||||
}
|
||||
|
||||
// MARK: - Snooze
|
||||
|
||||
/// 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 {
|
||||
try await api.snooze(hostId: item.hostId, threadId: item.threadId, until: until)
|
||||
snoozing = nil
|
||||
removeFromRecent(1) { $0.id == item.id }
|
||||
feed?.snoozedTotal += 1
|
||||
if path.last == item.route { path.removeLast() }
|
||||
await refreshFeed()
|
||||
}
|
||||
|
||||
/// `GET /v1/snoozed`, for the Snoozed screen.
|
||||
func loadSnoozed() async {
|
||||
guard mode != .unpaired else { return }
|
||||
do {
|
||||
snoozed = try await api.snoozed().items
|
||||
snoozedError = nil
|
||||
} catch {
|
||||
if let message = message(for: error) { snoozedError = message }
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
func unsnooze(_ item: FeedItem) async {
|
||||
snoozed?.removeAll { $0.id == item.id }
|
||||
do {
|
||||
try await api.unsnooze(hostId: item.hostId, threadId: item.threadId)
|
||||
snoozedError = nil
|
||||
} catch {
|
||||
snoozedError = message(for: error)
|
||||
}
|
||||
await refreshFeed()
|
||||
await loadSnoozed()
|
||||
}
|
||||
|
||||
/// Drops settled threads from the feed and the full list until the next refresh confirms it.
|
||||
private func removeFromRecent(_ count: Int, where settled: (FeedItem) -> Bool) {
|
||||
allRecent?.removeAll(where: settled)
|
||||
@@ -262,7 +342,7 @@ final class AppModel {
|
||||
projects = try await api.projects()
|
||||
projectsError = nil
|
||||
} catch {
|
||||
projectsError = message(for: error)
|
||||
if let message = message(for: error) { projectsError = message }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,6 +423,6 @@ extension AppModel {
|
||||
static func preview(path: [Route] = []) -> AppModel {
|
||||
AppModel(
|
||||
mode: .demo, feed: DemoFixtures.previewFeed(), allRecent: DemoFixtures.previewRecent(),
|
||||
projects: ProjectsResponse(hosts: DemoFixtures(now: .now).projectHosts), path: path)
|
||||
snoozed: DemoFixtures.previewSnoozed(), projects: ProjectsResponse(hosts: DemoFixtures(now: .now).projectHosts), path: path)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user