Compare commits
24
Commits
@@ -335,6 +335,38 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
try await live.currentUser()
|
||||
}
|
||||
|
||||
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
|
||||
try await live.createAttachment(request)
|
||||
}
|
||||
|
||||
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
|
||||
try await live.uploadAttachmentFile(result, fileData: fileData)
|
||||
}
|
||||
|
||||
public func deleteAttachment(id: String) async throws {
|
||||
try await live.deleteAttachment(id: id)
|
||||
}
|
||||
|
||||
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
|
||||
try await live.updateUserAvatar(request)
|
||||
}
|
||||
|
||||
public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser {
|
||||
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)
|
||||
|
||||
public func pendingOperations() async -> [PendingOperationSummary] {
|
||||
|
||||
@@ -69,4 +69,26 @@ public protocol OutlineAPIClient: Sendable {
|
||||
func deleteStar(id: String) async throws
|
||||
|
||||
func currentUser() async throws -> OutlineUser
|
||||
|
||||
/// Two-step presigned upload: this requests where/how to upload,
|
||||
/// `uploadAttachmentFile` performs the actual multipart POST to that
|
||||
/// target. See `OutlineAttachment`/`CreateAttachmentResult`.
|
||||
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult
|
||||
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws
|
||||
/// Best-effort — matches the shape every other simple `id`-only delete
|
||||
/// in this API uses (`pins.delete`, `stars.delete`, …), not confirmed
|
||||
/// against a live server specifically for attachments yet.
|
||||
func deleteAttachment(id: String) async throws
|
||||
/// `users.update`, avatar only. See `UpdateUserAvatarRequest`.
|
||||
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser
|
||||
/// `users.update`, name only. See `UpdateUserNameRequest`.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -229,6 +229,79 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
||||
try await post("users.info", body: EmptyParams())
|
||||
}
|
||||
|
||||
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
|
||||
try await post("attachments.create", body: request)
|
||||
}
|
||||
|
||||
/// No Bearer/CSRF header attached here, deliberately — the presigned
|
||||
/// `form.sig` field (short-lived, scoped to this exact upload key) is
|
||||
/// what authorizes this specific request, the same way an S3 presigned
|
||||
/// POST works. Best-effort against a live server: if it turns out the
|
||||
/// self-hosted local-storage backend also wants a bearer token here,
|
||||
/// that's a one-line addition once confirmed, not a design change.
|
||||
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
|
||||
let (body, contentType) = MultipartFormDataBuilder.build(
|
||||
fields: result.form,
|
||||
fileFieldName: "file",
|
||||
fileName: result.attachment.name ?? "avatar",
|
||||
fileData: fileData,
|
||||
fileContentType: result.form["Content-Type"] ?? "application/octet-stream"
|
||||
)
|
||||
|
||||
// `uploadUrl` is a host-relative path (e.g. "/api/files.create") on
|
||||
// a self-hosted local-storage backend, not an absolute S3 URL —
|
||||
// resolving against `baseURL` handles both: `URL(string:relativeTo:)`
|
||||
// replaces the whole path for a leading-slash relative string per
|
||||
// RFC 3986, same resolution already used for user/team avatar URLs.
|
||||
guard let uploadURL = URL(string: result.uploadUrl, relativeTo: baseURL)?.absoluteURL else {
|
||||
throw OutlineAPIError.transport(URLError(.badURL))
|
||||
}
|
||||
|
||||
var request = URLRequest(url: uploadURL)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = body
|
||||
|
||||
let data: Data
|
||||
let response: HTTPURLResponse
|
||||
do {
|
||||
(data, response) = try await httpClient.send(request)
|
||||
} catch let error as OutlineAPIError {
|
||||
throw error
|
||||
} catch {
|
||||
throw OutlineAPIError.transport(error)
|
||||
}
|
||||
|
||||
guard (200...299).contains(response.statusCode) else {
|
||||
let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data)
|
||||
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
|
||||
}
|
||||
}
|
||||
|
||||
public func deleteAttachment(id: String) async throws {
|
||||
try await postForSuccess("attachments.delete", body: StarIDParams(id: id))
|
||||
}
|
||||
|
||||
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
|
||||
try await post("users.update", body: request)
|
||||
}
|
||||
|
||||
public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser {
|
||||
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 {
|
||||
guard let token = try? tokenStore.token() else {
|
||||
throw OutlineAPIError.tokenUnavailable
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/// One uploaded file — created via a two-step presigned upload
|
||||
/// (`attachments.create` for the upload target, then a direct POST to
|
||||
/// `uploadUrl`/`form`). Confirmed live against a self-hosted instance's
|
||||
/// local-storage backend; `size` comes back as a string there, not a
|
||||
/// number — same "don't trust the vendored spec's implied types" lesson as
|
||||
/// everywhere else this codebase has hit it.
|
||||
public struct OutlineAttachment: Decodable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let documentId: String?
|
||||
public let contentType: String?
|
||||
public let name: String?
|
||||
public let url: String?
|
||||
public let size: String?
|
||||
}
|
||||
|
||||
/// `attachments.create`'s response — everything needed to perform the
|
||||
/// actual upload. `form` fields (Content-Type, key, acl, sig, `_csrf`, …)
|
||||
/// must all be included as their own multipart parts, in the same request
|
||||
/// as the file itself, POSTed to `uploadUrl`.
|
||||
public struct CreateAttachmentResult: Decodable, Sendable {
|
||||
public let attachment: OutlineAttachment
|
||||
public let uploadUrl: String
|
||||
public let form: [String: String]
|
||||
}
|
||||
@@ -6,18 +6,24 @@ public struct OutlineUser: Codable, Identifiable, Hashable, Sendable {
|
||||
public let email: String?
|
||||
public let avatarUrl: String?
|
||||
public let role: String?
|
||||
public let language: String?
|
||||
public let preferences: OutlineUserPreferences?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
email: String? = nil,
|
||||
avatarUrl: String? = nil,
|
||||
role: String? = nil
|
||||
role: String? = nil,
|
||||
language: String? = nil,
|
||||
preferences: OutlineUserPreferences? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.email = email
|
||||
self.avatarUrl = avatarUrl
|
||||
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,40 @@
|
||||
import Foundation
|
||||
|
||||
/// Builds the multipart/form-data body for Outline's presigned-upload
|
||||
/// targets (`attachments.create`'s `uploadUrl`/`form`) — an S3-style
|
||||
/// presigned POST: every `form` field has to ride along as its own part in
|
||||
/// the same request as the file, not as query params or headers.
|
||||
enum MultipartFormDataBuilder {
|
||||
static func build(
|
||||
fields: [String: String],
|
||||
fileFieldName: String,
|
||||
fileName: String,
|
||||
fileData: Data,
|
||||
fileContentType: String,
|
||||
boundary: String = "Boundary-\(UUID().uuidString)"
|
||||
) -> (body: Data, contentType: String) {
|
||||
var body = Data()
|
||||
|
||||
for (key, value) in fields {
|
||||
body.append("--\(boundary)\r\n".utf8Data)
|
||||
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".utf8Data)
|
||||
body.append(value.utf8Data)
|
||||
body.append("\r\n".utf8Data)
|
||||
}
|
||||
|
||||
body.append("--\(boundary)\r\n".utf8Data)
|
||||
body.append(
|
||||
"Content-Disposition: form-data; name=\"\(fileFieldName)\"; filename=\"\(fileName)\"\r\n".utf8Data
|
||||
)
|
||||
body.append("Content-Type: \(fileContentType)\r\n\r\n".utf8Data)
|
||||
body.append(fileData)
|
||||
body.append("\r\n".utf8Data)
|
||||
body.append("--\(boundary)--\r\n".utf8Data)
|
||||
|
||||
return (body, "multipart/form-data; boundary=\(boundary)")
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var utf8Data: Data { Data(utf8) }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
/// Requests an upload target for a new file — not the upload itself, see
|
||||
/// `OutlineAPIClient.uploadAttachmentFile`. `documentId: nil` is what a
|
||||
/// user-avatar upload sends (confirmed live); a real value scopes the
|
||||
/// attachment to a document instead (e.g. an inline image embed).
|
||||
public struct CreateAttachmentRequest: Encodable, Sendable {
|
||||
public let name: String
|
||||
public let contentType: String
|
||||
public let size: Int
|
||||
public let documentId: String?
|
||||
|
||||
public init(name: String, contentType: String, size: Int, documentId: String? = nil) {
|
||||
self.name = name
|
||||
self.contentType = contentType
|
||||
self.size = size
|
||||
self.documentId = documentId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
/// `users.update`, scoped to just the avatar. Custom `encode(to:)` because
|
||||
/// Swift's synthesized `Encodable` uses `encodeIfPresent` for `Optional`
|
||||
/// properties, which *omits* the key entirely when the value is `nil` —
|
||||
/// removing the avatar needs a literal `"avatarUrl": null` in the request
|
||||
/// body, not the key missing. `id` is required: confirmed live (the
|
||||
/// captured request body's length only matches `{"id": "...", "avatarUrl":
|
||||
/// ...}`, not a shorter shape without it).
|
||||
public struct UpdateUserAvatarRequest: Encodable, Sendable {
|
||||
public let id: String
|
||||
public let avatarUrl: String?
|
||||
|
||||
public init(id: String, avatarUrl: String?) {
|
||||
self.id = id
|
||||
self.avatarUrl = avatarUrl
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, avatarUrl
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(avatarUrl, forKey: .avatarUrl)
|
||||
}
|
||||
}
|
||||
@@ -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,12 @@
|
||||
import Foundation
|
||||
|
||||
/// `users.update`, name only.
|
||||
public struct UpdateUserNameRequest: Encodable, Sendable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
|
||||
public init(id: String, name: String) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,14 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
|
||||
func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { throw NotStubbed() }
|
||||
func deleteStar(id: String) async throws { throw NotStubbed() }
|
||||
func currentUser() async throws -> OutlineUser { throw NotStubbed() }
|
||||
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() }
|
||||
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws { throw NotStubbed() }
|
||||
func deleteAttachment(id: String) async throws { throw NotStubbed() }
|
||||
func updateUserAvatar(_ request: UpdateUserAvatarRequest) 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 {}
|
||||
|
||||
@@ -993,6 +993,299 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.list")
|
||||
}
|
||||
|
||||
func testUpdateUserAvatarSendsExplicitNullWhenRemoving() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "user-1",
|
||||
"name": "Jane Doe",
|
||||
"avatarUrl": null
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let user = try await client.updateUserAvatar(UpdateUserAvatarRequest(id: "user-1", avatarUrl: nil))
|
||||
|
||||
XCTAssertNil(user.avatarUrl)
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
|
||||
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
|
||||
let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8))
|
||||
// The whole point of UpdateUserAvatarRequest's custom encode(to:) —
|
||||
// Swift's synthesized Encodable would have omitted the key entirely
|
||||
// for a nil Optional instead of sending a literal null.
|
||||
XCTAssertTrue(sentJSON.contains("\"avatarUrl\":null"), "expected an explicit null, got: \(sentJSON)")
|
||||
}
|
||||
|
||||
func testUpdateUserAvatarSendsTheNewURLWhenSet() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "user-1",
|
||||
"name": "Jane Doe",
|
||||
"avatarUrl": "/api/attachments.redirect?id=abc"
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let user = try await client.updateUserAvatar(
|
||||
UpdateUserAvatarRequest(id: "user-1", avatarUrl: "/api/attachments.redirect?id=abc")
|
||||
)
|
||||
|
||||
XCTAssertEqual(user.avatarUrl, "/api/attachments.redirect?id=abc")
|
||||
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
|
||||
let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8))
|
||||
XCTAssertTrue(sentJSON.contains("\"id\":\"user-1\""))
|
||||
}
|
||||
|
||||
func testUpdateUserNameSendsRequestAndDecodesResult() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "user-1",
|
||||
"name": "New Name"
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let user = try await client.updateUserName(UpdateUserNameRequest(id: "user-1", name: "New Name"))
|
||||
|
||||
XCTAssertEqual(user.name, "New Name")
|
||||
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 {
|
||||
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.deleteAttachment(id: "attach-1")
|
||||
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.delete")
|
||||
}
|
||||
|
||||
func testCreateAttachmentDecodesUploadTargetAndFormFields() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"attachment": {
|
||||
"id": "attach-1",
|
||||
"documentId": null,
|
||||
"contentType": "image/jpeg",
|
||||
"name": "avatar.jpg",
|
||||
"url": "/api/attachments.redirect?id=attach-1",
|
||||
"size": "59304"
|
||||
},
|
||||
"uploadUrl": "/api/files.create",
|
||||
"form": {
|
||||
"Content-Type": "image/jpeg",
|
||||
"key": "uploads/user-1/attach-1/avatar.jpg",
|
||||
"acl": "public-read"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let result = try await client.createAttachment(
|
||||
CreateAttachmentRequest(name: "avatar.jpg", contentType: "image/jpeg", size: 59304)
|
||||
)
|
||||
|
||||
XCTAssertEqual(result.attachment.id, "attach-1")
|
||||
XCTAssertEqual(result.attachment.size, "59304")
|
||||
XCTAssertEqual(result.uploadUrl, "/api/files.create")
|
||||
XCTAssertEqual(result.form["acl"], "public-read")
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.create")
|
||||
}
|
||||
|
||||
func testUploadAttachmentFilePostsToTheResolvedRelativeURL() 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
|
||||
)
|
||||
|
||||
let uploadTarget = CreateAttachmentResult(
|
||||
attachment: OutlineAttachment(
|
||||
id: "attach-1",
|
||||
documentId: nil,
|
||||
contentType: "image/jpeg",
|
||||
name: "avatar.jpg",
|
||||
url: "/api/attachments.redirect?id=attach-1",
|
||||
size: "3"
|
||||
),
|
||||
uploadUrl: "/api/files.create",
|
||||
form: ["Content-Type": "image/jpeg", "key": "uploads/attach-1"]
|
||||
)
|
||||
|
||||
try await client.uploadAttachmentFile(uploadTarget, fileData: Data("abc".utf8))
|
||||
|
||||
// Resolved against baseURL's host, not appended onto "/api/<baseURL-relative-path>".
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.absoluteString, "https://outline.example.com/api/files.create")
|
||||
XCTAssertNil(httpClient.lastRequest?.value(forHTTPHeaderField: "Authorization"))
|
||||
let contentType = httpClient.lastRequest?.value(forHTTPHeaderField: "Content-Type")
|
||||
XCTAssertTrue(contentType?.hasPrefix("multipart/form-data; boundary=") ?? false)
|
||||
}
|
||||
|
||||
func testMissingTokenThrowsTokenUnavailable() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
let client = LiveOutlineAPIClient(
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import XCTest
|
||||
@testable import OutlineKit
|
||||
|
||||
final class MultipartFormDataBuilderTests: XCTestCase {
|
||||
func testBuildIncludesEveryFieldAndTheFile() throws {
|
||||
let fileData = Data("fake-jpeg-bytes".utf8)
|
||||
let (body, contentType) = MultipartFormDataBuilder.build(
|
||||
fields: ["key": "uploads/abc", "acl": "public-read", "Content-Type": "image/jpeg"],
|
||||
fileFieldName: "file",
|
||||
fileName: "avatar.jpg",
|
||||
fileData: fileData,
|
||||
fileContentType: "image/jpeg",
|
||||
boundary: "TestBoundary"
|
||||
)
|
||||
|
||||
let bodyString = String(decoding: body, as: UTF8.self)
|
||||
|
||||
XCTAssertEqual(contentType, "multipart/form-data; boundary=TestBoundary")
|
||||
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"key\""))
|
||||
XCTAssertTrue(bodyString.contains("uploads/abc"))
|
||||
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"acl\""))
|
||||
XCTAssertTrue(bodyString.contains("public-read"))
|
||||
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"file\"; filename=\"avatar.jpg\""))
|
||||
XCTAssertTrue(bodyString.contains("fake-jpeg-bytes"))
|
||||
XCTAssertTrue(bodyString.hasPrefix("--TestBoundary\r\n"))
|
||||
XCTAssertTrue(bodyString.hasSuffix("--TestBoundary--\r\n"))
|
||||
}
|
||||
|
||||
func testFileFieldComesAfterAllFormFields() throws {
|
||||
let (body, _) = MultipartFormDataBuilder.build(
|
||||
fields: ["a": "1", "b": "2"],
|
||||
fileFieldName: "file",
|
||||
fileName: "x.jpg",
|
||||
fileData: Data("bytes".utf8),
|
||||
fileContentType: "image/jpeg",
|
||||
boundary: "B"
|
||||
)
|
||||
let bodyString = String(decoding: body, as: UTF8.self)
|
||||
|
||||
let fieldsRange = bodyString.range(of: "name=\"a\"")
|
||||
let fileRange = bodyString.range(of: "name=\"file\"")
|
||||
XCTAssertNotNil(fieldsRange)
|
||||
XCTAssertNotNil(fileRange)
|
||||
if let fieldsRange, let fileRange {
|
||||
XCTAssertTrue(fieldsRange.lowerBound < fileRange.lowerBound)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Crop/rotate/zoom editor shown after picking a photo, before it's
|
||||
/// uploaded — pan (drag), zoom (pinch or the slider), and 90°-increment
|
||||
/// rotate, all inside a circular mask matching how the avatar actually
|
||||
/// renders everywhere else in the app.
|
||||
///
|
||||
/// The on-screen preview and the final exported image are built from the
|
||||
/// exact same view composition (`avatarContent`), just instantiated once
|
||||
/// for display and once inside an `ImageRenderer` — that's deliberate:
|
||||
/// hand-deriving a separate set of crop-math for a higher-resolution
|
||||
/// render would risk it silently disagreeing with what the user actually
|
||||
/// saw and confirmed, and there's no way to visually verify that
|
||||
/// agreement without running the app. Reusing the identical view tree
|
||||
/// makes the export WYSIWYG by construction instead of by careful math.
|
||||
struct AvatarCropperView: View {
|
||||
let sourceImage: NSImage
|
||||
let onConfirm: (Data) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@State private var scale: CGFloat = 1
|
||||
@State private var offset: CGSize = .zero
|
||||
@State private var rotationDegrees: Double = 0
|
||||
@GestureState private var dragTranslation: CGSize = .zero
|
||||
|
||||
/// Used for both the live preview and the exported image — see the
|
||||
/// type-level doc comment for why that's the same size, not two.
|
||||
private let diameter: CGFloat = 320
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("Edit Photo")
|
||||
.font(.headline)
|
||||
|
||||
ZStack {
|
||||
avatarContent
|
||||
.clipShape(Circle())
|
||||
Circle()
|
||||
.strokeBorder(Color.primary.opacity(0.15), lineWidth: 1)
|
||||
}
|
||||
.frame(width: diameter, height: diameter)
|
||||
.contentShape(Circle())
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.updating($dragTranslation) { value, state, _ in state = value.translation }
|
||||
.onEnded { value in
|
||||
offset.width += value.translation.width
|
||||
offset.height += value.translation.height
|
||||
}
|
||||
)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees -= 90 }
|
||||
} label: {
|
||||
Image(systemName: "rotate.left")
|
||||
}
|
||||
.help("Rotate left")
|
||||
|
||||
Slider(value: $scale, in: 1...4)
|
||||
.frame(width: 140)
|
||||
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees += 90 }
|
||||
} label: {
|
||||
Image(systemName: "rotate.right")
|
||||
}
|
||||
.help("Rotate right")
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Cancel", role: .cancel, action: onCancel)
|
||||
Spacer()
|
||||
Button("Use Photo") {
|
||||
if let data = renderFinalImage() {
|
||||
onConfirm(data)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 360)
|
||||
}
|
||||
|
||||
/// Aspect-fills `sourceImage` into a `diameter`×`diameter` square, then
|
||||
/// applies the user's pan/zoom/rotation on top — identical between the
|
||||
/// live preview and the final render (see the type-level doc comment).
|
||||
private var avatarContent: some View {
|
||||
Image(nsImage: sourceImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: diameter, height: diameter)
|
||||
.scaleEffect(scale)
|
||||
.rotationEffect(.degrees(rotationDegrees))
|
||||
.offset(x: offset.width + dragTranslation.width, y: offset.height + dragTranslation.height)
|
||||
.frame(width: diameter, height: diameter)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func renderFinalImage() -> Data? {
|
||||
let content = avatarContent
|
||||
.clipShape(Circle())
|
||||
.frame(width: diameter, height: diameter)
|
||||
let renderer = ImageRenderer(content: content)
|
||||
renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays
|
||||
guard let nsImage = renderer.nsImage else { return nil }
|
||||
return nsImage.jpegData(compressionQuality: 0.9)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -6,6 +6,11 @@ import SwiftUI
|
||||
/// content, rather than a separate mini sidebar nested inside a page. "Done"
|
||||
/// clears `AppNavigation.isShowingSettings`, which puts the collections tree
|
||||
/// back.
|
||||
///
|
||||
/// Grouped by `SettingsCategory` — `general` (ours) sits under an "Outpost"
|
||||
/// header at the top, then Outline's own Account/Workspace/Integrations &
|
||||
/// Installation groups, matching the settings page structure of the
|
||||
/// Outline web app.
|
||||
struct SettingsSidebarList: View {
|
||||
@Binding var selection: SettingsSection?
|
||||
let onDone: () -> Void
|
||||
@@ -22,9 +27,20 @@ struct SettingsSidebarList: View {
|
||||
|
||||
Divider()
|
||||
|
||||
List(SettingsSection.allCases, selection: $selection) { section in
|
||||
Label(section.title, systemImage: section.icon)
|
||||
.tag(section)
|
||||
List(selection: $selection) {
|
||||
ForEach(SettingsCategory.allCases) { category in
|
||||
let sections = SettingsSection.allCases.filter { $0.category == category }
|
||||
Section {
|
||||
ForEach(sections) { section in
|
||||
Label(section.title, systemImage: section.icon)
|
||||
.tag(section)
|
||||
}
|
||||
} header: {
|
||||
if let title = category.title {
|
||||
Text(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import OutlineKit
|
||||
|
||||
/// Settings *detail* content for one section — the section list itself now
|
||||
@@ -24,6 +26,19 @@ struct SettingsView: View {
|
||||
@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?
|
||||
|
||||
/// Full Local Sync and cache-clearing both need a real connection to be
|
||||
/// safe — clearing while offline (or letting Full Local Sync think it
|
||||
@@ -62,13 +77,30 @@ struct SettingsView: View {
|
||||
private var sectionDetail: some View {
|
||||
switch section {
|
||||
case .appearance: appearanceDetail
|
||||
case .account: accountDetail
|
||||
case .profile: profileDetail
|
||||
case .preferences: preferencesDetail
|
||||
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())
|
||||
@@ -90,26 +122,411 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Account
|
||||
// MARK: - Profile
|
||||
|
||||
private var accountDetail: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
private var profileDetail: some View {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
sectionHeader
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
labeledRow("Signed in as", session.userName ?? "—")
|
||||
if let email = session.userEmail {
|
||||
labeledRow("Email", email)
|
||||
}
|
||||
if let teamName = session.teamName {
|
||||
labeledRow("Workspace", teamName)
|
||||
}
|
||||
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)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
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
|
||||
@@ -416,5 +833,97 @@ struct SettingsView: View {
|
||||
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
|
||||
|
||||
@@ -1,27 +1,131 @@
|
||||
import Observation
|
||||
|
||||
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
case appearance, account, offlineSync, advanced, about
|
||||
/// Top-level groupings shown as section headers in `SettingsSidebarList`.
|
||||
/// `general` holds everything that's ours, not Outline's own settings
|
||||
/// categories — labeled "Outpost" so it reads as clearly distinct from the
|
||||
/// Outline-sourced groups below it.
|
||||
enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||
case general
|
||||
case account
|
||||
case workspace
|
||||
case integrationsInstallation
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String? {
|
||||
switch self {
|
||||
case .general: return "Outpost"
|
||||
case .account: return "Account"
|
||||
case .workspace: return "Workspace"
|
||||
case .integrationsInstallation: return "Integrations & Installation"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry in the Settings sidebar. Mirrors Outline's own settings
|
||||
/// categories (Account/Workspace/Integrations & Installation) so this app's
|
||||
/// settings read as a native counterpart to the web app's, plus a `general`
|
||||
/// group for things that are ours and don't map onto Outline's structure
|
||||
/// (offline/sync, advanced, about, appearance).
|
||||
///
|
||||
/// Most of the Account/Workspace/Integrations cases are navigation-only for
|
||||
/// now — `SettingsView` renders a "Coming Soon" placeholder for anything not
|
||||
/// explicitly built yet. Content lands section by section.
|
||||
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
// General (ours)
|
||||
case appearance, offlineSync, advanced, about
|
||||
|
||||
// Account
|
||||
case profile, preferences, notifications, passkeys, apiAccess
|
||||
|
||||
// Workspace
|
||||
case details, authentication, security, ai, members, groups, templates, emojis, applications, shared, links, webhooks, importData, exportData
|
||||
|
||||
// Integrations & Installation
|
||||
case installation
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var category: SettingsCategory {
|
||||
switch self {
|
||||
case .appearance, .offlineSync, .advanced, .about:
|
||||
return .general
|
||||
case .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
||||
return .account
|
||||
case .details, .authentication, .security, .ai, .members, .groups, .templates, .emojis, .applications, .shared, .links, .webhooks, .importData, .exportData:
|
||||
return .workspace
|
||||
case .installation:
|
||||
return .integrationsInstallation
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .appearance: return "Appearance"
|
||||
case .account: return "Account"
|
||||
case .offlineSync: return "Offline & Sync"
|
||||
case .advanced: return "Advanced"
|
||||
case .about: return "About"
|
||||
case .profile: return "Profile"
|
||||
case .preferences: return "Preferences"
|
||||
case .notifications: return "Notifications"
|
||||
case .passkeys: return "Passkeys"
|
||||
case .apiAccess: return "API & Access"
|
||||
case .details: return "Details"
|
||||
case .authentication: return "Authentication"
|
||||
case .security: return "Security"
|
||||
case .ai: return "AI"
|
||||
case .members: return "Members"
|
||||
case .groups: return "Groups"
|
||||
case .templates: return "Templates"
|
||||
case .emojis: return "Emojis"
|
||||
case .applications: return "Applications"
|
||||
case .shared: return "Shared"
|
||||
case .links: return "Links"
|
||||
case .webhooks: return "Webhooks"
|
||||
case .importData: return "Import"
|
||||
case .exportData: return "Export"
|
||||
case .installation: return "Installation"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .appearance: return "paintbrush"
|
||||
case .account: return "person.crop.circle"
|
||||
case .offlineSync: return "arrow.triangle.2.circlepath"
|
||||
case .advanced: return "wrench.and.screwdriver"
|
||||
case .about: return "info.circle"
|
||||
case .profile: return "person.crop.circle"
|
||||
case .preferences: return "gearshape"
|
||||
case .notifications: return "bell"
|
||||
case .passkeys: return "key"
|
||||
case .apiAccess: return "chevron.left.forwardslash.chevron.right"
|
||||
case .details: return "building.2"
|
||||
case .authentication: return "lock"
|
||||
case .security: return "shield"
|
||||
case .ai: return "sparkles"
|
||||
case .members: return "person.2"
|
||||
case .groups: return "person.3"
|
||||
case .templates: return "doc.on.doc"
|
||||
case .emojis: return "face.smiling"
|
||||
case .applications: return "app.badge"
|
||||
case .shared: return "square.and.arrow.up.on.square"
|
||||
case .links: return "link"
|
||||
case .webhooks: return "bolt.horizontal"
|
||||
case .importData: return "square.and.arrow.down"
|
||||
case .exportData: return "square.and.arrow.up"
|
||||
case .installation: return "shippingbox"
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything actually built so far — everything else in Account/
|
||||
/// Workspace/Integrations & Installation renders a "Coming Soon"
|
||||
/// placeholder until its content is specified and built.
|
||||
var isImplemented: Bool {
|
||||
switch self {
|
||||
case .appearance, .offlineSync, .advanced, .about, .profile, .preferences:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,12 @@ final class SessionStore {
|
||||
private let defaults: UserDefaults
|
||||
|
||||
var isSignedIn: Bool
|
||||
private(set) var userId: String?
|
||||
var userName: String?
|
||||
var userEmail: String?
|
||||
var userAvatarURL: URL?
|
||||
var userLanguage: String?
|
||||
var userPreferences: OutlineUserPreferences?
|
||||
var teamName: String?
|
||||
var teamAvatarURL: URL?
|
||||
private(set) var apiClient: OutlineAPIClient?
|
||||
@@ -68,9 +71,12 @@ final class SessionStore {
|
||||
try? tokenStore.clear()
|
||||
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
|
||||
isSignedIn = false
|
||||
userId = nil
|
||||
userName = nil
|
||||
userEmail = nil
|
||||
userAvatarURL = nil
|
||||
userLanguage = nil
|
||||
userPreferences = nil
|
||||
teamName = nil
|
||||
teamAvatarURL = nil
|
||||
apiClient = nil
|
||||
@@ -85,13 +91,28 @@ final class SessionStore {
|
||||
apply(user: auth.user, team: auth.team, serverURL: serverURL)
|
||||
}
|
||||
|
||||
/// Settings calls this after a successful name/avatar change so the
|
||||
/// sidebar's account footer and everywhere else reading these reflect
|
||||
/// it immediately, without waiting for the next `auth.info` refresh.
|
||||
func applyUpdatedProfile(_ user: OutlineUser) {
|
||||
guard let serverURL else { return }
|
||||
userId = user.id
|
||||
userName = user.name
|
||||
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) {
|
||||
userId = user.id
|
||||
userName = user.name
|
||||
userEmail = user.email
|
||||
// Outline can return either an absolute URL or a server-relative path
|
||||
// (e.g. `/api/files.get?key=...`) for avatarUrl — resolve against the
|
||||
// configured server so relative paths don't fail as "unsupported URL".
|
||||
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||
userLanguage = user.language
|
||||
userPreferences = user.preferences
|
||||
teamName = team.name
|
||||
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
@@ -41,11 +42,46 @@ struct AvatarBadge: View {
|
||||
.task(id: avatarURL) {
|
||||
loadedImage = nil
|
||||
guard let avatarURL else { return }
|
||||
guard let (data, _) = try? await URLSession.shared.data(from: avatarURL) else { return }
|
||||
loadedImage = PlatformImage(data: data)
|
||||
loadedImage = await Self.loadImage(from: avatarURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// The real fix, confirmed against a live network capture: every other
|
||||
/// request this app makes attaches `Authorization: Bearer <token>` —
|
||||
/// this one never did, sending a bare unauthenticated GET. Outline's
|
||||
/// browser session authenticates `attachments.redirect` via cookies
|
||||
/// instead, which a native app doesn't have; the API-token equivalent
|
||||
/// is the same Bearer header every RPC call already uses. Almost
|
||||
/// certainly means no avatar image (not just a freshly-uploaded one)
|
||||
/// has ever actually loaded in this app — a 401 and a "no avatar set"
|
||||
/// look identical here, both just fall back to the placeholder icon
|
||||
/// with nothing on screen to flag it as an error.
|
||||
///
|
||||
/// The retry loop is a secondary, independent hardening — cheap
|
||||
/// insurance against a self-hosted reverse-proxied storage backend not
|
||||
/// being instantly consistent right after an upload — kept alongside
|
||||
/// the auth fix rather than instead of it.
|
||||
private static func loadImage(from url: URL) async -> PlatformImage? {
|
||||
var request = URLRequest(url: url)
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
if let token = try? KeychainTokenStore().token() {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
for attempt in 0..<3 {
|
||||
if attempt > 0 {
|
||||
try? await Task.sleep(for: .milliseconds(400))
|
||||
}
|
||||
if let (data, response) = try? await URLSession.shared.data(for: request),
|
||||
let httpResponse = response as? HTTPURLResponse,
|
||||
(200...299).contains(httpResponse.statusCode),
|
||||
let image = PlatformImage(data: data) {
|
||||
return image
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func platformImage(_ image: PlatformImage) -> Image {
|
||||
#if os(macOS)
|
||||
Image(nsImage: image)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
extension NSImage {
|
||||
/// `NSImage` has no built-in JPEG encoder (unlike `UIImage`) — routes
|
||||
/// through a bitmap representation to get one.
|
||||
func jpegData(compressionQuality: CGFloat) -> Data? {
|
||||
guard let tiffData = tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiffData) else {
|
||||
return nil
|
||||
}
|
||||
return bitmap.representation(using: .jpeg, properties: [.compressionFactor: compressionQuality])
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -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