Compare commits
15
Commits
cd6277f5aa
...
aaa6dbf966
@@ -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 = """
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user