Compare commits
48
Commits
@@ -355,6 +355,42 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await live.updateUserName(request)
|
try await live.updateUserName(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
|
||||||
|
try await live.updateUserLanguage(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
|
||||||
|
try await live.updateUserPreferences(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteAccount() async throws {
|
||||||
|
try await live.deleteAccount()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||||
|
try await live.subscribeToNotifications(eventType: eventType)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||||
|
try await live.unsubscribeFromNotifications(eventType: eventType)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
|
||||||
|
try await live.listApiKeys(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
|
||||||
|
try await live.createApiKey(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteApiKey(id: String) async throws {
|
||||||
|
try await live.deleteApiKey(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func installationInfo() async throws -> OutlineInstallationInfo {
|
||||||
|
try await live.installationInfo()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Sync management (Settings surface)
|
// MARK: - Sync management (Settings surface)
|
||||||
|
|
||||||
public func pendingOperations() async -> [PendingOperationSummary] {
|
public func pendingOperations() async -> [PendingOperationSummary] {
|
||||||
|
|||||||
@@ -83,4 +83,26 @@ public protocol OutlineAPIClient: Sendable {
|
|||||||
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser
|
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser
|
||||||
/// `users.update`, name only. See `UpdateUserNameRequest`.
|
/// `users.update`, name only. See `UpdateUserNameRequest`.
|
||||||
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser
|
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser
|
||||||
|
/// `users.update`, language only. See `UpdateUserLanguageRequest`.
|
||||||
|
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser
|
||||||
|
/// `users.update`, preferences only. See `UpdateUserPreferencesRequest`.
|
||||||
|
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser
|
||||||
|
/// Backed by `users.delete` — self-service account deletion, no
|
||||||
|
/// confirmation code param confirmed live, matches every other simple
|
||||||
|
/// no-body delete in this API.
|
||||||
|
func deleteAccount() async throws
|
||||||
|
/// `nil` targets every notification event. See `NotificationEventType`.
|
||||||
|
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
|
||||||
|
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
|
||||||
|
|
||||||
|
/// Settings → API & Access.
|
||||||
|
func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey]
|
||||||
|
/// The returned `OutlineAPIKey.value` is the only time the full
|
||||||
|
/// plaintext key is ever available — the caller is responsible for
|
||||||
|
/// displaying it once and then discarding it.
|
||||||
|
func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey
|
||||||
|
func deleteApiKey(id: String) async throws
|
||||||
|
|
||||||
|
/// Settings → Installation. Self-hosted server version info.
|
||||||
|
func installationInfo() async throws -> OutlineInstallationInfo
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,6 +290,42 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await post("users.update", body: request)
|
try await post("users.update", body: request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
|
||||||
|
try await post("users.update", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
|
||||||
|
try await post("users.update", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteAccount() async throws {
|
||||||
|
try await postForSuccess("users.delete", body: EmptyParams())
|
||||||
|
}
|
||||||
|
|
||||||
|
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||||
|
try await post("users.notificationsSubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||||
|
try await post("users.notificationsUnsubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
|
||||||
|
try await post("apiKeys.list", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
|
||||||
|
try await post("apiKeys.create", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteApiKey(id: String) async throws {
|
||||||
|
try await postForSuccess("apiKeys.delete", body: StarIDParams(id: id))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func installationInfo() async throws -> OutlineInstallationInfo {
|
||||||
|
try await post("installation.info", body: EmptyParams())
|
||||||
|
}
|
||||||
|
|
||||||
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
|
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
|
||||||
guard let token = try? tokenStore.token() else {
|
guard let token = try? tokenStore.token() else {
|
||||||
throw OutlineAPIError.tokenUnavailable
|
throw OutlineAPIError.tokenUnavailable
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The subset of Outline's notification event types this app exposes a
|
||||||
|
/// toggle for. Wire values confirmed live (captured `users.update`
|
||||||
|
/// responses showing the full `notificationSettings` dictionary after
|
||||||
|
/// toggling each one in Outline's own web app). The server tracks more
|
||||||
|
/// event types than this app has UI for (`revisions.create`,
|
||||||
|
/// `emails.onboarding`, `emails.features` were also seen live) — those are
|
||||||
|
/// left alone since `users.notificationsSubscribe`/`Unsubscribe` are
|
||||||
|
/// per-event, not a whole-object replace like `preferences`, so there's no
|
||||||
|
/// clobbering risk in only covering a subset.
|
||||||
|
public enum NotificationEventType: String, CaseIterable, Sendable {
|
||||||
|
case documentPublish = "documents.publish"
|
||||||
|
case documentUpdate = "documents.update"
|
||||||
|
case commentCreate = "comments.create"
|
||||||
|
case commentMentioned = "comments.mentioned"
|
||||||
|
case documentMentioned = "documents.mentioned"
|
||||||
|
case commentGroupMentioned = "comments.group_mentioned"
|
||||||
|
case documentGroupMentioned = "documents.group_mentioned"
|
||||||
|
case commentResolve = "comments.resolve"
|
||||||
|
case reactionCreate = "reactions.create"
|
||||||
|
case collectionCreate = "collections.create"
|
||||||
|
case emailsInviteAccepted = "emails.invite_accepted"
|
||||||
|
case documentAddUser = "documents.add_user"
|
||||||
|
case collectionAddUser = "collections.add_user"
|
||||||
|
case emailsExportCompleted = "emails.export_completed"
|
||||||
|
case accessRequestCreate = "access_requests.create"
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A personal API key (Settings → API & Access). Only the last 4 characters
|
||||||
|
/// of the actual token are ever returned by the server — there's no way to
|
||||||
|
/// see a full key again after creation, matching every other API-key UI
|
||||||
|
/// convention.
|
||||||
|
public struct OutlineAPIKey: Codable, Identifiable, Hashable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let name: String
|
||||||
|
public let last4: String?
|
||||||
|
public let scope: [String]?
|
||||||
|
public let createdAt: Date
|
||||||
|
public let expiresAt: Date?
|
||||||
|
public let lastActiveAt: Date?
|
||||||
|
/// The full plaintext key — present *only* in `apiKeys.create`'s
|
||||||
|
/// response, confirmed live: `apiKeys.list` never includes it, matching
|
||||||
|
/// "shown once at creation" being enforced server-side, not just a
|
||||||
|
/// client-side UI convention this app has to uphold on its own.
|
||||||
|
public let value: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
last4: String? = nil,
|
||||||
|
scope: [String]? = nil,
|
||||||
|
createdAt: Date,
|
||||||
|
expiresAt: Date? = nil,
|
||||||
|
lastActiveAt: Date? = nil,
|
||||||
|
value: String? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.last4 = last4
|
||||||
|
self.scope = scope
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.expiresAt = expiresAt
|
||||||
|
self.lastActiveAt = lastActiveAt
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `installation.info` — the self-hosted server's own version, confirmed
|
||||||
|
/// live. `policies` (a separate top-level array alongside `data` in the raw
|
||||||
|
/// response) isn't modeled here — not used by this app's Installation
|
||||||
|
/// settings page.
|
||||||
|
public struct OutlineInstallationInfo: Decodable, Sendable {
|
||||||
|
public let version: String
|
||||||
|
public let latestVersion: String
|
||||||
|
public let versionsBehind: Int
|
||||||
|
}
|
||||||
@@ -6,18 +6,30 @@ public struct OutlineUser: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public let email: String?
|
public let email: String?
|
||||||
public let avatarUrl: String?
|
public let avatarUrl: String?
|
||||||
public let role: String?
|
public let role: String?
|
||||||
|
public let language: String?
|
||||||
|
public let preferences: OutlineUserPreferences?
|
||||||
|
/// Keyed by `NotificationEventType`'s raw values, plus event types this
|
||||||
|
/// app has no UI for — kept as a flexible dictionary rather than a
|
||||||
|
/// fixed struct for that reason. `true` means subscribed.
|
||||||
|
public let notificationSettings: [String: Bool]?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
email: String? = nil,
|
email: String? = nil,
|
||||||
avatarUrl: String? = nil,
|
avatarUrl: String? = nil,
|
||||||
role: String? = nil
|
role: String? = nil,
|
||||||
|
language: String? = nil,
|
||||||
|
preferences: OutlineUserPreferences? = nil,
|
||||||
|
notificationSettings: [String: Bool]? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
self.email = email
|
self.email = email
|
||||||
self.avatarUrl = avatarUrl
|
self.avatarUrl = avatarUrl
|
||||||
self.role = role
|
self.role = role
|
||||||
|
self.language = language
|
||||||
|
self.preferences = preferences
|
||||||
|
self.notificationSettings = notificationSettings
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `User.preferences` — a free-form JSON blob on Outline's own `User` row,
|
||||||
|
/// not a fixed-shape API resource. Wire key names below are confirmed
|
||||||
|
/// against a live server's own web app traffic (captured toggling every
|
||||||
|
/// Preferences setting one at a time), not guessed — see the `CodingKeys`
|
||||||
|
/// mapping for the two that don't match this struct's own property names.
|
||||||
|
///
|
||||||
|
/// The server also validates `preferences` against a known key allowlist
|
||||||
|
/// and rejects the whole `users.update` call (not just the bad field) if
|
||||||
|
/// any key it doesn't recognize is present — confirmed live via a
|
||||||
|
/// `"notificationBadge: Invalid Input"` error when an earlier, wrong value
|
||||||
|
/// was sent. That's also why `fullWidthDocuments` is kept here even though
|
||||||
|
/// this app has no UI for it yet: this app always sends the *whole*
|
||||||
|
/// preferences object back on every save (see
|
||||||
|
/// `UpdateUserPreferencesRequest`), so silently dropping an unknown key
|
||||||
|
/// during decode would permanently clear it the next time any other
|
||||||
|
/// preference here gets saved.
|
||||||
|
public struct OutlineUserPreferences: Codable, Hashable, Sendable {
|
||||||
|
public var rememberLastPath: Bool?
|
||||||
|
/// App-facing polarity: `true` means "separate editing mode is on" —
|
||||||
|
/// the opposite of the wire's own `seamlessEdit` (seamless editing and
|
||||||
|
/// separate editing modes are each other's negation), inverted in
|
||||||
|
/// `init(from:)`/`encode(to:)` so nothing outside this file has to
|
||||||
|
/// remember that.
|
||||||
|
public var separateEditing: Bool?
|
||||||
|
public var useCursorPointer: Bool?
|
||||||
|
public var codeBlockLineNumbers: Bool?
|
||||||
|
public var showCommentMarker: Bool?
|
||||||
|
public var smartText: Bool?
|
||||||
|
/// One of `NotificationBadgeStyle`'s raw values.
|
||||||
|
public var notificationBadge: String?
|
||||||
|
/// No UI in this app yet — preserved purely so saving any other
|
||||||
|
/// preference here doesn't clobber it. See the type doc comment.
|
||||||
|
public var fullWidthDocuments: Bool?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
rememberLastPath: Bool? = nil,
|
||||||
|
separateEditing: Bool? = nil,
|
||||||
|
useCursorPointer: Bool? = nil,
|
||||||
|
codeBlockLineNumbers: Bool? = nil,
|
||||||
|
showCommentMarker: Bool? = nil,
|
||||||
|
smartText: Bool? = nil,
|
||||||
|
notificationBadge: String? = nil,
|
||||||
|
fullWidthDocuments: Bool? = nil
|
||||||
|
) {
|
||||||
|
self.rememberLastPath = rememberLastPath
|
||||||
|
self.separateEditing = separateEditing
|
||||||
|
self.useCursorPointer = useCursorPointer
|
||||||
|
self.codeBlockLineNumbers = codeBlockLineNumbers
|
||||||
|
self.showCommentMarker = showCommentMarker
|
||||||
|
self.smartText = smartText
|
||||||
|
self.notificationBadge = notificationBadge
|
||||||
|
self.fullWidthDocuments = fullWidthDocuments
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case rememberLastPath
|
||||||
|
case seamlessEdit
|
||||||
|
case useCursorPointer
|
||||||
|
case codeBlockLineNumbers
|
||||||
|
case commentsInGutter
|
||||||
|
case enableSmartText
|
||||||
|
case notificationBadge
|
||||||
|
case fullWidthDocuments
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
rememberLastPath = try container.decodeIfPresent(Bool.self, forKey: .rememberLastPath)
|
||||||
|
separateEditing = try container.decodeIfPresent(Bool.self, forKey: .seamlessEdit).map { !$0 }
|
||||||
|
useCursorPointer = try container.decodeIfPresent(Bool.self, forKey: .useCursorPointer)
|
||||||
|
codeBlockLineNumbers = try container.decodeIfPresent(Bool.self, forKey: .codeBlockLineNumbers)
|
||||||
|
showCommentMarker = try container.decodeIfPresent(Bool.self, forKey: .commentsInGutter)
|
||||||
|
smartText = try container.decodeIfPresent(Bool.self, forKey: .enableSmartText)
|
||||||
|
notificationBadge = try container.decodeIfPresent(String.self, forKey: .notificationBadge)
|
||||||
|
fullWidthDocuments = try container.decodeIfPresent(Bool.self, forKey: .fullWidthDocuments)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encodeIfPresent(rememberLastPath, forKey: .rememberLastPath)
|
||||||
|
try container.encodeIfPresent(separateEditing.map { !$0 }, forKey: .seamlessEdit)
|
||||||
|
try container.encodeIfPresent(useCursorPointer, forKey: .useCursorPointer)
|
||||||
|
try container.encodeIfPresent(codeBlockLineNumbers, forKey: .codeBlockLineNumbers)
|
||||||
|
try container.encodeIfPresent(showCommentMarker, forKey: .commentsInGutter)
|
||||||
|
try container.encodeIfPresent(smartText, forKey: .enableSmartText)
|
||||||
|
try container.encodeIfPresent(notificationBadge, forKey: .notificationBadge)
|
||||||
|
try container.encodeIfPresent(fullWidthDocuments, forKey: .fullWidthDocuments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App-icon unread indicator style. Wire values confirmed live (captured
|
||||||
|
/// setting all three from Outline's own web app).
|
||||||
|
public enum NotificationBadgeStyle: String, CaseIterable, Identifiable, Sendable {
|
||||||
|
case none = "disabled"
|
||||||
|
case unreadIndicator = "indicator"
|
||||||
|
case unreadCount = "count"
|
||||||
|
|
||||||
|
public var id: String { rawValue }
|
||||||
|
|
||||||
|
public var label: String {
|
||||||
|
switch self {
|
||||||
|
case .none: return "None"
|
||||||
|
case .unreadIndicator: return "Unread Indicator"
|
||||||
|
case .unreadCount: return "Unread Count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `apiKeys.create`. `expiresAt: nil` (the key omitted entirely, not sent as
|
||||||
|
/// literal `null`) confirmed live to mean no expiration — Swift's
|
||||||
|
/// synthesized `Encodable` already omits `nil` optionals via
|
||||||
|
/// `encodeIfPresent`, so no custom `encode(to:)` is needed here the way
|
||||||
|
/// `UpdateUserAvatarRequest` needed one for the opposite case.
|
||||||
|
public struct CreateApiKeyRequest: Encodable, Sendable {
|
||||||
|
public let name: String
|
||||||
|
public let expiresAt: Date?
|
||||||
|
/// `nil`/omitted grants full access — confirmed live (every key created
|
||||||
|
/// without a scope came back with unrestricted access). A specific
|
||||||
|
/// scope is a list of allowed API paths, e.g. `["/api/documents.info"]`.
|
||||||
|
public let scope: [String]?
|
||||||
|
|
||||||
|
public init(name: String, expiresAt: Date? = nil, scope: [String]? = nil) {
|
||||||
|
self.name = name
|
||||||
|
self.expiresAt = expiresAt
|
||||||
|
self.scope = scope
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `apiKeys.list` — matches every other paginated `.list` endpoint's flat
|
||||||
|
/// offset/limit convention (e.g. `ListSharesRequest`), not the nested
|
||||||
|
/// `pagination` object that only appears in list *responses*.
|
||||||
|
public struct ListApiKeysRequest: Encodable, Sendable {
|
||||||
|
public let offset: Int
|
||||||
|
public let limit: Int
|
||||||
|
|
||||||
|
public init(offset: Int = 0, limit: Int = 25) {
|
||||||
|
self.offset = offset
|
||||||
|
self.limit = limit
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `users.notificationsSubscribe` / `users.notificationsUnsubscribe`. A
|
||||||
|
/// `nil` `eventType` targets every notification event at once — confirmed
|
||||||
|
/// live via Outline's own "All notifications" master toggle, which sends
|
||||||
|
/// no `eventType` at all.
|
||||||
|
public struct NotificationSubscriptionRequest: Encodable, Sendable {
|
||||||
|
public let eventType: String?
|
||||||
|
|
||||||
|
public init(eventType: String? = nil) {
|
||||||
|
self.eventType = eventType
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `users.update`, language only.
|
||||||
|
public struct UpdateUserLanguageRequest: Encodable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let language: String
|
||||||
|
|
||||||
|
public init(id: String, language: String) {
|
||||||
|
self.id = id
|
||||||
|
self.language = language
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `users.update`, preferences only. Sends the *whole* preferences object
|
||||||
|
/// back (not a single changed key) — avoids needing to know whether the
|
||||||
|
/// server deep-merges a partial `preferences` body or replaces it outright.
|
||||||
|
public struct UpdateUserPreferencesRequest: Encodable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let preferences: OutlineUserPreferences
|
||||||
|
|
||||||
|
public init(id: String, preferences: OutlineUserPreferences) {
|
||||||
|
self.id = id
|
||||||
|
self.preferences = preferences
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,15 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
|
|||||||
func deleteAttachment(id: String) async throws { throw NotStubbed() }
|
func deleteAttachment(id: String) async throws { throw NotStubbed() }
|
||||||
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
|
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() }
|
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
|
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
|
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
|
func deleteAccount() async throws { throw NotStubbed() }
|
||||||
|
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
|
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
|
func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] { throw NotStubbed() }
|
||||||
|
func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey { throw NotStubbed() }
|
||||||
|
func deleteApiKey(id: String) async throws { throw NotStubbed() }
|
||||||
|
func installationInfo() async throws -> OutlineInstallationInfo { throw NotStubbed() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct StubTransportError: Error {}
|
private struct StubTransportError: Error {}
|
||||||
|
|||||||
@@ -1074,6 +1074,318 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testUpdateUserLanguageSendsRequestAndDecodesResult() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "user-1",
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"language": "fr_FR"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let user = try await client.updateUserLanguage(UpdateUserLanguageRequest(id: "user-1", language: "fr_FR"))
|
||||||
|
|
||||||
|
XCTAssertEqual(user.language, "fr_FR")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUpdateUserPreferencesSendsWholeObjectAndDecodesResult() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "user-1",
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"preferences": { "rememberLastPath": true, "useCursorPointer": true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let preferences = OutlineUserPreferences(rememberLastPath: true, useCursorPointer: true)
|
||||||
|
let user = try await client.updateUserPreferences(UpdateUserPreferencesRequest(id: "user-1", preferences: preferences))
|
||||||
|
|
||||||
|
XCTAssertEqual(user.preferences?.rememberLastPath, true)
|
||||||
|
XCTAssertEqual(user.preferences?.useCursorPointer, true)
|
||||||
|
|
||||||
|
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||||
|
let sentPreferences = sentBody?["preferences"] as? [String: Any]
|
||||||
|
XCTAssertEqual(sentPreferences?["rememberLastPath"] as? Bool, true)
|
||||||
|
XCTAssertEqual(sentPreferences?["useCursorPointer"] as? Bool, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locks in the wire mapping confirmed against a live server's own web
|
||||||
|
/// app traffic: `seamlessEdit`/`commentsInGutter`/`enableSmartText` are
|
||||||
|
/// the real keys (not `separateEditing`/`showCommentMarker`/`smartText`
|
||||||
|
/// this struct exposes), and `seamlessEdit` is the *negation* of this
|
||||||
|
/// app's `separateEditing`.
|
||||||
|
func testOutlineUserPreferencesDecodesRealWireKeys() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"seamlessEdit": false,
|
||||||
|
"commentsInGutter": true,
|
||||||
|
"enableSmartText": true,
|
||||||
|
"rememberLastPath": true,
|
||||||
|
"useCursorPointer": true,
|
||||||
|
"codeBlockLineNumbers": false,
|
||||||
|
"notificationBadge": "indicator",
|
||||||
|
"fullWidthDocuments": true
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let preferences = try JSONDecoder().decode(OutlineUserPreferences.self, from: json)
|
||||||
|
|
||||||
|
XCTAssertEqual(preferences.separateEditing, true, "seamlessEdit: false means separate editing is ON")
|
||||||
|
XCTAssertEqual(preferences.showCommentMarker, true)
|
||||||
|
XCTAssertEqual(preferences.smartText, true)
|
||||||
|
XCTAssertEqual(preferences.rememberLastPath, true)
|
||||||
|
XCTAssertEqual(preferences.useCursorPointer, true)
|
||||||
|
XCTAssertEqual(preferences.codeBlockLineNumbers, false)
|
||||||
|
XCTAssertEqual(preferences.notificationBadge, "indicator")
|
||||||
|
XCTAssertEqual(preferences.fullWidthDocuments, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOutlineUserPreferencesEncodesRealWireKeysAndInvertsSeparateEditing() throws {
|
||||||
|
var preferences = OutlineUserPreferences()
|
||||||
|
preferences.separateEditing = true
|
||||||
|
preferences.showCommentMarker = false
|
||||||
|
preferences.smartText = true
|
||||||
|
preferences.fullWidthDocuments = true
|
||||||
|
|
||||||
|
let data = try JSONEncoder().encode(preferences)
|
||||||
|
let object = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
|
||||||
|
XCTAssertEqual(object?["seamlessEdit"] as? Bool, false, "separateEditing: true must encode as seamlessEdit: false")
|
||||||
|
XCTAssertEqual(object?["commentsInGutter"] as? Bool, false)
|
||||||
|
XCTAssertEqual(object?["enableSmartText"] as? Bool, true)
|
||||||
|
XCTAssertEqual(object?["fullWidthDocuments"] as? Bool, true)
|
||||||
|
XCTAssertNil(object?["separateEditing"], "must not leak this app's own field name onto the wire")
|
||||||
|
XCTAssertNil(object?["showCommentMarker"])
|
||||||
|
XCTAssertNil(object?["smartText"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSubscribeToNotificationsSendsEventTypeAndDecodesResult() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "user-1",
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"notificationSettings": { "documents.publish": true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let user = try await client.subscribeToNotifications(eventType: .documentPublish)
|
||||||
|
|
||||||
|
XCTAssertEqual(user.notificationSettings?["documents.publish"], true)
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsSubscribe")
|
||||||
|
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||||
|
XCTAssertEqual(sentBody?["eventType"] as? String, "documents.publish")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnsubscribeFromNotificationsWithNilEventTypeTargetsAll() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "user-1",
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"notificationSettings": { "documents.publish": false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = try await client.unsubscribeFromNotifications(eventType: nil)
|
||||||
|
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsUnsubscribe")
|
||||||
|
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||||
|
XCTAssertNil(sentBody?["eventType"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListApiKeysDecodesResult() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"pagination": { "limit": 25, "offset": 0 },
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "c3eec545-6d38-4065-90dc-b6c96a551445",
|
||||||
|
"name": "Outpost",
|
||||||
|
"scope": null,
|
||||||
|
"last4": "dl1h",
|
||||||
|
"createdAt": "2026-08-12T18:44:32.467Z",
|
||||||
|
"updatedAt": "2026-08-12T18:44:32.467Z",
|
||||||
|
"expiresAt": null,
|
||||||
|
"lastActiveAt": "2026-08-18T01:01:36.553Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let keys = try await client.listApiKeys(ListApiKeysRequest())
|
||||||
|
|
||||||
|
XCTAssertEqual(keys.count, 1)
|
||||||
|
XCTAssertEqual(keys.first?.name, "Outpost")
|
||||||
|
XCTAssertEqual(keys.first?.last4, "dl1h")
|
||||||
|
XCTAssertNil(keys.first?.expiresAt)
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.list")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInstallationInfoDecodesResult() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": { "version": "1.9.2", "latestVersion": "1.9.2", "versionsBehind": 0 },
|
||||||
|
"policies": []
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let info = try await client.installationInfo()
|
||||||
|
|
||||||
|
XCTAssertEqual(info.version, "1.9.2")
|
||||||
|
XCTAssertEqual(info.versionsBehind, 0)
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/installation.info")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCreateApiKeyOmitsExpiresAtWhenNilAndDecodesValue() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683",
|
||||||
|
"name": "test",
|
||||||
|
"scope": null,
|
||||||
|
"value": "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv",
|
||||||
|
"last4": "0DGv",
|
||||||
|
"createdAt": "2026-08-18T15:39:29.872Z",
|
||||||
|
"expiresAt": null,
|
||||||
|
"lastActiveAt": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let key = try await client.createApiKey(CreateApiKeyRequest(name: "test"))
|
||||||
|
|
||||||
|
XCTAssertEqual(key.value, "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv")
|
||||||
|
XCTAssertNil(key.expiresAt)
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.create")
|
||||||
|
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||||
|
XCTAssertEqual(sentBody?["name"] as? String, "test")
|
||||||
|
XCTAssertNil(sentBody?["expiresAt"], "omitting expiresAt (not sending null) is what produces a non-expiring key")
|
||||||
|
XCTAssertNil(sentBody?["scope"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCreateApiKeySendsExpiresAtWhenProvided() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "04e204fb-51a4-4b54-aed1-2056dfd576d7",
|
||||||
|
"name": "Test",
|
||||||
|
"scope": null,
|
||||||
|
"value": "ol_api_aeYcOts7I2sJXw3zaztjydRM3W3W89TBAtcLts",
|
||||||
|
"last4": "cLts",
|
||||||
|
"createdAt": "2026-08-18T15:38:22.928Z",
|
||||||
|
"expiresAt": "2026-11-16T23:59:59.999Z",
|
||||||
|
"lastActiveAt": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let expiresAt = Date(timeIntervalSince1970: 1_795_000_000)
|
||||||
|
_ = try await client.createApiKey(CreateApiKeyRequest(name: "Test", expiresAt: expiresAt))
|
||||||
|
|
||||||
|
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||||
|
XCTAssertNotNil(sentBody?["expiresAt"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeleteApiKeySendsRequest() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{ "success": true }
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
try await client.deleteApiKey(id: "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683")
|
||||||
|
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.delete")
|
||||||
|
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||||
|
XCTAssertEqual(sentBody?["id"] as? String, "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeleteAccountSendsRequest() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{ "success": true }
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
try await client.deleteAccount()
|
||||||
|
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.delete")
|
||||||
|
}
|
||||||
|
|
||||||
func testDeleteAttachmentSendsRequest() async throws {
|
func testDeleteAttachmentSendsRequest() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
httpClient.responseData = """
|
httpClient.responseData = """
|
||||||
|
|||||||
@@ -299,7 +299,7 @@
|
|||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -361,7 +361,7 @@
|
|||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -385,15 +385,20 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Outpost;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||||
@@ -408,9 +413,10 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||||
MARKETING_VERSION = 0.0.3;
|
MARKETING_VERSION = 0.0.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -430,15 +436,20 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Outpost;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||||
@@ -453,9 +464,10 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||||
MARKETING_VERSION = 0.0.3;
|
MARKETING_VERSION = 0.0.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -476,7 +488,7 @@
|
|||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||||
@@ -502,7 +514,7 @@
|
|||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||||
@@ -527,7 +539,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||||
@@ -552,7 +564,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "outpost-ios-1024.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 440 KiB |
@@ -14,17 +14,7 @@ struct AboutInfoView: View {
|
|||||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost"
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bumped alongside `MARKETING_VERSION` in the Xcode project — kept out
|
var versionString: String { OutpostVersion.fullVersionString }
|
||||||
/// of the bundle version itself since `CFBundleShortVersionString` is
|
|
||||||
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
|
|
||||||
private let releaseStage = "ALPHA"
|
|
||||||
|
|
||||||
var versionString: String {
|
|
||||||
let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
|
|
||||||
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
|
||||||
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
|
|
||||||
return "Version \(shortVersion)\(stageSuffix) (\(buildNumber))"
|
|
||||||
}
|
|
||||||
|
|
||||||
private var copyrightYear: String {
|
private var copyrightYear: String {
|
||||||
String(Calendar.current.component(.year, from: Date()))
|
String(Calendar.current.component(.year, from: Date()))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import OutlineKit
|
||||||
|
|
||||||
/// Swapped into the real sidebar's content slot (search field, collections
|
/// Swapped into the real sidebar's content slot (search field, collections
|
||||||
/// tree, account footer) while Settings is open — same sidebar, different
|
/// tree, account footer) while Settings is open — same sidebar, different
|
||||||
@@ -8,13 +9,26 @@ import SwiftUI
|
|||||||
/// back.
|
/// back.
|
||||||
///
|
///
|
||||||
/// Grouped by `SettingsCategory` — `general` (ours) sits under an "Outpost"
|
/// Grouped by `SettingsCategory` — `general` (ours) sits under an "Outpost"
|
||||||
/// header at the top, then Outline's own Account/Workspace/Integrations &
|
/// header at the top, then Outline's own Account/Workspace groups, matching
|
||||||
/// Installation groups, matching the settings page structure of the
|
/// the settings page structure of the Outline web app. Outline's own server
|
||||||
/// Outline web app.
|
/// version has no dedicated section (there used to be an Integrations &
|
||||||
|
/// Installation category for just that) — it's cheap enough to show
|
||||||
|
/// unconditionally in the footer here instead, alongside Outpost's own
|
||||||
|
/// version.
|
||||||
struct SettingsSidebarList: View {
|
struct SettingsSidebarList: View {
|
||||||
@Binding var selection: SettingsSection?
|
@Binding var selection: SettingsSection?
|
||||||
let onDone: () -> Void
|
let onDone: () -> Void
|
||||||
|
|
||||||
|
@Environment(SessionStore.self) private var session
|
||||||
|
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||||
|
@State private var outlineVersion: String?
|
||||||
|
|
||||||
|
/// Mirrors `SettingsView`'s own check — a real dropped connection or
|
||||||
|
/// the manual Offline Mode toggle both mean there's no server to ask.
|
||||||
|
private var isEffectivelyOnline: Bool {
|
||||||
|
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -37,7 +51,21 @@ struct SettingsSidebarList: View {
|
|||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
if let title = category.title {
|
if let title = category.title {
|
||||||
|
HStack(spacing: 6) {
|
||||||
Text(title)
|
Text(title)
|
||||||
|
// Not hardcoded to `.workspace` specifically —
|
||||||
|
// stays correct on its own as sections get
|
||||||
|
// built, only shows while every section in
|
||||||
|
// the category is still `!isImplemented`.
|
||||||
|
if sections.allSatisfy({ !$0.isImplemented }) {
|
||||||
|
Text("Coming Soon")
|
||||||
|
.font(.system(size: 9, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(.secondary.opacity(0.15), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,12 +74,42 @@ struct SettingsSidebarList: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
versionFooter
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
Button("Done", action: onDone)
|
Button("Done", action: onDone)
|
||||||
.keyboardShortcut(.cancelAction)
|
.keyboardShortcut(.cancelAction)
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(12)
|
.padding(12)
|
||||||
}
|
}
|
||||||
|
// Keyed to connectivity, not a one-shot `.task {}` — reconnecting
|
||||||
|
// (or turning the manual Offline Mode toggle back off) re-fires
|
||||||
|
// this automatically instead of leaving the footer stuck on
|
||||||
|
// whatever it last knew, or blank, until Settings is reopened.
|
||||||
|
.task(id: isEffectivelyOnline) { await refreshOutlineVersion() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var versionFooter: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Outpost \(OutpostVersion.displayString)")
|
||||||
|
if let outlineVersion {
|
||||||
|
Text("Outline \(outlineVersion)")
|
||||||
|
} else if !isEffectivelyOnline {
|
||||||
|
Text("Outline — offline")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshOutlineVersion() async {
|
||||||
|
guard isEffectivelyOnline, let apiClient = session.apiClient else { return }
|
||||||
|
outlineVersion = try? await apiClient.installationInfo().version
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -32,6 +32,28 @@ struct SettingsView: View {
|
|||||||
@State private var editableName = ""
|
@State private var editableName = ""
|
||||||
@State private var isSavingName = false
|
@State private var isSavingName = false
|
||||||
@State private var nameErrorMessage: String?
|
@State private var nameErrorMessage: String?
|
||||||
|
@State private var isSavingLanguage = false
|
||||||
|
@State private var languageErrorMessage: String?
|
||||||
|
@State private var isSavingPreferences = false
|
||||||
|
@State private var preferencesErrorMessage: String?
|
||||||
|
@State private var isShowingDeleteAccountConfirmation = false
|
||||||
|
@State private var isDeletingAccount = false
|
||||||
|
@State private var deleteAccountErrorMessage: String?
|
||||||
|
@State private var isSavingNotifications = false
|
||||||
|
@State private var notificationsErrorMessage: String?
|
||||||
|
@State private var apiKeys: [OutlineAPIKey] = []
|
||||||
|
@State private var isLoadingApiKeys = false
|
||||||
|
@State private var apiKeysErrorMessage: String?
|
||||||
|
@State private var isShowingCreateApiKey = false
|
||||||
|
@State private var newApiKeyName = ""
|
||||||
|
@State private var newApiKeyExpiration: ApiKeyExpiration = .noExpiration
|
||||||
|
@State private var isCreatingApiKey = false
|
||||||
|
@State private var createApiKeyErrorMessage: String?
|
||||||
|
@State private var revealedApiKey: RevealedApiKey?
|
||||||
|
@State private var didCopyRevealedKey = false
|
||||||
|
@State private var apiKeyPendingDeletion: OutlineAPIKey?
|
||||||
|
@State private var deletingApiKeyId: String?
|
||||||
|
@State private var isShowingApiKeyWebOnlyNotice = false
|
||||||
|
|
||||||
/// Full Local Sync and cache-clearing both need a real connection to be
|
/// Full Local Sync and cache-clearing both need a real connection to be
|
||||||
/// safe — clearing while offline (or letting Full Local Sync think it
|
/// safe — clearing while offline (or letting Full Local Sync think it
|
||||||
@@ -71,6 +93,10 @@ struct SettingsView: View {
|
|||||||
switch section {
|
switch section {
|
||||||
case .appearance: appearanceDetail
|
case .appearance: appearanceDetail
|
||||||
case .profile: profileDetail
|
case .profile: profileDetail
|
||||||
|
case .preferences: preferencesDetail
|
||||||
|
case .notifications: notificationsDetail
|
||||||
|
case .passkeys: passkeysDetail
|
||||||
|
case .apiAccess: apiAccessDetail
|
||||||
case .offlineSync: offlineSyncDetail
|
case .offlineSync: offlineSyncDetail
|
||||||
case .advanced: advancedDetail
|
case .advanced: advancedDetail
|
||||||
case .about: aboutDetail
|
case .about: aboutDetail
|
||||||
@@ -78,9 +104,8 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything in Account/Workspace/Integrations & Installation that
|
/// Everything in Account/Workspace that isn't `.profile` — real content
|
||||||
/// isn't `.profile` — real content lands section by section; this is
|
/// lands section by section; this is just the nav skeleton until then.
|
||||||
/// just the nav skeleton until then.
|
|
||||||
private var comingSoonDetail: some View {
|
private var comingSoonDetail: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
sectionHeader
|
sectionHeader
|
||||||
@@ -119,6 +144,9 @@ struct SettingsView: View {
|
|||||||
private var profileDetail: some View {
|
private var profileDetail: some View {
|
||||||
VStack(alignment: .leading, spacing: 24) {
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
sectionHeader
|
sectionHeader
|
||||||
|
if !isEffectivelyOnline {
|
||||||
|
offlineSettingsHint
|
||||||
|
}
|
||||||
avatarRow
|
avatarRow
|
||||||
Divider().frame(maxWidth: 420)
|
Divider().frame(maxWidth: 420)
|
||||||
nameRow
|
nameRow
|
||||||
@@ -176,6 +204,7 @@ struct SettingsView: View {
|
|||||||
ProgressView().controlSize(.small)
|
ProgressView().controlSize(.small)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
if let avatarErrorMessage {
|
if let avatarErrorMessage {
|
||||||
Text(avatarErrorMessage)
|
Text(avatarErrorMessage)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -204,6 +233,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
if let nameErrorMessage {
|
if let nameErrorMessage {
|
||||||
Text(nameErrorMessage)
|
Text(nameErrorMessage)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -231,6 +261,805 @@ struct SettingsView: View {
|
|||||||
.frame(maxWidth: 420, alignment: .leading)
|
.frame(maxWidth: 420, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Preferences
|
||||||
|
|
||||||
|
private var preferencesDetail: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
|
sectionHeader
|
||||||
|
Text("Manage settings that affect your personal experience.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if !isEffectivelyOnline {
|
||||||
|
offlineSettingsHint
|
||||||
|
}
|
||||||
|
|
||||||
|
preferencesSubsection("Display") {
|
||||||
|
languageRow
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
|
Divider()
|
||||||
|
appearanceRow
|
||||||
|
Divider()
|
||||||
|
preferenceToggleRow(
|
||||||
|
"Use pointer cursor",
|
||||||
|
description: "Show a hand cursor when hovering over interactive elements.",
|
||||||
|
isOn: session.userPreferences?.useCursorPointer ?? false,
|
||||||
|
onChange: { newValue in Task { await savePreference { $0.useCursorPointer = newValue } } }
|
||||||
|
)
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
|
Divider()
|
||||||
|
preferenceToggleRow(
|
||||||
|
"Show line numbers",
|
||||||
|
description: "Show line numbers on code blocks in documents.",
|
||||||
|
isOn: session.userPreferences?.codeBlockLineNumbers ?? false,
|
||||||
|
onChange: { newValue in Task { await savePreference { $0.codeBlockLineNumbers = newValue } } }
|
||||||
|
)
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
|
Divider()
|
||||||
|
preferenceToggleRow(
|
||||||
|
"Show comment marker",
|
||||||
|
description: "Display a marker beside lines in the editor that contain comments.",
|
||||||
|
isOn: session.userPreferences?.showCommentMarker ?? false,
|
||||||
|
onChange: { newValue in Task { await savePreference { $0.showCommentMarker = newValue } } }
|
||||||
|
)
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
|
}
|
||||||
|
|
||||||
|
preferencesSubsection("Behavior") {
|
||||||
|
preferenceToggleRow(
|
||||||
|
"Separate editing",
|
||||||
|
description: "When enabled, documents have a separate editing mode. When disabled, documents are always editable when you have permission.",
|
||||||
|
isOn: session.userPreferences?.separateEditing ?? false,
|
||||||
|
onChange: { newValue in Task { await savePreference { $0.separateEditing = newValue } } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
preferenceToggleRow(
|
||||||
|
"Remember previous location",
|
||||||
|
description: "Automatically return to the document you were last viewing when the app is re-opened.",
|
||||||
|
isOn: session.userPreferences?.rememberLastPath ?? false,
|
||||||
|
onChange: { newValue in Task { await savePreference { $0.rememberLastPath = newValue } } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
preferenceToggleRow(
|
||||||
|
"Smart text replacements",
|
||||||
|
description: "Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.",
|
||||||
|
isOn: session.userPreferences?.smartText ?? false,
|
||||||
|
onChange: { newValue in Task { await savePreference { $0.smartText = newValue } } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationBadgeRow
|
||||||
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
|
|
||||||
|
if let preferencesErrorMessage {
|
||||||
|
Text(preferencesErrorMessage)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
preferencesSubsection("Danger") {
|
||||||
|
deleteAccountRow
|
||||||
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 480, alignment: .leading)
|
||||||
|
.task { await refreshProfile() }
|
||||||
|
.confirmationDialog(
|
||||||
|
"Delete Account?",
|
||||||
|
isPresented: $isShowingDeleteAccountConfirmation,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Delete Account", role: .destructive) { Task { await deleteAccount() } }
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("This is unrecoverable — everything associated with your account will be permanently deleted.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deleteAccountRow: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Delete account")
|
||||||
|
Text("You may delete your account at any time, note that this is unrecoverable.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if isDeletingAccount {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Button("Delete…", role: .destructive) { isShowingDeleteAccountConfirmation = true }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let deleteAccountErrorMessage {
|
||||||
|
Text(deleteAccountErrorMessage)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func preferencesSubsection<Content: View>(
|
||||||
|
_ title: String,
|
||||||
|
@ViewBuilder content: () -> Content
|
||||||
|
) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var languageRow: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Language")
|
||||||
|
Text("Choose the interface language. Community translations are accepted through our translation portal.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if isSavingLanguage {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Picker("Language", selection: languageBinding) {
|
||||||
|
ForEach(displayedLocales) { locale in
|
||||||
|
Text(locale.label).tag(locale.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.frame(width: 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let languageErrorMessage {
|
||||||
|
Text(languageErrorMessage)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same `@AppStorage("outpost.appearance")` binding the standalone
|
||||||
|
/// Appearance section already uses — this is a local-only, device-side
|
||||||
|
/// preference (matches how this app has always handled it), not a
|
||||||
|
/// server-synced Outline preference, so it doesn't need its own
|
||||||
|
/// save call or error state.
|
||||||
|
private var appearanceRow: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Appearance")
|
||||||
|
Text("Choose your preferred interface colour scheme.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Picker("Appearance", selection: $appearance) {
|
||||||
|
ForEach(AppAppearance.allCases) { option in
|
||||||
|
Text(option.label).tag(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.frame(width: 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var notificationBadgeRow: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Notification badge")
|
||||||
|
Text("Choose how unread notifications are indicated on the app icon.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Picker("Notification badge", selection: notificationBadgeBinding) {
|
||||||
|
ForEach(NotificationBadgeStyle.allCases) { style in
|
||||||
|
Text(style.label).tag(style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.frame(width: 160)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var notificationBadgeBinding: Binding<NotificationBadgeStyle> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
session.userPreferences?.notificationBadge.flatMap(NotificationBadgeStyle.init(rawValue:)) ?? .unreadCount
|
||||||
|
},
|
||||||
|
set: { newValue in Task { await savePreference { $0.notificationBadge = newValue.rawValue } } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `OutlineLocale.all` is a curated subset, not Outline's full list —
|
||||||
|
/// a self-hosted server can report a code we don't know about (seen
|
||||||
|
/// live: "en_GB"). `Picker` needs a `Text` for every possible selection
|
||||||
|
/// or it logs "invalid tag" and its displayed value goes undefined —
|
||||||
|
/// appending the current code here (using itself as the label, same
|
||||||
|
/// fallback `OutlineLocale.label(for:)` already uses) guarantees a
|
||||||
|
/// match no matter what the server sends.
|
||||||
|
private var displayedLocales: [OutlineLocale] {
|
||||||
|
guard let current = session.userLanguage, !OutlineLocale.all.contains(where: { $0.code == current }) else {
|
||||||
|
return OutlineLocale.all
|
||||||
|
}
|
||||||
|
return OutlineLocale.all + [OutlineLocale(code: current, label: OutlineLocale.label(for: current))]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var languageBinding: Binding<String> {
|
||||||
|
Binding(
|
||||||
|
get: { session.userLanguage ?? "en_US" },
|
||||||
|
set: { newValue in Task { await saveLanguage(newValue) } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteAccount() async {
|
||||||
|
guard let apiClient = session.apiClient else { return }
|
||||||
|
isDeletingAccount = true
|
||||||
|
defer { isDeletingAccount = false }
|
||||||
|
do {
|
||||||
|
try await apiClient.deleteAccount()
|
||||||
|
session.signOut()
|
||||||
|
} catch {
|
||||||
|
deleteAccountErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete your account.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveLanguage(_ code: String) async {
|
||||||
|
guard code != session.userLanguage, let apiClient = session.apiClient, let userId = session.userId else { return }
|
||||||
|
isSavingLanguage = true
|
||||||
|
defer { isSavingLanguage = false }
|
||||||
|
do {
|
||||||
|
let updated = try await apiClient.updateUserLanguage(UpdateUserLanguageRequest(id: userId, language: code))
|
||||||
|
session.applyUpdatedProfile(updated)
|
||||||
|
languageErrorMessage = nil
|
||||||
|
} catch {
|
||||||
|
languageErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update your language.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every toggle in the Preferences page shares this: read
|
||||||
|
/// `session.userPreferences` (or an empty object if nothing's been set
|
||||||
|
/// yet), flip the one field the caller cares about, send the *whole*
|
||||||
|
/// object back — see `UpdateUserPreferencesRequest`'s own doc comment
|
||||||
|
/// for why the full object rather than a partial diff.
|
||||||
|
private func savePreference(_ update: (inout OutlineUserPreferences) -> Void) async {
|
||||||
|
guard let apiClient = session.apiClient, let userId = session.userId else { return }
|
||||||
|
var preferences = session.userPreferences ?? OutlineUserPreferences()
|
||||||
|
update(&preferences)
|
||||||
|
isSavingPreferences = true
|
||||||
|
defer { isSavingPreferences = false }
|
||||||
|
do {
|
||||||
|
let updated = try await apiClient.updateUserPreferences(
|
||||||
|
UpdateUserPreferencesRequest(id: userId, preferences: preferences)
|
||||||
|
)
|
||||||
|
session.applyUpdatedProfile(updated)
|
||||||
|
preferencesErrorMessage = nil
|
||||||
|
} catch {
|
||||||
|
preferencesErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update your preferences.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func preferenceToggleRow(
|
||||||
|
_ title: String,
|
||||||
|
description: String,
|
||||||
|
isOn: Bool,
|
||||||
|
onChange: @escaping (Bool) -> Void
|
||||||
|
) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Toggle(isOn: Binding(get: { isOn }, set: onChange)) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
Text(description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notifications
|
||||||
|
|
||||||
|
private var notificationsDetail: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
sectionHeader
|
||||||
|
Text("Manage when and where you receive email notifications.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if !isEffectivelyOnline {
|
||||||
|
offlineSettingsHint
|
||||||
|
}
|
||||||
|
|
||||||
|
allNotificationsRow
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Document published",
|
||||||
|
description: "Receive a notification whenever a new document is published",
|
||||||
|
isOn: session.userNotificationSettings?[NotificationEventType.documentPublish.rawValue] ?? false,
|
||||||
|
onChange: { newValue in Task { await setNotifications([.documentPublish], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Document updated",
|
||||||
|
description: "Receive a notification when a document you are subscribed to is edited",
|
||||||
|
isOn: session.userNotificationSettings?[NotificationEventType.documentUpdate.rawValue] ?? false,
|
||||||
|
onChange: { newValue in Task { await setNotifications([.documentUpdate], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Comment posted",
|
||||||
|
description: "Receive a notification when a document you are subscribed to or a thread you participated in receives a comment",
|
||||||
|
isOn: session.userNotificationSettings?[NotificationEventType.commentCreate.rawValue] ?? false,
|
||||||
|
onChange: { newValue in Task { await setNotifications([.commentCreate], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Mentioned",
|
||||||
|
description: "Receive a notification when someone mentions you in a document or comment",
|
||||||
|
isOn: [NotificationEventType.commentMentioned, .documentMentioned].allSatisfy { session.userNotificationSettings?[$0.rawValue] ?? false },
|
||||||
|
onChange: { newValue in Task { await setNotifications([.commentMentioned, .documentMentioned], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Group mentions",
|
||||||
|
description: "Receive a notification when someone mentions a group you are a member of in a document or comment",
|
||||||
|
isOn: [NotificationEventType.commentGroupMentioned, .documentGroupMentioned].allSatisfy { session.userNotificationSettings?[$0.rawValue] ?? false },
|
||||||
|
onChange: { newValue in Task { await setNotifications([.commentGroupMentioned, .documentGroupMentioned], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Resolved",
|
||||||
|
description: "Receive a notification when a comment thread you were involved in is resolved",
|
||||||
|
isOn: session.userNotificationSettings?[NotificationEventType.commentResolve.rawValue] ?? false,
|
||||||
|
onChange: { newValue in Task { await setNotifications([.commentResolve], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Reaction added",
|
||||||
|
description: "Receive a notification when someone reacts to your comment",
|
||||||
|
isOn: session.userNotificationSettings?[NotificationEventType.reactionCreate.rawValue] ?? false,
|
||||||
|
onChange: { newValue in Task { await setNotifications([.reactionCreate], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Collection created",
|
||||||
|
description: "Receive a notification whenever a new collection is created",
|
||||||
|
isOn: session.userNotificationSettings?[NotificationEventType.collectionCreate.rawValue] ?? false,
|
||||||
|
onChange: { newValue in Task { await setNotifications([.collectionCreate], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Invite accepted",
|
||||||
|
description: "Receive a notification when someone you invited creates an account",
|
||||||
|
isOn: session.userNotificationSettings?[NotificationEventType.emailsInviteAccepted.rawValue] ?? false,
|
||||||
|
onChange: { newValue in Task { await setNotifications([.emailsInviteAccepted], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Invited to document",
|
||||||
|
description: "Receive a notification when a document is shared with you",
|
||||||
|
isOn: session.userNotificationSettings?[NotificationEventType.documentAddUser.rawValue] ?? false,
|
||||||
|
onChange: { newValue in Task { await setNotifications([.documentAddUser], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Invited to collection",
|
||||||
|
description: "Receive a notification when you are given access to a collection",
|
||||||
|
isOn: session.userNotificationSettings?[NotificationEventType.collectionAddUser.rawValue] ?? false,
|
||||||
|
onChange: { newValue in Task { await setNotifications([.collectionAddUser], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Export completed",
|
||||||
|
description: "Receive a notification when an export you requested has been completed",
|
||||||
|
isOn: session.userNotificationSettings?[NotificationEventType.emailsExportCompleted.rawValue] ?? false,
|
||||||
|
onChange: { newValue in Task { await setNotifications([.emailsExportCompleted], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
notificationToggleRow(
|
||||||
|
"Document access requested",
|
||||||
|
description: "Receive a notification when a user requests access to a document you manage",
|
||||||
|
isOn: session.userNotificationSettings?[NotificationEventType.accessRequestCreate.rawValue] ?? false,
|
||||||
|
onChange: { newValue in Task { await setNotifications([.accessRequestCreate], subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
|
||||||
|
if let notificationsErrorMessage {
|
||||||
|
Text(notificationsErrorMessage)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
|
.frame(maxWidth: 480, alignment: .leading)
|
||||||
|
.task { await refreshProfile() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var allNotificationsRow: some View {
|
||||||
|
let isOn = NotificationEventType.allCases.allSatisfy { session.userNotificationSettings?[$0.rawValue] ?? false }
|
||||||
|
return notificationToggleRow(
|
||||||
|
"All notifications",
|
||||||
|
description: nil,
|
||||||
|
isOn: isOn,
|
||||||
|
onChange: { newValue in Task { await setNotifications(nil, subscribed: newValue) } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notificationToggleRow(
|
||||||
|
_ title: String,
|
||||||
|
description: String?,
|
||||||
|
isOn: Bool,
|
||||||
|
onChange: @escaping (Bool) -> Void
|
||||||
|
) -> some View {
|
||||||
|
Toggle(isOn: Binding(get: { isOn }, set: onChange)) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
if let description {
|
||||||
|
Text(description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `eventTypes` groups more than one wire event under a single visible
|
||||||
|
/// toggle (e.g. "Mentioned" covers both `comments.mentioned` and
|
||||||
|
/// `documents.mentioned`) — confirmed as the right grouping against
|
||||||
|
/// Outline's own settings copy, applied with one subscribe/unsubscribe
|
||||||
|
/// call per underlying event type, sequentially.
|
||||||
|
private func setNotifications(_ eventTypes: [NotificationEventType]?, subscribed: Bool) async {
|
||||||
|
guard let apiClient = session.apiClient else { return }
|
||||||
|
isSavingNotifications = true
|
||||||
|
defer { isSavingNotifications = false }
|
||||||
|
let targets: [NotificationEventType?] = eventTypes ?? [nil]
|
||||||
|
do {
|
||||||
|
for target in targets {
|
||||||
|
let updated = subscribed
|
||||||
|
? try await apiClient.subscribeToNotifications(eventType: target)
|
||||||
|
: try await apiClient.unsubscribeFromNotifications(eventType: target)
|
||||||
|
session.applyUpdatedProfile(updated)
|
||||||
|
}
|
||||||
|
notificationsErrorMessage = nil
|
||||||
|
} catch {
|
||||||
|
notificationsErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update your notification settings.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Passkeys
|
||||||
|
|
||||||
|
/// Read-only on purpose — Outline only exposes passkey management from
|
||||||
|
/// its own web app (WebAuthn registration needs a browser context this
|
||||||
|
/// native app doesn't have), so this page is informational, not a
|
||||||
|
/// placeholder for missing functionality.
|
||||||
|
private var passkeysDetail: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
sectionHeader
|
||||||
|
Text("Passkeys allow you to sign in safely without a password using your device's biometric authentication (Face ID, Touch ID, Windows Hello) or security key.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: 480, alignment: .leading)
|
||||||
|
|
||||||
|
if !isEffectivelyOnline {
|
||||||
|
offlineSettingsHint
|
||||||
|
}
|
||||||
|
|
||||||
|
Label("This setting can only be changed from the web version of Outline.", systemImage: "lock.fill")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: 480, alignment: .leading)
|
||||||
|
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - API & Access
|
||||||
|
|
||||||
|
private var apiAccessDetail: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
sectionHeader
|
||||||
|
Text("Create personal API keys to authenticate with the API and programmatically control your workspace's data. For more details see the [developer documentation](https://www.getoutline.com/developers).")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.tint(.accentColor)
|
||||||
|
.frame(maxWidth: 480, alignment: .leading)
|
||||||
|
|
||||||
|
Divider().frame(maxWidth: 480)
|
||||||
|
|
||||||
|
if !isEffectivelyOnline {
|
||||||
|
offlineSettingsHint
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Personal keys")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
// TODO: apiKeys.create needs Outline's cookie+CSRF web
|
||||||
|
// session, not this app's Bearer-token auth — confirmed
|
||||||
|
// this app's requests to it don't work. Swap this back to
|
||||||
|
// `isShowingCreateApiKey = true` (the real create sheet
|
||||||
|
// below is fully built and untouched) once there's a
|
||||||
|
// supported native auth path, or Outline adds Bearer
|
||||||
|
// support for this endpoint.
|
||||||
|
Button("New API Key…") { isShowingApiKeyWebOnlyNotice = true }
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 480)
|
||||||
|
|
||||||
|
apiKeysList
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
|
.opacity(isEffectivelyOnline ? 1 : 0.4)
|
||||||
|
}
|
||||||
|
.task { await refreshApiKeys() }
|
||||||
|
.sheet(isPresented: $isShowingCreateApiKey) {
|
||||||
|
createApiKeySheet
|
||||||
|
}
|
||||||
|
.sheet(item: $revealedApiKey) { revealed in
|
||||||
|
apiKeyRevealSheet(revealed)
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Delete API Key?",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { apiKeyPendingDeletion != nil },
|
||||||
|
set: { if !$0 { apiKeyPendingDeletion = nil } }
|
||||||
|
),
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
if let key = apiKeyPendingDeletion {
|
||||||
|
Task { await deleteApiKey(key) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) { apiKeyPendingDeletion = nil }
|
||||||
|
} message: {
|
||||||
|
if let name = apiKeyPendingDeletion?.name {
|
||||||
|
Text("Any scripts or integrations using \"\(name)\" will stop working immediately.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Manage API Keys on the Web", isPresented: $isShowingApiKeyWebOnlyNotice) {
|
||||||
|
Button("OK") {}
|
||||||
|
} message: {
|
||||||
|
Text("Creating and deleting personal API keys is only supported on the web version of Outline. This app can display your existing keys, but not create or delete them.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shown exactly once, immediately after creation — Outline never
|
||||||
|
/// returns the plaintext value again after this response (confirmed
|
||||||
|
/// live: `apiKeys.list` omits it), so this app enforces the same
|
||||||
|
/// "copy it now or lose it" rule the web app does, not just for show.
|
||||||
|
private func apiKeyRevealSheet(_ revealed: RevealedApiKey) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("API Key Created")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Copy this key now — treat it like a password. For security, it will only be shown this once.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text(revealed.value)
|
||||||
|
.font(.system(.callout, design: .monospaced))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
Spacer()
|
||||||
|
Button(didCopyRevealedKey ? "Copied" : "Copy") {
|
||||||
|
copyToPasteboard(revealed.value)
|
||||||
|
didCopyRevealedKey = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||||
|
|
||||||
|
Label(
|
||||||
|
"Be careful when handling your keys, as they allow full access to your data — treat them like passwords.",
|
||||||
|
systemImage: "exclamationmark.triangle.fill"
|
||||||
|
)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button(didCopyRevealedKey ? "Done" : "I've Copied It") {
|
||||||
|
revealedApiKey = nil
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(!didCopyRevealedKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.frame(width: 420)
|
||||||
|
.interactiveDismissDisabled(!didCopyRevealedKey)
|
||||||
|
// Belt-and-suspenders: however this sheet actually closes, the
|
||||||
|
// plaintext key is gone from memory the moment it's gone from
|
||||||
|
// screen — not just visually hidden behind dismissed UI state.
|
||||||
|
.onDisappear {
|
||||||
|
revealedApiKey = nil
|
||||||
|
didCopyRevealedKey = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copyToPasteboard(_ string: String) {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(string, forType: .string)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var createApiKeySheet: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("New API Key")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Name")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("e.g. My Script", text: $newApiKeyName)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.onSubmit { Task { await createApiKey() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Expiration")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Picker("Expiration", selection: $newApiKeyExpiration) {
|
||||||
|
ForEach(ApiKeyExpiration.allCases) { option in
|
||||||
|
Text(option.label).tag(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let createApiKeyErrorMessage {
|
||||||
|
Text(createApiKeyErrorMessage)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") {
|
||||||
|
isShowingCreateApiKey = false
|
||||||
|
newApiKeyName = ""
|
||||||
|
newApiKeyExpiration = .noExpiration
|
||||||
|
createApiKeyErrorMessage = nil
|
||||||
|
}
|
||||||
|
if isCreatingApiKey {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Button("Create") { Task { await createApiKey() } }
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(newApiKeyName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !isEffectivelyOnline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.frame(width: 360)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var apiKeysList: some View {
|
||||||
|
if isLoadingApiKeys && apiKeys.isEmpty {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else if let apiKeysErrorMessage {
|
||||||
|
Text(apiKeysErrorMessage)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
} else if apiKeys.isEmpty {
|
||||||
|
Text("No personal API keys yet.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
ForEach(apiKeys) { key in
|
||||||
|
apiKeyRow(key)
|
||||||
|
if key.id != apiKeys.last?.id {
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 480, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apiKeyRow(_ key: OutlineAPIKey) -> some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text(key.name)
|
||||||
|
.font(.callout.weight(.medium))
|
||||||
|
Spacer()
|
||||||
|
if let last4 = key.last4 {
|
||||||
|
Text("••••••••\(last4)")
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Created \(formattedDate(key.createdAt))\(key.lastActiveAt.map { " — last used \(formattedDate($0))" } ?? "")")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if deletingApiKeyId == key.id {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
// TODO: apiKeys.delete — same web-session-only limitation
|
||||||
|
// as create above, see that comment. Swap back to
|
||||||
|
// `apiKeyPendingDeletion = key` (the real confirmation
|
||||||
|
// dialog + deleteApiKey(_:) below are fully built and
|
||||||
|
// untouched) once native auth can actually call it.
|
||||||
|
Button {
|
||||||
|
isShowingApiKeyWebOnlyNotice = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: not currently reachable from the UI — apiKeys.create needs
|
||||||
|
// Outline's cookie+CSRF web session, confirmed this app's Bearer-token
|
||||||
|
// requests to it don't work. Kept intact (and covered by OutlineKit
|
||||||
|
// tests) for when native auth can support it; see the "New API Key…"
|
||||||
|
// button's own TODO for the reconnect point.
|
||||||
|
private func createApiKey() async {
|
||||||
|
guard let apiClient = session.apiClient else { return }
|
||||||
|
let trimmedName = newApiKeyName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedName.isEmpty else { return }
|
||||||
|
isCreatingApiKey = true
|
||||||
|
defer { isCreatingApiKey = false }
|
||||||
|
do {
|
||||||
|
let created = try await apiClient.createApiKey(
|
||||||
|
CreateApiKeyRequest(name: trimmedName, expiresAt: newApiKeyExpiration.expiresAtDate)
|
||||||
|
)
|
||||||
|
isShowingCreateApiKey = false
|
||||||
|
newApiKeyName = ""
|
||||||
|
newApiKeyExpiration = .noExpiration
|
||||||
|
createApiKeyErrorMessage = nil
|
||||||
|
// The plaintext `value` only ever exists on this one response —
|
||||||
|
// held only in `revealedApiKey`'s short lifetime, never merged
|
||||||
|
// into the persisted `apiKeys` list (refreshed from the server
|
||||||
|
// right after, which never returns it).
|
||||||
|
if let value = created.value {
|
||||||
|
revealedApiKey = RevealedApiKey(name: created.name, value: value)
|
||||||
|
}
|
||||||
|
await refreshApiKeys()
|
||||||
|
} catch {
|
||||||
|
createApiKeyErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create this API key.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: not currently reachable from the UI — same apiKeys.delete
|
||||||
|
// web-session-only limitation as createApiKey() above. Kept intact
|
||||||
|
// for the same reason; see the trash button's own TODO.
|
||||||
|
private func deleteApiKey(_ key: OutlineAPIKey) async {
|
||||||
|
guard let apiClient = session.apiClient else { return }
|
||||||
|
apiKeyPendingDeletion = nil
|
||||||
|
deletingApiKeyId = key.id
|
||||||
|
defer { deletingApiKeyId = nil }
|
||||||
|
do {
|
||||||
|
try await apiClient.deleteApiKey(id: key.id)
|
||||||
|
await refreshApiKeys()
|
||||||
|
} catch {
|
||||||
|
apiKeysErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete this API key.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshApiKeys() async {
|
||||||
|
guard let apiClient = session.apiClient else { return }
|
||||||
|
isLoadingApiKeys = true
|
||||||
|
defer { isLoadingApiKeys = false }
|
||||||
|
do {
|
||||||
|
apiKeys = try await apiClient.listApiKeys(ListApiKeysRequest())
|
||||||
|
apiKeysErrorMessage = nil
|
||||||
|
} catch {
|
||||||
|
apiKeysErrorMessage = outlineErrorMessage(error, fallback: "Couldn't load your API keys.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Offline & Sync
|
// MARK: - Offline & Sync
|
||||||
|
|
||||||
private var offlineSyncDetail: some View {
|
private var offlineSyncDetail: some View {
|
||||||
@@ -480,6 +1309,20 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// Everything backed by a live `users.update`/`users.notifications*`
|
||||||
|
/// call (Profile's name/avatar, all of Preferences, all of
|
||||||
|
/// Notifications) shows this and disables its controls while offline —
|
||||||
|
/// there's nothing to optimistically apply here the way document edits
|
||||||
|
/// can be, and no offline queue for it (see `TODO.local.md`'s own
|
||||||
|
/// "things that actually require an internet connection" framing,
|
||||||
|
/// already applied to Share/Permissions/Search). Local-only settings
|
||||||
|
/// (Appearance, Offline Mode itself) are deliberately left enabled.
|
||||||
|
private var offlineSettingsHint: some View {
|
||||||
|
Text("You're offline — these settings need a connection to change.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
|
||||||
private func labeledRow(_ label: String, _ value: String) -> some View {
|
private func labeledRow(_ label: String, _ value: String) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Text(label)
|
Text(label)
|
||||||
@@ -490,6 +1333,10 @@ struct SettingsView: View {
|
|||||||
.font(.callout)
|
.font(.callout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func formattedDate(_ date: Date) -> String {
|
||||||
|
date.formatted(date: .abbreviated, time: .omitted)
|
||||||
|
}
|
||||||
|
|
||||||
private func formattedBytes(_ bytes: Int) -> String {
|
private func formattedBytes(_ bytes: Int) -> String {
|
||||||
ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
|
ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
|
||||||
}
|
}
|
||||||
@@ -628,4 +1475,41 @@ private struct PickedPhoto: Identifiable {
|
|||||||
let id = UUID()
|
let id = UUID()
|
||||||
let image: NSImage
|
let image: NSImage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The one-time plaintext value from a just-created API key, plus enough
|
||||||
|
/// to label the reveal sheet — deliberately not `OutlineAPIKey` itself, so
|
||||||
|
/// nothing holding a reference to "the created key" for other purposes can
|
||||||
|
/// accidentally end up holding the secret too.
|
||||||
|
private struct RevealedApiKey: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let name: String
|
||||||
|
let value: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ApiKeyExpiration: String, CaseIterable, Identifiable {
|
||||||
|
case noExpiration, oneMonth, threeMonths, sixMonths, oneYear
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .noExpiration: return "No expiration"
|
||||||
|
case .oneMonth: return "1 month"
|
||||||
|
case .threeMonths: return "3 months"
|
||||||
|
case .sixMonths: return "6 months"
|
||||||
|
case .oneYear: return "1 year"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiresAtDate: Date? {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
switch self {
|
||||||
|
case .noExpiration: return nil
|
||||||
|
case .oneMonth: return calendar.date(byAdding: .month, value: 1, to: Date())
|
||||||
|
case .threeMonths: return calendar.date(byAdding: .month, value: 3, to: Date())
|
||||||
|
case .sixMonths: return calendar.date(byAdding: .month, value: 6, to: Date())
|
||||||
|
case .oneYear: return calendar.date(byAdding: .year, value: 1, to: Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -3,21 +3,17 @@ import SwiftUI
|
|||||||
struct AuthHeaderView: View {
|
struct AuthHeaderView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
ZStack {
|
// A plain Image Set, not the AppIcon *app icon* asset — App Icon
|
||||||
Circle()
|
// sets aren't reliably resolvable through Image(_:)/UIImage
|
||||||
.fill(
|
// (named:) at runtime (confirmed live: showed nothing). This is
|
||||||
LinearGradient(
|
// the same source artwork (outpost-ios-1024.png) duplicated
|
||||||
colors: [Color.accentColor, Color.accentColor.opacity(0.6)],
|
// into a normal image set so SwiftUI can actually load it.
|
||||||
startPoint: .topLeading,
|
Image("AppLogo")
|
||||||
endPoint: .bottomTrailing
|
.resizable()
|
||||||
)
|
.scaledToFit()
|
||||||
)
|
.frame(width: 72, height: 72)
|
||||||
.frame(width: 64, height: 64)
|
.clipShape(RoundedRectangle(cornerRadius: 72 * 0.2237, style: .continuous))
|
||||||
Image(systemName: "text.book.closed.fill")
|
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||||
.font(.system(size: 26, weight: .semibold))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
}
|
|
||||||
.shadow(color: Color.accentColor.opacity(0.35), radius: 12, y: 6)
|
|
||||||
|
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
Text("Welcome to Outpost")
|
Text("Welcome to Outpost")
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
|
|||||||
case general
|
case general
|
||||||
case account
|
case account
|
||||||
case workspace
|
case workspace
|
||||||
case integrationsInstallation
|
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
@@ -17,19 +16,20 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
|
|||||||
case .general: return "Outpost"
|
case .general: return "Outpost"
|
||||||
case .account: return "Account"
|
case .account: return "Account"
|
||||||
case .workspace: return "Workspace"
|
case .workspace: return "Workspace"
|
||||||
case .integrationsInstallation: return "Integrations & Installation"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One entry in the Settings sidebar. Mirrors Outline's own settings
|
/// One entry in the Settings sidebar. Mirrors Outline's own settings
|
||||||
/// categories (Account/Workspace/Integrations & Installation) so this app's
|
/// categories (Account/Workspace) so this app's settings read as a native
|
||||||
/// settings read as a native counterpart to the web app's, plus a `general`
|
/// counterpart to the web app's, plus a `general` group for things that are
|
||||||
/// group for things that are ours and don't map onto Outline's structure
|
/// ours and don't map onto Outline's structure (offline/sync, advanced,
|
||||||
/// (offline/sync, advanced, about, appearance).
|
/// about, appearance). Outline's own version info moved to the sidebar
|
||||||
|
/// footer (`SettingsSidebarList`) instead of a standalone
|
||||||
|
/// Integrations & Installation section.
|
||||||
///
|
///
|
||||||
/// Most of the Account/Workspace/Integrations cases are navigation-only for
|
/// Most of the Account/Workspace cases are navigation-only for now —
|
||||||
/// now — `SettingsView` renders a "Coming Soon" placeholder for anything not
|
/// `SettingsView` renders a "Coming Soon" placeholder for anything not
|
||||||
/// explicitly built yet. Content lands section by section.
|
/// explicitly built yet. Content lands section by section.
|
||||||
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||||
// General (ours)
|
// General (ours)
|
||||||
@@ -41,9 +41,6 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
// Workspace
|
// Workspace
|
||||||
case details, authentication, security, ai, members, groups, templates, emojis, applications, shared, links, webhooks, importData, exportData
|
case details, authentication, security, ai, members, groups, templates, emojis, applications, shared, links, webhooks, importData, exportData
|
||||||
|
|
||||||
// Integrations & Installation
|
|
||||||
case installation
|
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
var category: SettingsCategory {
|
var category: SettingsCategory {
|
||||||
@@ -54,8 +51,6 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
return .account
|
return .account
|
||||||
case .details, .authentication, .security, .ai, .members, .groups, .templates, .emojis, .applications, .shared, .links, .webhooks, .importData, .exportData:
|
case .details, .authentication, .security, .ai, .members, .groups, .templates, .emojis, .applications, .shared, .links, .webhooks, .importData, .exportData:
|
||||||
return .workspace
|
return .workspace
|
||||||
case .installation:
|
|
||||||
return .integrationsInstallation
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +79,6 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
case .webhooks: return "Webhooks"
|
case .webhooks: return "Webhooks"
|
||||||
case .importData: return "Import"
|
case .importData: return "Import"
|
||||||
case .exportData: return "Export"
|
case .exportData: return "Export"
|
||||||
case .installation: return "Installation"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,16 +107,15 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
case .webhooks: return "bolt.horizontal"
|
case .webhooks: return "bolt.horizontal"
|
||||||
case .importData: return "square.and.arrow.down"
|
case .importData: return "square.and.arrow.down"
|
||||||
case .exportData: return "square.and.arrow.up"
|
case .exportData: return "square.and.arrow.up"
|
||||||
case .installation: return "shippingbox"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything actually built so far — everything else in Account/
|
/// Everything actually built so far — everything else in Account/
|
||||||
/// Workspace/Integrations & Installation renders a "Coming Soon"
|
/// Workspace renders a "Coming Soon" placeholder until its content is
|
||||||
/// placeholder until its content is specified and built.
|
/// specified and built.
|
||||||
var isImplemented: Bool {
|
var isImplemented: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .appearance, .offlineSync, .advanced, .about, .profile:
|
case .appearance, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ final class SessionStore {
|
|||||||
var userName: String?
|
var userName: String?
|
||||||
var userEmail: String?
|
var userEmail: String?
|
||||||
var userAvatarURL: URL?
|
var userAvatarURL: URL?
|
||||||
|
var userLanguage: String?
|
||||||
|
var userPreferences: OutlineUserPreferences?
|
||||||
|
var userNotificationSettings: [String: Bool]?
|
||||||
var teamName: String?
|
var teamName: String?
|
||||||
var teamAvatarURL: URL?
|
var teamAvatarURL: URL?
|
||||||
private(set) var apiClient: OutlineAPIClient?
|
private(set) var apiClient: OutlineAPIClient?
|
||||||
@@ -36,11 +39,27 @@ final class SessionStore {
|
|||||||
init(tokenStore: TokenStoring = KeychainTokenStore(), defaults: UserDefaults = .standard) {
|
init(tokenStore: TokenStoring = KeychainTokenStore(), defaults: UserDefaults = .standard) {
|
||||||
self.tokenStore = tokenStore
|
self.tokenStore = tokenStore
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
self.isSignedIn = (try? tokenStore.token()) != nil
|
|
||||||
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
|
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
|
||||||
|
|
||||||
if isSignedIn, let serverURL {
|
let hasToken = (try? tokenStore.token()) != nil
|
||||||
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore)
|
let storedServerURL = defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
|
||||||
|
|
||||||
|
if hasToken, let storedServerURL {
|
||||||
|
isSignedIn = true
|
||||||
|
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore)
|
||||||
|
} else {
|
||||||
|
// Keychain and the sandboxed UserDefaults container don't
|
||||||
|
// always survive together — a Keychain item written by an
|
||||||
|
// older-signed build can outlive a reinstall that wipes the
|
||||||
|
// container (or vice versa), leaving a token with no server or
|
||||||
|
// a server with no token. Clear whichever half survived rather
|
||||||
|
// than showing a broken "signed in" UI with no working
|
||||||
|
// apiClient — a fresh sign-in rewrites both consistently.
|
||||||
|
if hasToken {
|
||||||
|
try? tokenStore.clear()
|
||||||
|
}
|
||||||
|
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
|
||||||
|
isSignedIn = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +92,9 @@ final class SessionStore {
|
|||||||
userName = nil
|
userName = nil
|
||||||
userEmail = nil
|
userEmail = nil
|
||||||
userAvatarURL = nil
|
userAvatarURL = nil
|
||||||
|
userLanguage = nil
|
||||||
|
userPreferences = nil
|
||||||
|
userNotificationSettings = nil
|
||||||
teamName = nil
|
teamName = nil
|
||||||
teamAvatarURL = nil
|
teamAvatarURL = nil
|
||||||
apiClient = nil
|
apiClient = nil
|
||||||
@@ -95,6 +117,9 @@ final class SessionStore {
|
|||||||
userId = user.id
|
userId = user.id
|
||||||
userName = user.name
|
userName = user.name
|
||||||
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||||
|
userLanguage = user.language
|
||||||
|
userPreferences = user.preferences
|
||||||
|
userNotificationSettings = user.notificationSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) {
|
private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) {
|
||||||
@@ -105,6 +130,9 @@ final class SessionStore {
|
|||||||
// (e.g. `/api/files.get?key=...`) for avatarUrl — resolve against the
|
// (e.g. `/api/files.get?key=...`) for avatarUrl — resolve against the
|
||||||
// configured server so relative paths don't fail as "unsupported URL".
|
// configured server so relative paths don't fail as "unsupported URL".
|
||||||
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||||
|
userLanguage = user.language
|
||||||
|
userPreferences = user.preferences
|
||||||
|
userNotificationSettings = user.notificationSettings
|
||||||
teamName = team.name
|
teamName = team.name
|
||||||
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Outline's interface-language options. Not exhaustive — Outline accepts
|
||||||
|
/// community translations via its own translation portal, so the real list
|
||||||
|
/// on any given server can be longer than this; this covers the common
|
||||||
|
/// cases and falls back to showing whatever code the server already has
|
||||||
|
/// set even if it isn't in this list.
|
||||||
|
struct OutlineLocale: Identifiable, Hashable {
|
||||||
|
let code: String
|
||||||
|
let label: String
|
||||||
|
|
||||||
|
var id: String { code }
|
||||||
|
|
||||||
|
static let all: [OutlineLocale] = [
|
||||||
|
OutlineLocale(code: "en_US", label: "English (US)"),
|
||||||
|
OutlineLocale(code: "en_GB", label: "English (UK)"),
|
||||||
|
OutlineLocale(code: "de_DE", label: "Deutsch"),
|
||||||
|
OutlineLocale(code: "fr_FR", label: "Français"),
|
||||||
|
OutlineLocale(code: "es_ES", label: "Español"),
|
||||||
|
OutlineLocale(code: "pt_PT", label: "Português"),
|
||||||
|
OutlineLocale(code: "pt_BR", label: "Português (Brasil)"),
|
||||||
|
OutlineLocale(code: "it_IT", label: "Italiano"),
|
||||||
|
OutlineLocale(code: "nl_NL", label: "Nederlands"),
|
||||||
|
OutlineLocale(code: "pl_PL", label: "Polski"),
|
||||||
|
OutlineLocale(code: "ru_RU", label: "Русский"),
|
||||||
|
OutlineLocale(code: "ja_JP", label: "日本語"),
|
||||||
|
OutlineLocale(code: "ko_KR", label: "한국어"),
|
||||||
|
OutlineLocale(code: "zh_CN", label: "中文 (简体)"),
|
||||||
|
OutlineLocale(code: "zh_TW", label: "中文 (繁體)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
static func label(for code: String) -> String {
|
||||||
|
all.first(where: { $0.code == code })?.label ?? code
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Single source of truth for how Outpost's own version is formatted —
|
||||||
|
/// used by both the About page and the Settings sidebar footer, so they
|
||||||
|
/// can't drift out of sync the way `AboutInfoView` was already written to
|
||||||
|
/// avoid for its own two call sites.
|
||||||
|
enum OutpostVersion {
|
||||||
|
/// Bumped alongside `MARKETING_VERSION` in the Xcode project — kept out
|
||||||
|
/// of the bundle version itself since `CFBundleShortVersionString` is
|
||||||
|
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
|
||||||
|
static let releaseStage = "ALPHA"
|
||||||
|
|
||||||
|
static var shortVersion: String {
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
|
||||||
|
}
|
||||||
|
|
||||||
|
static var buildNumber: String {
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// e.g. `"0.0.3-ALPHA"` — for compact display (sidebar footer).
|
||||||
|
static var displayString: String {
|
||||||
|
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
|
||||||
|
return "\(shortVersion)\(stageSuffix)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// e.g. `"Version 0.0.3-ALPHA (1)"` — for the About page.
|
||||||
|
static var fullVersionString: String {
|
||||||
|
"Version \(displayString) (\(buildNumber))"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user