feat(app): auto-retry every remaining try?-swallowed API call, add repeated-failure banner
Second half of the silent-failure fix - OutlineKit's RetryPolicy and
CachingOutlineAPIClient tracking landed in 512c6d2, this wires the
rest of the app onto it.
Every bare `try? await apiClient.X(...)` that bypasses
CachingOutlineAPIClient's own caching (listPins, listSubscriptions,
listViews, listStars, documentUsers, listUsers, listComments,
currentUser, installationInfo, authInfo, deleteAttachment - the
"pass-through" methods) now goes through RetryPolicy.withRetry first,
so a single transient blip gets absorbed automatically instead of
just returning nil. Calls that were already routed through
CachingOutlineAPIClient's cached-read path (documentInfo,
listDocuments, listCollections, etc.) are left alone - they picked up
retry and repeated-failure tracking for free from the previous commit
and wrapping them again would've just retried twice.
New: APIFailureCenter (Root/) turns CachingOutlineAPIClient's
repeatedFailureSummaries() into a banner - RootView polls it every
30s while signed in (cheap, no network call of its own) and shows
RepeatedFailureBanner for whichever category is currently past the
threshold. No manual "Retry" button - the retries already happened
automatically before the banner ever appears, so the only actions are
Report (opens a prefilled Gitea issue - category, generic error
description, app/OS version, no document content or server URL) and
dismiss, which starts a 15-minute cooldown so a still-flaky operation
doesn't immediately pop the same banner back up.
Not compiler-verified - the Outpost app target has no CLI build path,
only OutlineKit does (92/92 passing as of the previous commit, no
OutlineKit changes here).
This commit is contained in:
@@ -125,7 +125,7 @@ struct SettingsSidebarList: View {
|
|||||||
|
|
||||||
private func refreshOutlineVersion() async {
|
private func refreshOutlineVersion() async {
|
||||||
guard isEffectivelyOnline, let apiClient = session.apiClient else { return }
|
guard isEffectivelyOnline, let apiClient = session.apiClient else { return }
|
||||||
outlineVersion = try? await apiClient.installationInfo().version
|
outlineVersion = try? await RetryPolicy.withRetry({ try await apiClient.installationInfo().version })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1511,7 +1511,7 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
private func refreshProfile() async {
|
private func refreshProfile() async {
|
||||||
guard let apiClient = session.apiClient else { return }
|
guard let apiClient = session.apiClient else { return }
|
||||||
guard let fresh = try? await apiClient.currentUser() else { return }
|
guard let fresh = try? await RetryPolicy.withRetry({ try await apiClient.currentUser() }) else { return }
|
||||||
session.applyUpdatedProfile(fresh)
|
session.applyUpdatedProfile(fresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1543,7 +1543,7 @@ struct SettingsView: View {
|
|||||||
// Best-effort — the new avatar is already live either way, this
|
// Best-effort — the new avatar is already live either way, this
|
||||||
// just stops the old upload from sitting around unreferenced.
|
// just stops the old upload from sitting around unreferenced.
|
||||||
if let previousAttachmentId {
|
if let previousAttachmentId {
|
||||||
try? await apiClient.deleteAttachment(id: previousAttachmentId)
|
try? await RetryPolicy.withRetry({ try await apiClient.deleteAttachment(id: previousAttachmentId) })
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't upload this photo.")
|
avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't upload this photo.")
|
||||||
@@ -1560,7 +1560,7 @@ struct SettingsView: View {
|
|||||||
session.applyUpdatedProfile(updated)
|
session.applyUpdatedProfile(updated)
|
||||||
avatarErrorMessage = nil
|
avatarErrorMessage = nil
|
||||||
if let previousAttachmentId {
|
if let previousAttachmentId {
|
||||||
try? await apiClient.deleteAttachment(id: previousAttachmentId)
|
try? await RetryPolicy.withRetry({ try await apiClient.deleteAttachment(id: previousAttachmentId) })
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't remove this photo.")
|
avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't remove this photo.")
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ struct CollectionDocumentsOutline: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadPins() async {
|
private func loadPins() async {
|
||||||
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collection.id)) else { return }
|
guard let pins = try? await RetryPolicy.withRetry({ try await apiClient.listPins(ListPinsRequest(collectionId: collection.id)) }) else { return }
|
||||||
pinsByDocumentID = Dictionary(uniqueKeysWithValues: pins.map { ($0.documentId, $0) })
|
pinsByDocumentID = Dictionary(uniqueKeysWithValues: pins.map { ($0.documentId, $0) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ struct DocumentCommentsSheet: View {
|
|||||||
}
|
}
|
||||||
.frame(width: 480, height: 560)
|
.frame(width: 480, height: 560)
|
||||||
.task { await load() }
|
.task { await load() }
|
||||||
.task { currentUserId = try? await apiClient.currentUser().id }
|
.task { currentUserId = try? await RetryPolicy.withRetry({ try await apiClient.currentUser().id }) }
|
||||||
.task { composingAnchorText = pendingAnchorText }
|
.task { composingAnchorText = pendingAnchorText }
|
||||||
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
|
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
|
||||||
Button("OK") { actionErrorMessage = nil }
|
Button("OK") { actionErrorMessage = nil }
|
||||||
|
|||||||
@@ -328,9 +328,11 @@ struct DocumentReaderView: View {
|
|||||||
await viewModel.loadInsightsEnabledState()
|
await viewModel.loadInsightsEnabledState()
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
loadedComments = (try? await apiClient.listComments(
|
loadedComments = (try? await RetryPolicy.withRetry({
|
||||||
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
|
try await apiClient.listComments(
|
||||||
)) ?? []
|
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
|
||||||
|
)
|
||||||
|
})) ?? []
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
@@ -387,9 +389,11 @@ struct DocumentReaderView: View {
|
|||||||
pendingAnchorText: pendingCommentAnchorText,
|
pendingAnchorText: pendingCommentAnchorText,
|
||||||
onCommentsChanged: {
|
onCommentsChanged: {
|
||||||
Task {
|
Task {
|
||||||
loadedComments = (try? await apiClient.listComments(
|
loadedComments = (try? await RetryPolicy.withRetry({
|
||||||
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
|
try await apiClient.listComments(
|
||||||
)) ?? loadedComments
|
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
|
||||||
|
)
|
||||||
|
})) ?? loadedComments
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -111,14 +111,18 @@ final class DocumentReaderViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadViewers() async {
|
func loadViewers() async {
|
||||||
guard let views = try? await apiClient.listViews(ListViewsRequest(documentId: documentId)) else { return }
|
// A single blip here used to just leave `viewers` empty forever with
|
||||||
|
// no sign anything went wrong — retry-with-backoff absorbs that;
|
||||||
|
// `try?` still covers the "still failing after retries" case, same
|
||||||
|
// silent-but-harmless fallback as before (an empty viewers list).
|
||||||
|
guard let views = try? await RetryPolicy.withRetry({ try await apiClient.listViews(ListViewsRequest(documentId: documentId)) }) else { return }
|
||||||
viewers = views.filter { $0.lastViewedAt != nil }
|
viewers = views.filter { $0.lastViewedAt != nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadPinAndSubscriptionState() async {
|
func loadPinAndSubscriptionState() async {
|
||||||
// `collectionId: nil` = Home pins. This menu's Pin action is "Pin to
|
// `collectionId: nil` = Home pins. This menu's Pin action is "Pin to
|
||||||
// Home", not "Pin to Collection" — those are distinct on the server.
|
// Home", not "Pin to Collection" — those are distinct on the server.
|
||||||
if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)),
|
if let pins = try? await RetryPolicy.withRetry({ try await apiClient.listPins(ListPinsRequest(collectionId: nil)) }),
|
||||||
let match = pins.first(where: { $0.documentId == documentId }) {
|
let match = pins.first(where: { $0.documentId == documentId }) {
|
||||||
isPinned = true
|
isPinned = true
|
||||||
pinId = match.id
|
pinId = match.id
|
||||||
@@ -127,7 +131,7 @@ final class DocumentReaderViewModel {
|
|||||||
pinId = nil
|
pinId = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if let subscriptions = try? await apiClient.listSubscriptions(ListSubscriptionsRequest(documentId: documentId)),
|
if let subscriptions = try? await RetryPolicy.withRetry({ try await apiClient.listSubscriptions(ListSubscriptionsRequest(documentId: documentId)) }),
|
||||||
let match = subscriptions.first {
|
let match = subscriptions.first {
|
||||||
isSubscribed = true
|
isSubscribed = true
|
||||||
subscriptionId = match.id
|
subscriptionId = match.id
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ struct DocumentShareSheet: View {
|
|||||||
private func loadMembers() async {
|
private func loadMembers() async {
|
||||||
isLoadingMembers = true
|
isLoadingMembers = true
|
||||||
defer { isLoadingMembers = false }
|
defer { isLoadingMembers = false }
|
||||||
members = (try? await apiClient.documentUsers(ListDocumentUsersRequest(id: documentId))) ?? []
|
members = (try? await RetryPolicy.withRetry({ try await apiClient.documentUsers(ListDocumentUsersRequest(id: documentId)) })) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
private func searchUsers(_ query: String) async {
|
private func searchUsers(_ query: String) async {
|
||||||
@@ -381,7 +381,7 @@ struct DocumentShareSheet: View {
|
|||||||
}
|
}
|
||||||
isSearchingUsers = true
|
isSearchingUsers = true
|
||||||
defer { isSearchingUsers = false }
|
defer { isSearchingUsers = false }
|
||||||
userSearchResults = (try? await apiClient.listUsers(ListUsersRequest(query: trimmed))) ?? []
|
userSearchResults = (try? await RetryPolicy.withRetry({ try await apiClient.listUsers(ListUsersRequest(query: trimmed)) })) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addUser(_ user: OutlineUser) async {
|
private func addUser(_ user: OutlineUser) async {
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ final class HomeViewModel {
|
|||||||
/// set (unlike a full collection tree), so the N+1 here is acceptable
|
/// set (unlike a full collection tree), so the N+1 here is acceptable
|
||||||
/// where it wouldn't be in the sidebar.
|
/// where it wouldn't be in the sidebar.
|
||||||
private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
|
private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
|
||||||
let pins = try await apiClient.listPins(ListPinsRequest(collectionId: nil))
|
let pins = try await RetryPolicy.withRetry { try await apiClient.listPins(ListPinsRequest(collectionId: nil)) }
|
||||||
var documents: [OutlineDocument] = []
|
var documents: [OutlineDocument] = []
|
||||||
for pin in pins {
|
for pin in pins {
|
||||||
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
|
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
|
||||||
@@ -134,7 +134,7 @@ final class HomeViewModel {
|
|||||||
|
|
||||||
private func resolveCurrentUserID() async throws -> String {
|
private func resolveCurrentUserID() async throws -> String {
|
||||||
if let currentUserID { return currentUserID }
|
if let currentUserID { return currentUserID }
|
||||||
let user = try await apiClient.currentUser()
|
let user = try await RetryPolicy.withRetry { try await apiClient.currentUser() }
|
||||||
currentUserID = user.id
|
currentUserID = user.id
|
||||||
return user.id
|
return user.id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import Observation
|
||||||
|
import OutlineKit
|
||||||
|
|
||||||
|
/// Turns `CachingOutlineAPIClient.repeatedFailureSummaries()` into a banner
|
||||||
|
/// the user can actually see and act on, instead of a silently-swallowed
|
||||||
|
/// `try?` — see the pins bug this whole mechanism exists to catch a repeat
|
||||||
|
/// of. `RootView` polls the client periodically and feeds results in via
|
||||||
|
/// `update(with:)`; nothing here talks to the network directly.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class APIFailureCenter {
|
||||||
|
/// The single most-relevant category to show right now, or nil if
|
||||||
|
/// nothing's currently past the threshold (or everything past it has
|
||||||
|
/// been dismissed and is still in its cooldown).
|
||||||
|
private(set) var activeBanner: RepeatedFailure?
|
||||||
|
|
||||||
|
/// Categories the user's already dismissed, and when — suppressed from
|
||||||
|
/// reappearing until `dismissCooldown` passes, so a still-flaky
|
||||||
|
/// operation doesn't pop the same banner right back up a few seconds
|
||||||
|
/// after being told to go away.
|
||||||
|
private var dismissedAt: [String: Date] = [:]
|
||||||
|
private let dismissCooldown: TimeInterval = 900
|
||||||
|
|
||||||
|
/// Called from `RootView`'s poll loop with the latest snapshot from
|
||||||
|
/// `CachingOutlineAPIClient`. Picks the worst-offending category
|
||||||
|
/// (highest failure count) that isn't in cooldown; clears the banner
|
||||||
|
/// entirely once nothing qualifies (e.g. the user went back online and
|
||||||
|
/// everything recovered).
|
||||||
|
func update(with summaries: [RepeatedFailure]) {
|
||||||
|
let now = Date()
|
||||||
|
dismissedAt = dismissedAt.filter { now.timeIntervalSince($0.value) < dismissCooldown }
|
||||||
|
|
||||||
|
let eligible = summaries
|
||||||
|
.filter { dismissedAt[$0.category] == nil }
|
||||||
|
.sorted { $0.count > $1.count }
|
||||||
|
|
||||||
|
activeBanner = eligible.first
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dismiss without reporting — starts that category's cooldown so it
|
||||||
|
/// won't immediately reappear on the next poll if it's still failing.
|
||||||
|
func dismiss() {
|
||||||
|
guard let category = activeBanner?.category else { return }
|
||||||
|
dismissedAt[category] = Date()
|
||||||
|
activeBanner = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything folded into the report is safe to paste into a public bug
|
||||||
|
/// tracker as-is: a category name, a generic error description, and
|
||||||
|
/// version numbers — no document content, no server URL, no token.
|
||||||
|
func reportURL(appVersion: String, osVersion: String) -> URL? {
|
||||||
|
guard let banner = activeBanner else { return nil }
|
||||||
|
var components = URLComponents(string: "https://git.psmattas.com/psmattas/Outpost/issues/new")
|
||||||
|
let body = """
|
||||||
|
Outpost kept failing to \(banner.category) (\(banner.count) times in the last few minutes).
|
||||||
|
|
||||||
|
Error: \(banner.message)
|
||||||
|
App version: \(appVersion)
|
||||||
|
macOS: \(osVersion)
|
||||||
|
|
||||||
|
<!-- Anything else you can add about what you were doing when this started would help. -->
|
||||||
|
"""
|
||||||
|
components?.queryItems = [URLQueryItem(name: "body", value: body)]
|
||||||
|
return components?.url
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Shown when the same category of API call has failed repeatedly within a
|
||||||
|
/// few minutes (see `APIFailureCenter`) — the self-diagnosing replacement
|
||||||
|
/// for a `try?` that used to fail silently. No manual "Retry" button: the
|
||||||
|
/// retries already happened automatically before this ever appears, so all
|
||||||
|
/// that's left worth offering is reporting it and moving on.
|
||||||
|
struct RepeatedFailureBanner: View {
|
||||||
|
let message: String
|
||||||
|
let onReport: () -> Void
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text(message)
|
||||||
|
.font(.callout)
|
||||||
|
.lineLimit(2)
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Button("Report", action: onReport)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.small)
|
||||||
|
Button {
|
||||||
|
onDismiss()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.orange.opacity(0.12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -3,8 +3,10 @@ import OutlineKit
|
|||||||
|
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@Environment(SessionStore.self) private var session
|
@Environment(SessionStore.self) private var session
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
@State private var welcomeName: String?
|
@State private var welcomeName: String?
|
||||||
@State private var starStore = StarStore()
|
@State private var starStore = StarStore()
|
||||||
|
@State private var failureCenter = APIFailureCenter()
|
||||||
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
|
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
|
||||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||||
|
|
||||||
@@ -34,10 +36,39 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.environment(starStore)
|
.environment(starStore)
|
||||||
|
.environment(failureCenter)
|
||||||
|
#if os(macOS)
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
if let banner = failureCenter.activeBanner {
|
||||||
|
RepeatedFailureBanner(
|
||||||
|
message: bannerMessage(for: banner),
|
||||||
|
onReport: { reportActiveFailure() },
|
||||||
|
onDismiss: { failureCenter.dismiss() }
|
||||||
|
)
|
||||||
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: failureCenter.activeBanner)
|
||||||
|
#endif
|
||||||
.animation(.easeInOut(duration: 0.45), value: welcomeName != nil)
|
.animation(.easeInOut(duration: 0.45), value: welcomeName != nil)
|
||||||
.task {
|
.task {
|
||||||
await session.refreshTeamInfoIfNeeded()
|
await session.refreshTeamInfoIfNeeded()
|
||||||
}
|
}
|
||||||
|
// Repeated (non-transient) API failures already get an automatic
|
||||||
|
// retry-with-backoff inside CachingOutlineAPIClient itself — this
|
||||||
|
// just surfaces the ones that kept failing anyway, on a cheap poll
|
||||||
|
// (the client's own state, no network call of its own) rather than
|
||||||
|
// a push, since the client is a plain actor with no UI dependency.
|
||||||
|
.task(id: session.isSignedIn) {
|
||||||
|
guard session.isSignedIn else { return }
|
||||||
|
while !Task.isCancelled {
|
||||||
|
if let cachingClient = session.cachingClient {
|
||||||
|
let summaries = await cachingClient.repeatedFailureSummaries()
|
||||||
|
failureCenter.update(with: summaries)
|
||||||
|
}
|
||||||
|
try? await Task.sleep(for: .seconds(30))
|
||||||
|
}
|
||||||
|
}
|
||||||
.task(id: session.isSignedIn) {
|
.task(id: session.isSignedIn) {
|
||||||
if session.isSignedIn, let apiClient = session.apiClient {
|
if session.isSignedIn, let apiClient = session.apiClient {
|
||||||
await starStore.load(apiClient: apiClient)
|
await starStore.load(apiClient: apiClient)
|
||||||
@@ -89,6 +120,35 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
/// Category names are internal plumbing (`documents-write`, `pins`,
|
||||||
|
/// `collections-write`, ...) — this is the one place they turn into
|
||||||
|
/// something a user reads, so a new category added later just needs a
|
||||||
|
/// case here, not a rewrite of the tracking/polling underneath it.
|
||||||
|
private func bannerMessage(for failure: RepeatedFailure) -> String {
|
||||||
|
switch failure.category {
|
||||||
|
case "documents-write": return "Outpost is having trouble saving your document edits."
|
||||||
|
case "collections-write": return "Outpost is having trouble saving collection changes."
|
||||||
|
case "pins": return "Outpost is having trouble updating pins."
|
||||||
|
case "subscriptions": return "Outpost is having trouble updating subscriptions."
|
||||||
|
case "stars": return "Outpost is having trouble updating stars."
|
||||||
|
case "document", "documents": return "Outpost is having trouble loading documents."
|
||||||
|
case "collections": return "Outpost is having trouble loading collections."
|
||||||
|
case "drafts": return "Outpost is having trouble loading drafts."
|
||||||
|
default: return "Outpost is having trouble talking to the server (\(failure.category))."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reportActiveFailure() {
|
||||||
|
guard let url = failureCenter.reportURL(
|
||||||
|
appVersion: OutpostVersion.displayString,
|
||||||
|
osVersion: ProcessInfo.processInfo.operatingSystemVersionString
|
||||||
|
) else { return }
|
||||||
|
openURL(url)
|
||||||
|
failureCenter.dismiss()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) {
|
private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) {
|
||||||
welcomeName = result.user.name
|
welcomeName = result.user.name
|
||||||
session.signIn(serverURL: result.serverURL, user: result.user, team: result.team)
|
session.signIn(serverURL: result.serverURL, user: result.user, team: result.team)
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ final class SessionStore {
|
|||||||
/// in-memory state didn't.
|
/// in-memory state didn't.
|
||||||
func refreshTeamInfoIfNeeded() async {
|
func refreshTeamInfoIfNeeded() async {
|
||||||
guard isSignedIn, teamName == nil, let apiClient, let serverURL else { return }
|
guard isSignedIn, teamName == nil, let apiClient, let serverURL else { return }
|
||||||
guard let auth = try? await apiClient.authInfo() else { return }
|
guard let auth = try? await RetryPolicy.withRetry({ try await apiClient.authInfo() }) else { return }
|
||||||
apply(user: auth.user, team: auth.team, serverURL: serverURL)
|
apply(user: auth.user, team: auth.team, serverURL: serverURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ final class StarStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func load(apiClient: OutlineAPIClient) async {
|
func load(apiClient: OutlineAPIClient) async {
|
||||||
guard let stars = try? await apiClient.listStars(ListStarsRequest(offset: 0, limit: 250)) else { return }
|
guard let stars = try? await RetryPolicy.withRetry({ try await apiClient.listStars(ListStarsRequest(offset: 0, limit: 250)) }) else { return }
|
||||||
documentStars = Dictionary(uniqueKeysWithValues: stars.compactMap { star in
|
documentStars = Dictionary(uniqueKeysWithValues: stars.compactMap { star in
|
||||||
star.documentId.map { ($0, star) }
|
star.documentId.map { ($0, star) }
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user