15 Commits
Author SHA1 Message Date
Puranjay Savar MattasandClaude Sonnet 5 aaa6dbf966 fix(outlinekit): correct preferences wire keys to the real server shape
All preference toggles showed as off regardless of server state, and
saving notificationBadge 400'd with "notificationBadge: Invalid Input" —
the guessed wire keys/values from the earlier speculative commit were
wrong. Fixed against a live network capture of Outline's own web app
toggling every one of these settings:

- separateEditing -> seamlessEdit, and inverted (seamlessEdit is
  separate editing's negation, confirmed by toggling it live)
- showCommentMarker -> commentsInGutter
- smartText -> enableSmartText
- notificationBadge values -> "disabled"/"indicator"/"count", not the
  guessed "none"/"unread"/"all"
- rememberLastPath/useCursorPointer/codeBlockLineNumbers were already
  correct

Also preserves fullWidthDocuments (a real preference this app has no UI
for) on round-trip, since the client always sends the whole preferences
object back on save — dropping an unrecognized key during decode would
otherwise silently clear it the next time any toggle here gets saved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 21:35:17 +01:00
Puranjay Savar MattasandClaude Sonnet 5 b1681c8ffb fix(settings): language picker breaks on a server locale not in our list
Live warning: "en_GB" invalid tag, undefined Picker display — the curated
OutlineLocale.all didn't include it. Added en_GB explicitly, and made the
Picker's item list always include whatever code the server actually
reports (falling back to the code itself as the label) so any future
unlisted locale degrades gracefully instead of breaking the control.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 21:26:12 +01:00
Puranjay Savar MattasandClaude Sonnet 5 79aed97f7d feat(settings): Preferences delete-account action
Danger subsection, confirmation dialog before calling users.delete
(deleteAccount()), signs out locally on success. Closes out the
Preferences page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:31:50 +01:00
Puranjay Savar MattasandClaude Sonnet 5 7ff0f333c8 feat(settings): Preferences notification-badge picker
Closes out the Behavior subsection. Uses NotificationBadgeStyle
(None/Unread Indicator/Unread Count) — wire values are a guess same as
the other preference keys, flagged in OutlineUserPreferences.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:31:24 +01:00
Puranjay Savar MattasandClaude Sonnet 5 ead3fb4365 feat(settings): Preferences smart-text-replacements toggle
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:31:11 +01:00
Puranjay Savar MattasandClaude Sonnet 5 218da50769 feat(settings): Preferences remember-previous-location toggle
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:31:05 +01:00
Puranjay Savar MattasandClaude Sonnet 5 77bf75babb feat(settings): Preferences separate-editing toggle
Starts the Behavior subsection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:30:58 +01:00
Puranjay Savar MattasandClaude Sonnet 5 5a8fea73bf feat(settings): Preferences show-comment-marker toggle
Closes out the Display subsection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:30:51 +01:00
Puranjay Savar MattasandClaude Sonnet 5 55a4883f02 feat(settings): Preferences show-line-numbers toggle
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:30:44 +01:00
Puranjay Savar MattasandClaude Sonnet 5 48cb804cc9 feat(settings): Preferences use-pointer-cursor toggle
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:30:38 +01:00
Puranjay Savar MattasandClaude Sonnet 5 b6ed8ec996 feat(settings): shared save path for preference toggles
savePreference(_:) reads the current OutlineUserPreferences, flips one
field, sends the whole object via updateUserPreferences. Every Behavior/
Display toggle added next reuses this instead of its own copy of the
same read-modify-write.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:30:31 +01:00
Puranjay Savar MattasandClaude Sonnet 5 e694415b65 feat(settings): Preferences appearance setting
Reuses the existing local Appearance color-scheme picker instead of a
second divergent implementation — this has always been a device-side
preference in this app, not a server-synced one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:30:08 +01:00
Puranjay Savar MattasandClaude Sonnet 5 41be6e25b7 feat(settings): Preferences section skeleton + Language setting
Marks .preferences implemented, adds the section header/description and
a Display subsection. Language is real and wired end-to-end (users.update
language field, high confidence per Outline's public API docs) — the rest
of the toggles land in follow-up commits, one setting at a time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:29:54 +01:00
Puranjay Savar MattasandClaude Sonnet 5 e7cdc9f0b7 feat(session): track user language + preferences
Mirrors the existing name/avatar/id fields — Settings' Preferences page
needs somewhere to read current values from and applyUpdatedProfile
already refreshes everything else on save.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:29:06 +01:00
Puranjay Savar MattasandClaude Sonnet 5 353afd93fb feat(outlinekit): add language/preferences plumbing for Preferences settings
OutlineUser gains language + preferences fields, new updateUserLanguage/
updateUserPreferences/deleteAccount client methods. Preference wire keys
are best-effort against Outline's own naming, same speculative treatment
as OutlinePin/OutlineDocumentMember — expect a correction round once
tested against a live server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:28:38 +01:00
13 changed files with 640 additions and 2 deletions
@@ -355,6 +355,18 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
try await live.updateUserName(request) try await live.updateUserName(request)
} }
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
try await live.updateUserLanguage(request)
}
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
try await live.updateUserPreferences(request)
}
public func deleteAccount() async throws {
try await live.deleteAccount()
}
// MARK: - Sync management (Settings surface) // MARK: - Sync management (Settings surface)
public func pendingOperations() async -> [PendingOperationSummary] { public func pendingOperations() async -> [PendingOperationSummary] {
@@ -83,4 +83,12 @@ public protocol OutlineAPIClient: Sendable {
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser
/// `users.update`, name only. See `UpdateUserNameRequest`. /// `users.update`, name only. See `UpdateUserNameRequest`.
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser
/// `users.update`, language only. See `UpdateUserLanguageRequest`.
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser
/// `users.update`, preferences only. See `UpdateUserPreferencesRequest`.
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser
/// Backed by `users.delete` self-service account deletion, no
/// confirmation code param confirmed live, matches every other simple
/// no-body delete in this API.
func deleteAccount() async throws
} }
@@ -290,6 +290,18 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
try await post("users.update", body: request) try await post("users.update", body: request)
} }
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
try await post("users.update", body: request)
}
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
try await post("users.update", body: request)
}
public func deleteAccount() async throws {
try await postForSuccess("users.delete", body: EmptyParams())
}
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response { private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
guard let token = try? tokenStore.token() else { guard let token = try? tokenStore.token() else {
throw OutlineAPIError.tokenUnavailable throw OutlineAPIError.tokenUnavailable
@@ -6,18 +6,24 @@ public struct OutlineUser: Codable, Identifiable, Hashable, Sendable {
public let email: String? public let email: String?
public let avatarUrl: String? public let avatarUrl: String?
public let role: String? public let role: String?
public let language: String?
public let preferences: OutlineUserPreferences?
public init( public init(
id: String, id: String,
name: String, name: String,
email: String? = nil, email: String? = nil,
avatarUrl: String? = nil, avatarUrl: String? = nil,
role: String? = nil role: String? = nil,
language: String? = nil,
preferences: OutlineUserPreferences? = nil
) { ) {
self.id = id self.id = id
self.name = name self.name = name
self.email = email self.email = email
self.avatarUrl = avatarUrl self.avatarUrl = avatarUrl
self.role = role self.role = role
self.language = language
self.preferences = preferences
} }
} }
@@ -0,0 +1,109 @@
import Foundation
/// `User.preferences` a free-form JSON blob on Outline's own `User` row,
/// not a fixed-shape API resource. Wire key names below are confirmed
/// against a live server's own web app traffic (captured toggling every
/// Preferences setting one at a time), not guessed see the `CodingKeys`
/// mapping for the two that don't match this struct's own property names.
///
/// The server also validates `preferences` against a known key allowlist
/// and rejects the whole `users.update` call (not just the bad field) if
/// any key it doesn't recognize is present confirmed live via a
/// `"notificationBadge: Invalid Input"` error when an earlier, wrong value
/// was sent. That's also why `fullWidthDocuments` is kept here even though
/// this app has no UI for it yet: this app always sends the *whole*
/// preferences object back on every save (see
/// `UpdateUserPreferencesRequest`), so silently dropping an unknown key
/// during decode would permanently clear it the next time any other
/// preference here gets saved.
public struct OutlineUserPreferences: Codable, Hashable, Sendable {
public var rememberLastPath: Bool?
/// App-facing polarity: `true` means "separate editing mode is on"
/// the opposite of the wire's own `seamlessEdit` (seamless editing and
/// separate editing modes are each other's negation), inverted in
/// `init(from:)`/`encode(to:)` so nothing outside this file has to
/// remember that.
public var separateEditing: Bool?
public var useCursorPointer: Bool?
public var codeBlockLineNumbers: Bool?
public var showCommentMarker: Bool?
public var smartText: Bool?
/// One of `NotificationBadgeStyle`'s raw values.
public var notificationBadge: String?
/// No UI in this app yet preserved purely so saving any other
/// preference here doesn't clobber it. See the type doc comment.
public var fullWidthDocuments: Bool?
public init(
rememberLastPath: Bool? = nil,
separateEditing: Bool? = nil,
useCursorPointer: Bool? = nil,
codeBlockLineNumbers: Bool? = nil,
showCommentMarker: Bool? = nil,
smartText: Bool? = nil,
notificationBadge: String? = nil,
fullWidthDocuments: Bool? = nil
) {
self.rememberLastPath = rememberLastPath
self.separateEditing = separateEditing
self.useCursorPointer = useCursorPointer
self.codeBlockLineNumbers = codeBlockLineNumbers
self.showCommentMarker = showCommentMarker
self.smartText = smartText
self.notificationBadge = notificationBadge
self.fullWidthDocuments = fullWidthDocuments
}
private enum CodingKeys: String, CodingKey {
case rememberLastPath
case seamlessEdit
case useCursorPointer
case codeBlockLineNumbers
case commentsInGutter
case enableSmartText
case notificationBadge
case fullWidthDocuments
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
rememberLastPath = try container.decodeIfPresent(Bool.self, forKey: .rememberLastPath)
separateEditing = try container.decodeIfPresent(Bool.self, forKey: .seamlessEdit).map { !$0 }
useCursorPointer = try container.decodeIfPresent(Bool.self, forKey: .useCursorPointer)
codeBlockLineNumbers = try container.decodeIfPresent(Bool.self, forKey: .codeBlockLineNumbers)
showCommentMarker = try container.decodeIfPresent(Bool.self, forKey: .commentsInGutter)
smartText = try container.decodeIfPresent(Bool.self, forKey: .enableSmartText)
notificationBadge = try container.decodeIfPresent(String.self, forKey: .notificationBadge)
fullWidthDocuments = try container.decodeIfPresent(Bool.self, forKey: .fullWidthDocuments)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(rememberLastPath, forKey: .rememberLastPath)
try container.encodeIfPresent(separateEditing.map { !$0 }, forKey: .seamlessEdit)
try container.encodeIfPresent(useCursorPointer, forKey: .useCursorPointer)
try container.encodeIfPresent(codeBlockLineNumbers, forKey: .codeBlockLineNumbers)
try container.encodeIfPresent(showCommentMarker, forKey: .commentsInGutter)
try container.encodeIfPresent(smartText, forKey: .enableSmartText)
try container.encodeIfPresent(notificationBadge, forKey: .notificationBadge)
try container.encodeIfPresent(fullWidthDocuments, forKey: .fullWidthDocuments)
}
}
/// App-icon unread indicator style. Wire values confirmed live (captured
/// setting all three from Outline's own web app).
public enum NotificationBadgeStyle: String, CaseIterable, Identifiable, Sendable {
case none = "disabled"
case unreadIndicator = "indicator"
case unreadCount = "count"
public var id: String { rawValue }
public var label: String {
switch self {
case .none: return "None"
case .unreadIndicator: return "Unread Indicator"
case .unreadCount: return "Unread Count"
}
}
}
@@ -0,0 +1,12 @@
import Foundation
/// `users.update`, language only.
public struct UpdateUserLanguageRequest: Encodable, Sendable {
public let id: String
public let language: String
public init(id: String, language: String) {
self.id = id
self.language = language
}
}
@@ -0,0 +1,14 @@
import Foundation
/// `users.update`, preferences only. Sends the *whole* preferences object
/// back (not a single changed key) avoids needing to know whether the
/// server deep-merges a partial `preferences` body or replaces it outright.
public struct UpdateUserPreferencesRequest: Encodable, Sendable {
public let id: String
public let preferences: OutlineUserPreferences
public init(id: String, preferences: OutlineUserPreferences) {
self.id = id
self.preferences = preferences
}
}
@@ -94,6 +94,9 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
func deleteAttachment(id: String) async throws { throw NotStubbed() } func deleteAttachment(id: String) async throws { throw NotStubbed() }
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() } func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() } func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() }
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser { throw NotStubbed() }
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser { throw NotStubbed() }
func deleteAccount() async throws { throw NotStubbed() }
} }
private struct StubTransportError: Error {} private struct StubTransportError: Error {}
@@ -1074,6 +1074,127 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update") XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
} }
func testUpdateUserLanguageSendsRequestAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"language": "fr_FR"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserLanguage(UpdateUserLanguageRequest(id: "user-1", language: "fr_FR"))
XCTAssertEqual(user.language, "fr_FR")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
}
func testUpdateUserPreferencesSendsWholeObjectAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"preferences": { "rememberLastPath": true, "useCursorPointer": true }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let preferences = OutlineUserPreferences(rememberLastPath: true, useCursorPointer: true)
let user = try await client.updateUserPreferences(UpdateUserPreferencesRequest(id: "user-1", preferences: preferences))
XCTAssertEqual(user.preferences?.rememberLastPath, true)
XCTAssertEqual(user.preferences?.useCursorPointer, true)
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
let sentPreferences = sentBody?["preferences"] as? [String: Any]
XCTAssertEqual(sentPreferences?["rememberLastPath"] as? Bool, true)
XCTAssertEqual(sentPreferences?["useCursorPointer"] as? Bool, true)
}
/// Locks in the wire mapping confirmed against a live server's own web
/// app traffic: `seamlessEdit`/`commentsInGutter`/`enableSmartText` are
/// the real keys (not `separateEditing`/`showCommentMarker`/`smartText`
/// this struct exposes), and `seamlessEdit` is the *negation* of this
/// app's `separateEditing`.
func testOutlineUserPreferencesDecodesRealWireKeys() throws {
let json = """
{
"seamlessEdit": false,
"commentsInGutter": true,
"enableSmartText": true,
"rememberLastPath": true,
"useCursorPointer": true,
"codeBlockLineNumbers": false,
"notificationBadge": "indicator",
"fullWidthDocuments": true
}
""".data(using: .utf8)!
let preferences = try JSONDecoder().decode(OutlineUserPreferences.self, from: json)
XCTAssertEqual(preferences.separateEditing, true, "seamlessEdit: false means separate editing is ON")
XCTAssertEqual(preferences.showCommentMarker, true)
XCTAssertEqual(preferences.smartText, true)
XCTAssertEqual(preferences.rememberLastPath, true)
XCTAssertEqual(preferences.useCursorPointer, true)
XCTAssertEqual(preferences.codeBlockLineNumbers, false)
XCTAssertEqual(preferences.notificationBadge, "indicator")
XCTAssertEqual(preferences.fullWidthDocuments, true)
}
func testOutlineUserPreferencesEncodesRealWireKeysAndInvertsSeparateEditing() throws {
var preferences = OutlineUserPreferences()
preferences.separateEditing = true
preferences.showCommentMarker = false
preferences.smartText = true
preferences.fullWidthDocuments = true
let data = try JSONEncoder().encode(preferences)
let object = try JSONSerialization.jsonObject(with: data) as? [String: Any]
XCTAssertEqual(object?["seamlessEdit"] as? Bool, false, "separateEditing: true must encode as seamlessEdit: false")
XCTAssertEqual(object?["commentsInGutter"] as? Bool, false)
XCTAssertEqual(object?["enableSmartText"] as? Bool, true)
XCTAssertEqual(object?["fullWidthDocuments"] as? Bool, true)
XCTAssertNil(object?["separateEditing"], "must not leak this app's own field name onto the wire")
XCTAssertNil(object?["showCommentMarker"])
XCTAssertNil(object?["smartText"])
}
func testDeleteAccountSendsRequest() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.deleteAccount()
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.delete")
}
func testDeleteAttachmentSendsRequest() async throws { func testDeleteAttachmentSendsRequest() async throws {
let httpClient = MockHTTPClient() let httpClient = MockHTTPClient()
httpClient.responseData = """ httpClient.responseData = """
+298
View File
@@ -32,6 +32,13 @@ struct SettingsView: View {
@State private var editableName = "" @State private var editableName = ""
@State private var isSavingName = false @State private var isSavingName = false
@State private var nameErrorMessage: String? @State private var nameErrorMessage: String?
@State private var isSavingLanguage = false
@State private var languageErrorMessage: String?
@State private var isSavingPreferences = false
@State private var preferencesErrorMessage: String?
@State private var isShowingDeleteAccountConfirmation = false
@State private var isDeletingAccount = false
@State private var deleteAccountErrorMessage: String?
/// Full Local Sync and cache-clearing both need a real connection to be /// Full Local Sync and cache-clearing both need a real connection to be
/// safe clearing while offline (or letting Full Local Sync think it /// safe clearing while offline (or letting Full Local Sync think it
@@ -71,6 +78,7 @@ struct SettingsView: View {
switch section { switch section {
case .appearance: appearanceDetail case .appearance: appearanceDetail
case .profile: profileDetail case .profile: profileDetail
case .preferences: preferencesDetail
case .offlineSync: offlineSyncDetail case .offlineSync: offlineSyncDetail
case .advanced: advancedDetail case .advanced: advancedDetail
case .about: aboutDetail case .about: aboutDetail
@@ -231,6 +239,296 @@ struct SettingsView: View {
.frame(maxWidth: 420, alignment: .leading) .frame(maxWidth: 420, alignment: .leading)
} }
// MARK: - Preferences
private var preferencesDetail: some View {
VStack(alignment: .leading, spacing: 24) {
sectionHeader
Text("Manage settings that affect your personal experience.")
.font(.subheadline)
.foregroundStyle(.secondary)
preferencesSubsection("Display") {
languageRow
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 } } }
)
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 } } }
)
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 } } }
)
}
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
}
if let preferencesErrorMessage {
Text(preferencesErrorMessage)
.font(.caption)
.foregroundStyle(.red)
}
preferencesSubsection("Danger") {
deleteAccountRow
}
}
.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: - Offline & Sync // MARK: - Offline & Sync
private var offlineSyncDetail: some View { private var offlineSyncDetail: some View {
+1 -1
View File
@@ -122,7 +122,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
/// placeholder until its content is specified and built. /// placeholder until its content is specified and built.
var isImplemented: Bool { var isImplemented: Bool {
switch self { switch self {
case .appearance, .offlineSync, .advanced, .about, .profile: case .appearance, .offlineSync, .advanced, .about, .profile, .preferences:
return true return true
default: default:
return false return false
+8
View File
@@ -15,6 +15,8 @@ final class SessionStore {
var userName: String? var userName: String?
var userEmail: String? var userEmail: String?
var userAvatarURL: URL? var userAvatarURL: URL?
var userLanguage: String?
var userPreferences: OutlineUserPreferences?
var teamName: String? var teamName: String?
var teamAvatarURL: URL? var teamAvatarURL: URL?
private(set) var apiClient: OutlineAPIClient? private(set) var apiClient: OutlineAPIClient?
@@ -73,6 +75,8 @@ final class SessionStore {
userName = nil userName = nil
userEmail = nil userEmail = nil
userAvatarURL = nil userAvatarURL = nil
userLanguage = nil
userPreferences = nil
teamName = nil teamName = nil
teamAvatarURL = nil teamAvatarURL = nil
apiClient = nil apiClient = nil
@@ -95,6 +99,8 @@ final class SessionStore {
userId = user.id userId = user.id
userName = user.name userName = user.name
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL } userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
userLanguage = user.language
userPreferences = user.preferences
} }
private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) { private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) {
@@ -105,6 +111,8 @@ final class SessionStore {
// (e.g. `/api/files.get?key=...`) for avatarUrl resolve against the // (e.g. `/api/files.get?key=...`) for avatarUrl resolve against the
// configured server so relative paths don't fail as "unsupported URL". // configured server so relative paths don't fail as "unsupported URL".
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL } userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
userLanguage = user.language
userPreferences = user.preferences
teamName = team.name teamName = team.name
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL } teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
} }
+35
View File
@@ -0,0 +1,35 @@
import Foundation
/// Outline's interface-language options. Not exhaustive Outline accepts
/// community translations via its own translation portal, so the real list
/// on any given server can be longer than this; this covers the common
/// cases and falls back to showing whatever code the server already has
/// set even if it isn't in this list.
struct OutlineLocale: Identifiable, Hashable {
let code: String
let label: String
var id: String { code }
static let all: [OutlineLocale] = [
OutlineLocale(code: "en_US", label: "English (US)"),
OutlineLocale(code: "en_GB", label: "English (UK)"),
OutlineLocale(code: "de_DE", label: "Deutsch"),
OutlineLocale(code: "fr_FR", label: "Français"),
OutlineLocale(code: "es_ES", label: "Español"),
OutlineLocale(code: "pt_PT", label: "Português"),
OutlineLocale(code: "pt_BR", label: "Português (Brasil)"),
OutlineLocale(code: "it_IT", label: "Italiano"),
OutlineLocale(code: "nl_NL", label: "Nederlands"),
OutlineLocale(code: "pl_PL", label: "Polski"),
OutlineLocale(code: "ru_RU", label: "Русский"),
OutlineLocale(code: "ja_JP", label: "日本語"),
OutlineLocale(code: "ko_KR", label: "한국어"),
OutlineLocale(code: "zh_CN", label: "中文 (简体)"),
OutlineLocale(code: "zh_TW", label: "中文 (繁體)"),
]
static func label(for code: String) -> String {
all.first(where: { $0.code == code })?.label ?? code
}
}