Remember previous location (Preferences): persists collection + document chain (or Home) to UserDefaults on every navigation change, gated on the preference. Restored once per launch by resolving the stored IDs back through the API, stopping at the first failure (deleted doc, offline, etc.) rather than aborting the whole restore — a partial chain beats falling all the way back to Home. Cleared on sign-out so switching accounts can't restore a stale location. Command Palette (new Settings → Editor section, not synced to Outline): ⌘K opens a floating overlay, arrow-key/click navigation, Enter or click to select. Always searches locally, never a per-keystroke network request: - Lightweight (default): live listCollections + listViewedDocuments fetch once on open. - Full Workspace (opt-in, requires Full Local Sync on): reads CachingOutlineAPIClient's local cache directly — zero network calls, includes nested sub-documents now that Full Local Sync actually caches them (see the paired OutlineKit commit). Fixed through live testing, in order found: - Focus: the search field wasn't reliably first responder the instant ⌘K opened it (also the likely source of several AppKit CA-transaction console warnings) — added a short delay before focusing. - Full Workspace "no results": was sequential one-collection-at-a-time fetching before the cache-read redesign: withTaskGroup made it concurrent, and reading from the cache instead of the network made it moot. - Arrow keys not moving selection: .onKeyPress was on the outer card, but the focused TextField swallowed the events before they could bubble up. Moved the handlers directly onto the TextField. - Search ranking: exact-phrase-only matching meant a title like "Test Plan Document" never matched a "test document" query at all (filtered out, not just ranked low), making it look like collections always won. Added a fallback tier: every word of a multi-word query present anywhere in the title still matches, ranked below exact/prefix/substring hits. - foregroundStyle(.secondary vs .orange) ternary: HierarchicalShapeStyle vs Color type mismatch, fixed with AnyShapeStyle on both branches.
1576 lines
66 KiB
Swift
1576 lines
66 KiB
Swift
#if os(macOS)
|
|
import AppKit
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
import OutlineKit
|
|
|
|
/// Settings *detail* content for one section — the section list itself now
|
|
/// lives in `ContentView_macOS`'s real sidebar (swapped in over the
|
|
/// collections tree while `AppNavigation.isShowingSettings` is set, not a
|
|
/// separate mini sidebar of its own), so this view only ever renders
|
|
/// whichever section is currently selected.
|
|
struct SettingsView: View {
|
|
let section: SettingsSection
|
|
|
|
@Environment(SessionStore.self) private var session
|
|
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
|
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
|
|
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
|
|
@AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false
|
|
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
|
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
|
|
@AppStorage("outpost.advancedOptionsEnabled") private var isAdvancedOptionsEnabled = false
|
|
@State private var isShowingLogoutConfirmation = false
|
|
@State private var isShowingAdvancedWarning = false
|
|
@State private var storageSummary: CacheStorageSummary?
|
|
@State private var pendingOperations: [PendingOperationSummary] = []
|
|
@State private var isSyncing = false
|
|
@State private var isClearingCache = false
|
|
@State private var didClearCache = false
|
|
@State private var lastFullSyncSummary: FullSyncSummary?
|
|
@State private var lastFlushSummary: SyncFlushSummary?
|
|
@State private var pickedPhoto: PickedPhoto?
|
|
@State private var isUploadingAvatar = false
|
|
@State private var avatarErrorMessage: String?
|
|
@State private var editableName = ""
|
|
@State private var isSavingName = false
|
|
@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
|
|
/// safe — clearing while offline (or letting Full Local Sync think it
|
|
/// should be running) can leave the app with nothing local to show and
|
|
/// no way to refetch it. "Offline" here means either a real dropped
|
|
/// connection or the user's own manual toggle — both leave the app with
|
|
/// no server to talk to.
|
|
private var isEffectivelyOnline: Bool {
|
|
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
|
}
|
|
|
|
var body: some View {
|
|
// GeometryReader + `minHeight` (not `maxHeight`) is the actual fix for
|
|
// "center short content inside a ScrollView" — a ScrollView proposes
|
|
// effectively unbounded height to its content, so `maxHeight: .infinity`
|
|
// alone just resolves to the content's own intrinsic size and does
|
|
// nothing; forcing a `minHeight` equal to the real viewport height is
|
|
// what gives `aboutDetail`'s own centered alignment somewhere to
|
|
// actually center within. Harmless for the other (already
|
|
// top/leading-aligned) sections — short ones just get blank space
|
|
// below, same as before.
|
|
GeometryReader { geometry in
|
|
ScrollView {
|
|
sectionDetail
|
|
.padding(28)
|
|
.frame(maxWidth: .infinity, minHeight: geometry.size.height, alignment: .topLeading)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(.background)
|
|
.task { await refreshSyncState() }
|
|
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var sectionDetail: some View {
|
|
switch section {
|
|
case .appearance: appearanceDetail
|
|
case .editor: editorDetail
|
|
case .profile: profileDetail
|
|
case .preferences: preferencesDetail
|
|
case .notifications: notificationsDetail
|
|
case .passkeys: passkeysDetail
|
|
case .apiAccess: apiAccessDetail
|
|
case .offlineSync: offlineSyncDetail
|
|
case .advanced: advancedDetail
|
|
case .about: aboutDetail
|
|
default: comingSoonDetail
|
|
}
|
|
}
|
|
|
|
/// Everything in Account/Workspace that isn't `.profile` — real content
|
|
/// lands section by section; this is just the nav skeleton until then.
|
|
private var comingSoonDetail: some View {
|
|
VStack(spacing: 16) {
|
|
sectionHeader
|
|
ContentUnavailableView {
|
|
Label("Coming Soon", systemImage: section.icon)
|
|
} description: {
|
|
Text("\(section.title) settings aren't built yet.")
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
|
}
|
|
|
|
private var sectionHeader: some View {
|
|
Text(section.title)
|
|
.font(.title.bold())
|
|
}
|
|
|
|
// MARK: - Appearance
|
|
|
|
private var appearanceDetail: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
sectionHeader
|
|
Picker("Appearance", selection: $appearance) {
|
|
ForEach(AppAppearance.allCases) { option in
|
|
Text(option.label).tag(option)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
.frame(maxWidth: 320)
|
|
}
|
|
}
|
|
|
|
// MARK: - Editor
|
|
|
|
/// Local-only, device-side settings for how this app's own editor
|
|
/// behaves — not synced to Outline (unlike Preferences, which mirrors
|
|
/// server-side settings the web app also reads/writes). Same category
|
|
/// `.general`/"Outpost" as Appearance, for the same reason.
|
|
private var editorDetail: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
sectionHeader
|
|
Text("Settings for how documents are edited in this app.")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Toggle("Split View", isOn: $isSplitViewEnabled)
|
|
Text("Edit raw Markdown on the left with a live-updating preview on the right, instead of a single editable view.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: 480, alignment: .leading)
|
|
|
|
Divider().frame(maxWidth: 480)
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Toggle("Command Palette", isOn: $isCommandPaletteEnabled)
|
|
Text("Press ⌘K to quickly jump to a document or collection. Always searches locally on your device — never a network request while typing.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: 480, alignment: .leading)
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Toggle("Search Entire Workspace", isOn: $isCommandPaletteFullWorkspaceSearch)
|
|
.disabled(!isCommandPaletteEnabled || !isFullLocalSyncEnabled)
|
|
Text(fullWorkspaceSearchDescription)
|
|
.font(.caption)
|
|
.foregroundStyle(isFullLocalSyncEnabled ? AnyShapeStyle(.secondary) : AnyShapeStyle(Color.orange))
|
|
}
|
|
.frame(maxWidth: 480, alignment: .leading)
|
|
.opacity(isCommandPaletteEnabled ? 1 : 0.4)
|
|
}
|
|
}
|
|
|
|
/// Full Workspace mode reads Full Local Sync's own SwiftData cache
|
|
/// directly — zero network calls, and it's the only way to get nested
|
|
/// sub-documents included (the live per-collection fetch this used to
|
|
/// do could only ever see collection-root documents). Requires that
|
|
/// cache to actually exist first, so the toggle above stays disabled,
|
|
/// and this explains why, until Offline & Sync → Full Local Sync is on.
|
|
private var fullWorkspaceSearchDescription: String {
|
|
guard isFullLocalSyncEnabled else {
|
|
return "Requires Full Local Sync (Offline & Sync) — turn that on first so there's a local copy of your workspace to search."
|
|
}
|
|
return "Off (default): only your collections and recently viewed documents — near-instant. On: every document and collection from Full Local Sync's local copy, including nested sub-documents — entirely offline, no network request at all."
|
|
}
|
|
|
|
// MARK: - Profile
|
|
|
|
private var profileDetail: some View {
|
|
VStack(alignment: .leading, spacing: 24) {
|
|
sectionHeader
|
|
if !isEffectivelyOnline {
|
|
offlineSettingsHint
|
|
}
|
|
avatarRow
|
|
Divider().frame(maxWidth: 420)
|
|
nameRow
|
|
Divider().frame(maxWidth: 420)
|
|
emailRow
|
|
if let teamName = session.teamName {
|
|
Divider().frame(maxWidth: 420)
|
|
labeledRow("Workspace", teamName)
|
|
.frame(maxWidth: 420)
|
|
}
|
|
|
|
Button("Log Out…", role: .destructive) {
|
|
isShowingLogoutConfirmation = true
|
|
}
|
|
}
|
|
.sheet(item: $pickedPhoto) { picked in
|
|
AvatarCropperView(
|
|
sourceImage: picked.image,
|
|
onConfirm: { data in
|
|
pickedPhoto = nil
|
|
Task { await uploadAvatar(data: data) }
|
|
},
|
|
onCancel: { pickedPhoto = nil }
|
|
)
|
|
}
|
|
// The session only holds whatever was fetched at sign-in/launch —
|
|
// a name (or avatar) changed elsewhere (the Outline web app, say)
|
|
// never reaches it on its own. Refetching every time this page
|
|
// opens, rather than only once per app launch, is what makes that
|
|
// show up without having to quit and relaunch.
|
|
.task {
|
|
editableName = session.userName ?? ""
|
|
await refreshProfile()
|
|
}
|
|
.onChange(of: session.userName) { _, newValue in
|
|
editableName = newValue ?? ""
|
|
}
|
|
}
|
|
|
|
private var avatarRow: some View {
|
|
HStack(spacing: 16) {
|
|
AvatarBadge(avatarURL: session.userAvatarURL, size: 64, placeholderSystemImage: "person.crop.circle.fill")
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(spacing: 10) {
|
|
Button(isUploadingAvatar ? "Uploading…" : "Upload Photo…") { pickPhoto() }
|
|
.disabled(isUploadingAvatar)
|
|
if session.userAvatarURL != nil {
|
|
Button("Remove", role: .destructive) { Task { await removeAvatar() } }
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.red)
|
|
.disabled(isUploadingAvatar)
|
|
}
|
|
if isUploadingAvatar {
|
|
ProgressView().controlSize(.small)
|
|
}
|
|
}
|
|
.disabled(!isEffectivelyOnline)
|
|
if let avatarErrorMessage {
|
|
Text(avatarErrorMessage)
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var nameRow: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Name")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
HStack(spacing: 10) {
|
|
TextField("Name", text: $editableName)
|
|
.textFieldStyle(.roundedBorder)
|
|
.frame(maxWidth: 280)
|
|
.onSubmit { Task { await saveName() } }
|
|
if editableName != (session.userName ?? "") && !editableName.trimmingCharacters(in: .whitespaces).isEmpty {
|
|
if isSavingName {
|
|
ProgressView().controlSize(.small)
|
|
} else {
|
|
Button("Save") { Task { await saveName() } }
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
.disabled(!isEffectivelyOnline)
|
|
if let nameErrorMessage {
|
|
Text(nameErrorMessage)
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
.frame(maxWidth: 420, alignment: .leading)
|
|
}
|
|
|
|
private var emailRow: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
labeledRow("Email", session.userEmail ?? "—")
|
|
HStack(spacing: 4) {
|
|
Text("Email is tied to sign-in, so it can't be changed here — use")
|
|
if let serverURL = session.serverURL {
|
|
Link("Outline on the web", destination: serverURL)
|
|
} else {
|
|
Text("Outline on the web")
|
|
}
|
|
Text("instead.")
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.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
|
|
|
|
private var offlineSyncDetail: some View {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
sectionHeader
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
|
|
Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: 480, alignment: .leading)
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Toggle("Full Local Sync", isOn: $isFullLocalSyncEnabled)
|
|
.disabled(!isEffectivelyOnline)
|
|
Text("Keep a complete local copy of every collection and document, not just what's been opened — the whole workspace stays browsable offline. Runs automatically in the background once on; no need to trigger it by hand.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
if !isEffectivelyOnline {
|
|
Text("Requires an internet connection to turn on or off.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
}
|
|
.frame(maxWidth: 480, alignment: .leading)
|
|
.help(isEffectivelyOnline ? "" : "Full Local Sync needs a real connection — it can't safely turn on (or off) while offline.")
|
|
|
|
if isFullLocalSyncEnabled {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(spacing: 8) {
|
|
if isSyncing {
|
|
ProgressView().controlSize(.small)
|
|
Text("Syncing…")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
Button("Sync Now") { Task { await runFullSync() } }
|
|
.controlSize(.small)
|
|
.disabled(!isEffectivelyOnline)
|
|
if let lastFullSyncSummary {
|
|
Text(fullSyncSummaryText(lastFullSyncSummary))
|
|
.font(.caption)
|
|
.foregroundStyle(lastFullSyncSummary.errors.isEmpty ? Color.secondary : Color.red)
|
|
}
|
|
}
|
|
}
|
|
if let lastFullSyncSummary, !isSyncing {
|
|
ForEach(Array(lastFullSyncSummary.errors.enumerated()), id: \.offset) { _, message in
|
|
Text(message)
|
|
.font(.caption2)
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
.frame(maxWidth: 480)
|
|
|
|
pendingOperationsRow
|
|
.frame(maxWidth: 480, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
private var pendingOperationsRow: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Text("Pending Sync")
|
|
.font(.subheadline.weight(.medium))
|
|
Spacer()
|
|
if isSyncing {
|
|
ProgressView().controlSize(.small)
|
|
} else {
|
|
Button("Retry") { Task { await retrySync() } }
|
|
.buttonStyle(.plain)
|
|
.font(.caption)
|
|
.foregroundStyle(Color.accentColor)
|
|
.disabled(pendingOperations.isEmpty)
|
|
}
|
|
}
|
|
|
|
if pendingOperations.isEmpty {
|
|
Text("Everything's synced.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
ForEach(pendingOperations) { operation in
|
|
pendingOperationRow(operation)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func pendingOperationRow(_ operation: PendingOperationSummary) -> some View {
|
|
HStack(alignment: .top, spacing: 6) {
|
|
Image(systemName: operation.lastError == nil ? "clock" : "exclamationmark.triangle.fill")
|
|
.foregroundStyle(operation.lastError == nil ? Color.secondary : Color.orange)
|
|
.font(.caption)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(operationLabel(operation.kind))
|
|
.font(.caption)
|
|
if let lastError = operation.lastError {
|
|
Text(lastError)
|
|
.font(.caption2)
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func operationLabel(_ kind: String) -> String {
|
|
switch kind {
|
|
case "updateDocument": return "Document edit"
|
|
case "updateCollection": return "Collection rename"
|
|
case "createPin": return "Pin"
|
|
case "deletePin": return "Unpin"
|
|
case "createSubscription": return "Subscribe"
|
|
case "deleteSubscription": return "Unsubscribe"
|
|
case "starDocument", "starCollection": return "Star"
|
|
case "deleteStar": return "Unstar"
|
|
default: return kind
|
|
}
|
|
}
|
|
|
|
// MARK: - Advanced
|
|
|
|
/// Everything here either does something destructive (clearing the
|
|
/// cache Full Local Sync just spent minutes building) or doesn't exist
|
|
/// yet — gating all of it behind an off-by-default master toggle plus a
|
|
/// confirmation to turn that toggle on is the safeguard: nothing here
|
|
/// can be reached by accident, and nothing outside this section can
|
|
/// touch the cache at all, so there's no path to "messed up Full Local
|
|
/// Sync" that doesn't go through this screen on purpose.
|
|
private var advancedDetail: some View {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
sectionHeader
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Toggle("Enable Advanced Options", isOn: advancedOptionsBinding)
|
|
Text("Off by default on purpose. Turning this on unlocks things that can cause unintended behavior, including permanently losing your local cache.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: 480, alignment: .leading)
|
|
|
|
Divider()
|
|
.frame(maxWidth: 480)
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
comingSoonRow("Export All Data")
|
|
comingSoonRow("Developer Diagnostics")
|
|
comingSoonRow("Reset Local Database")
|
|
}
|
|
.frame(maxWidth: 480, alignment: .leading)
|
|
|
|
Divider()
|
|
.frame(maxWidth: 480)
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(spacing: 10) {
|
|
Text("Clear All Cache")
|
|
Spacer()
|
|
if didClearCache {
|
|
Label("Cleared", systemImage: "checkmark")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Button(role: .destructive) {
|
|
Task { await clearCache() }
|
|
} label: {
|
|
if isClearingCache {
|
|
ProgressView().controlSize(.small)
|
|
} else {
|
|
Text("Clear")
|
|
}
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(.red)
|
|
}
|
|
if let storageSummary {
|
|
Text("\(storageSummary.itemCount) items — \(formattedBytes(storageSummary.totalBytes))")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Text("Deletes every cached collection and document, including anything Full Local Sync built, and anything still waiting to sync. Doesn't touch the server. This is the only place that can — it works even while offline, which is exactly why it's behind this toggle.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.disabled(!isAdvancedOptionsEnabled || isClearingCache)
|
|
.opacity(isAdvancedOptionsEnabled ? 1 : 0.4)
|
|
.frame(maxWidth: 480, alignment: .leading)
|
|
}
|
|
.confirmationDialog(
|
|
"Enable Advanced Options?",
|
|
isPresented: $isShowingAdvancedWarning,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Enable", role: .destructive) { isAdvancedOptionsEnabled = true }
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("These settings can cause unintended behavior, including permanently losing your local cache. Only continue if you know what you're doing.")
|
|
}
|
|
}
|
|
|
|
/// Never writes `true` directly — turning the toggle on only opens the
|
|
/// warning dialog; only that dialog's own "Enable" button actually sets
|
|
/// it. Turning off doesn't need confirmation.
|
|
private var advancedOptionsBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { isAdvancedOptionsEnabled },
|
|
set: { newValue in
|
|
if newValue {
|
|
isShowingAdvancedWarning = true
|
|
} else {
|
|
isAdvancedOptionsEnabled = false
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private func comingSoonRow(_ title: String) -> some View {
|
|
HStack {
|
|
Text(title)
|
|
Spacer()
|
|
Text("Coming Soon")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.disabled(true)
|
|
.opacity(0.5)
|
|
}
|
|
|
|
// MARK: - About
|
|
|
|
private var aboutDetail: some View {
|
|
VStack(spacing: 16) {
|
|
sectionHeader
|
|
AboutInfoView()
|
|
.frame(maxWidth: 420)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
|
}
|
|
|
|
// 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 {
|
|
HStack {
|
|
Text(label)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(value)
|
|
}
|
|
.font(.callout)
|
|
}
|
|
|
|
private func formattedDate(_ date: Date) -> String {
|
|
date.formatted(date: .abbreviated, time: .omitted)
|
|
}
|
|
|
|
private func formattedBytes(_ bytes: Int) -> String {
|
|
ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
|
|
}
|
|
|
|
private func fullSyncSummaryText(_ summary: FullSyncSummary) -> String {
|
|
if summary.errors.isEmpty {
|
|
return "Synced \(summary.documentsCount) documents across \(summary.collectionsCount) collections."
|
|
}
|
|
return "Synced with \(summary.errors.count) error\(summary.errors.count == 1 ? "" : "s")."
|
|
}
|
|
|
|
private func refreshSyncState() async {
|
|
guard let cachingClient = session.cachingClient else { return }
|
|
storageSummary = await cachingClient.cacheStorageSummary()
|
|
pendingOperations = await cachingClient.pendingOperations()
|
|
}
|
|
|
|
private func runFullSync() async {
|
|
guard let cachingClient = session.cachingClient, !isSyncing else { return }
|
|
isSyncing = true
|
|
defer { isSyncing = false }
|
|
lastFullSyncSummary = await cachingClient.performFullSync()
|
|
await refreshSyncState()
|
|
}
|
|
|
|
private func retrySync() async {
|
|
guard let cachingClient = session.cachingClient, !isSyncing else { return }
|
|
isSyncing = true
|
|
defer { isSyncing = false }
|
|
lastFlushSummary = await cachingClient.flushPendingOperations()
|
|
await refreshSyncState()
|
|
}
|
|
|
|
private func clearCache() async {
|
|
guard let cachingClient = session.cachingClient else { return }
|
|
isClearingCache = true
|
|
defer { isClearingCache = false }
|
|
await cachingClient.clearCache()
|
|
await refreshSyncState()
|
|
didClearCache = true
|
|
Task {
|
|
try? await Task.sleep(for: .seconds(2))
|
|
didClearCache = false
|
|
}
|
|
}
|
|
|
|
// MARK: - Profile actions
|
|
|
|
private func refreshProfile() async {
|
|
guard let apiClient = session.apiClient else { return }
|
|
guard let fresh = try? await apiClient.currentUser() else { return }
|
|
session.applyUpdatedProfile(fresh)
|
|
}
|
|
|
|
private func pickPhoto() {
|
|
let panel = NSOpenPanel()
|
|
panel.allowedContentTypes = [.png, .jpeg, .heic, .image]
|
|
panel.allowsMultipleSelection = false
|
|
panel.canChooseDirectories = false
|
|
panel.canChooseFiles = true
|
|
guard panel.runModal() == .OK, let url = panel.url, let image = NSImage(contentsOf: url) else { return }
|
|
pickedPhoto = PickedPhoto(image: image)
|
|
}
|
|
|
|
private func uploadAvatar(data: Data) async {
|
|
guard let apiClient = session.apiClient, let userId = session.userId else { return }
|
|
isUploadingAvatar = true
|
|
defer { isUploadingAvatar = false }
|
|
let previousAttachmentId = attachmentId(from: session.userAvatarURL)
|
|
do {
|
|
let target = try await apiClient.createAttachment(
|
|
CreateAttachmentRequest(name: "avatar.jpg", contentType: "image/jpeg", size: data.count)
|
|
)
|
|
try await apiClient.uploadAttachmentFile(target, fileData: data)
|
|
let updated = try await apiClient.updateUserAvatar(
|
|
UpdateUserAvatarRequest(id: userId, avatarUrl: target.attachment.url)
|
|
)
|
|
session.applyUpdatedProfile(updated)
|
|
avatarErrorMessage = nil
|
|
// Best-effort — the new avatar is already live either way, this
|
|
// just stops the old upload from sitting around unreferenced.
|
|
if let previousAttachmentId {
|
|
try? await apiClient.deleteAttachment(id: previousAttachmentId)
|
|
}
|
|
} catch {
|
|
avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't upload this photo.")
|
|
}
|
|
}
|
|
|
|
private func removeAvatar() async {
|
|
guard let apiClient = session.apiClient, let userId = session.userId else { return }
|
|
isUploadingAvatar = true
|
|
defer { isUploadingAvatar = false }
|
|
let previousAttachmentId = attachmentId(from: session.userAvatarURL)
|
|
do {
|
|
let updated = try await apiClient.updateUserAvatar(UpdateUserAvatarRequest(id: userId, avatarUrl: nil))
|
|
session.applyUpdatedProfile(updated)
|
|
avatarErrorMessage = nil
|
|
if let previousAttachmentId {
|
|
try? await apiClient.deleteAttachment(id: previousAttachmentId)
|
|
}
|
|
} catch {
|
|
avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't remove this photo.")
|
|
}
|
|
}
|
|
|
|
/// The avatar URL is `/api/attachments.redirect?id=<attachment-id>` —
|
|
/// pulling the id back out of it is how the previous attachment gets
|
|
/// found for cleanup, since nothing else keeps a record of it.
|
|
private func attachmentId(from avatarURL: URL?) -> String? {
|
|
guard let avatarURL, let components = URLComponents(url: avatarURL, resolvingAgainstBaseURL: false) else {
|
|
return nil
|
|
}
|
|
return components.queryItems?.first(where: { $0.name == "id" })?.value
|
|
}
|
|
|
|
private func saveName() async {
|
|
let trimmed = editableName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty, trimmed != (session.userName ?? ""), let apiClient = session.apiClient, let userId = session.userId else { return }
|
|
isSavingName = true
|
|
defer { isSavingName = false }
|
|
do {
|
|
let updated = try await apiClient.updateUserName(UpdateUserNameRequest(id: userId, name: trimmed))
|
|
session.applyUpdatedProfile(updated)
|
|
editableName = updated.name
|
|
nameErrorMessage = nil
|
|
} catch {
|
|
nameErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update your name.")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Wraps a picked `NSImage` so it can drive `.sheet(item:)` — `NSImage`
|
|
/// itself isn't `Identifiable`.
|
|
private struct PickedPhoto: Identifiable {
|
|
let id = UUID()
|
|
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
|