1123 lines
47 KiB
Swift
1123 lines
47 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(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?
|
|
|
|
/// 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 .profile: profileDetail
|
|
case .preferences: preferencesDetail
|
|
case .notifications: notificationsDetail
|
|
case .offlineSync: offlineSyncDetail
|
|
case .advanced: advancedDetail
|
|
case .about: aboutDetail
|
|
default: comingSoonDetail
|
|
}
|
|
}
|
|
|
|
/// Everything in Account/Workspace/Integrations & Installation 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: - 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) } }
|
|
)
|
|
|
|
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: - 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 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
|
|
}
|
|
#endif
|