diff --git a/App/Features/AI/AIDocumentSheet.swift b/App/Features/AI/AIDocumentSheet.swift index bd9ba96..cb19b29 100644 --- a/App/Features/AI/AIDocumentSheet.swift +++ b/App/Features/AI/AIDocumentSheet.swift @@ -22,7 +22,7 @@ struct AIDocumentSheet: View { /// populated without the host having to hold lists it does not otherwise need. @State private var lists: [OwnedList] = [] /// Documents the user owns, offered when deriving from an article. - var documents: [Document] = [] + var documents: [InterlinedDomain.Document] = [] /// Called after a drafted document is created, so the host can reload it. var onCreated: (() async -> Void)? diff --git a/App/Features/Compose/ComposerViewModel.swift b/App/Features/Compose/ComposerViewModel.swift index 712d38e..b98063a 100644 --- a/App/Features/Compose/ComposerViewModel.swift +++ b/App/Features/Compose/ComposerViewModel.swift @@ -71,6 +71,12 @@ final class ComposerViewModel { /// built-in `ContentLimits.default` for the character counter. private let contentLimits: ContentLimitsProviding? + /// The account's own per-message character cap, injected by the composition + /// root from the session-cached `CurrentUser` (GitHub #46). `nil` when no + /// account has resolved, in which case the platform ceiling stands alone + /// rather than a guessed default standing in for it. + private let accountMessageCap: Int? + /// LinkedIn posting-targets surface (work-consolidation.md G11a). Optional so /// preview / test hosts without it keep the plain boolean toggle behaviour. private let linkedIn: LinkedInServicing? @@ -336,7 +342,8 @@ final class ComposerViewModel { contentLimits: ContentLimitsProviding? = nil, linkedIn: LinkedInServicing? = nil, initialVisibility: Visibility = .public, - initialShowsAdvancedOptions: Bool = true + initialShowsAdvancedOptions: Bool = true, + accountMessageCap: Int? = nil ) { self.messages = messages self.eventBus = eventBus @@ -351,6 +358,7 @@ final class ComposerViewModel { self.onSubscriberLapse = onSubscriberLapse self.userService = userService self.contentLimits = contentLimits + self.accountMessageCap = accountMessageCap self.linkedIn = linkedIn self.scheduledAt = Date().addingTimeInterval(3600) // Defaults to `true` so previews and existing tests that don't pass a @@ -377,9 +385,20 @@ final class ComposerViewModel { /// Refreshes `messageCharacterLimit` from the server (work-consolidation.md /// G14). No-op when no provider is wired; the provider itself never throws /// (it falls back to `ContentLimits.default`), so the limit is always sane. + /// + /// The budget is the **lower** of the platform ceiling and the account's own + /// `maxMessageLength` cap (GitHub #46). This used to read the platform + /// ceiling alone, which ignored a cap the user had deliberately set — and + /// the numbers make the other direction reachable too: the account field + /// accepts up to `10000` while the platform stops at `5000`, so trusting the + /// account value alone would let the composer accept a message the server + /// then rejects. func refreshLimits() async { guard let contentLimits else { return } - messageCharacterLimit = await contentLimits.limits().messageMaxContentLength + let limits = await contentLimits.limits() + messageCharacterLimit = limits.effectiveMessageLength( + accountCap: accountMessageCap + ) } func setVisibility(_ visibility: Visibility) { diff --git a/App/Features/Compose/ComposerWindowView.swift b/App/Features/Compose/ComposerWindowView.swift index 7203e30..8ba9ea3 100644 --- a/App/Features/Compose/ComposerWindowView.swift +++ b/App/Features/Compose/ComposerWindowView.swift @@ -97,7 +97,13 @@ struct ComposerWindowView: View { // options" preference decides whether the gear opens // revealed. Read synchronously off the preferences store, // so there is no fetch and no flicker. - initialShowsAdvancedOptions: environment.showsAdvancedPostOptionsByDefault + initialShowsAdvancedOptions: environment.showsAdvancedPostOptionsByDefault, + // GitHub #46: the account's own message cap, read off the + // same session-cached `CurrentUser`. The composer enforces + // the *lower* of this and the platform ceiling — the account + // range reaches 10000 where the platform stops at 5000, so + // neither number alone is the right answer. + accountMessageCap: environment.currentUserStore.currentUser?.maxMessageLength ) } if assistant == nil, let environment { diff --git a/App/Features/Documents/ConflictBannerView.swift b/App/Features/Documents/ConflictBannerView.swift index 04b1586..8997aeb 100644 --- a/App/Features/Documents/ConflictBannerView.swift +++ b/App/Features/Documents/ConflictBannerView.swift @@ -15,7 +15,7 @@ import InterlinedDomain struct ConflictBannerView: View { let pending: ConflictBannerViewModel.Pending - let onOpenLocalCopy: (Document.ID) -> Void + let onOpenLocalCopy: (InterlinedDomain.Document.ID) -> Void let onDismiss: () -> Void var body: some View { diff --git a/App/Features/Documents/DocumentEditorView.swift b/App/Features/Documents/DocumentEditorView.swift index d4d6070..9d67308 100644 --- a/App/Features/Documents/DocumentEditorView.swift +++ b/App/Features/Documents/DocumentEditorView.swift @@ -21,7 +21,7 @@ import Textual struct DocumentEditorView: View { let viewModel: DocumentEditorViewModel - let onOpenLocalCopy: (Document.ID) -> Void + let onOpenLocalCopy: (InterlinedDomain.Document.ID) -> Void var body: some View { VStack(spacing: 0) { diff --git a/App/Features/Documents/DocumentTemplatePickerView.swift b/App/Features/Documents/DocumentTemplatePickerView.swift index ad4b203..330a2da 100644 --- a/App/Features/Documents/DocumentTemplatePickerView.swift +++ b/App/Features/Documents/DocumentTemplatePickerView.swift @@ -36,7 +36,7 @@ struct DocumentTemplatePickerView: View { /// Called with the created document on success so the caller (the root /// view) can bind the editor to it. Not called on failure. - let onCreated: (Document) -> Void + let onCreated: (InterlinedDomain.Document) -> Void /// The built-in catalog to present. Defaults to the bundled built-ins; /// injectable so previews can substitute a list. diff --git a/App/Features/Documents/DocumentsListView.swift b/App/Features/Documents/DocumentsListView.swift index e0935c4..90436dd 100644 --- a/App/Features/Documents/DocumentsListView.swift +++ b/App/Features/Documents/DocumentsListView.swift @@ -9,10 +9,27 @@ import SwiftUI import InterlinedDomain +// `Document` is written as `InterlinedDomain.Document` throughout the SwiftUI +// files in this feature, and that qualification is load-bearing. +// +// The macOS 27 SDK added a `Document` **protocol** to SwiftUI +// (`protocol Document: ReadableDocument, WritableDocument`), which collides with +// the domain's `Document` **struct** in any file importing both — which is every +// documents view. Before Xcode 27 the bare name resolved; after it, the same +// source stopped compiling with `'Document' is ambiguous for type lookup` +// (GitHub #98). +// +// The domain type is not renamed: `Document` is the right name for it, it is +// correct across Kit, Domain, Persistence and their tests, and renaming a core +// model to dodge a collision in one consumer is the tail wagging the dog. A +// `typealias` would shorten the use sites at the cost of giving one concept two +// names. Only the SwiftUI-importing files need this; the view models import +// Foundation and Observation, not SwiftUI, and are unaffected. + struct DocumentsListView: View { let viewModel: DocumentsListViewModel - let onSelect: (Document.ID?) -> Void + let onSelect: (InterlinedDomain.Document.ID?) -> Void /// Source of the **Move to folder** destinations. Optional so the column /// still renders in isolation (previews, and any future host that has no @@ -21,7 +38,7 @@ struct DocumentsListView: View { /// Called with the document that was moved, so the host can rebind an open /// editor to the server's relocated copy. - var onMoved: ((Document) -> Void)? = nil + var onMoved: ((InterlinedDomain.Document) -> Void)? = nil var body: some View { List(selection: Binding( @@ -103,7 +120,7 @@ struct DocumentsListView: View { // MARK: - DocumentRowView private struct DocumentRowView: View { - let document: Document + let document: InterlinedDomain.Document var body: some View { VStack(alignment: .leading, spacing: 2) { diff --git a/App/Features/Documents/DocumentsRootView.swift b/App/Features/Documents/DocumentsRootView.swift index b52561b..dd1fa43 100644 --- a/App/Features/Documents/DocumentsRootView.swift +++ b/App/Features/Documents/DocumentsRootView.swift @@ -433,7 +433,7 @@ struct DocumentsRootView: View { /// was started from the editor, so there is exactly one optimistic-rollback /// implementation rather than two that can disagree. private func handleMove( - documentID: Document.ID, + documentID: InterlinedDomain.Document.ID, to destination: FolderNode.ID?, folderTree: FolderTreeViewModel, documentsList: DocumentsListViewModel, @@ -449,7 +449,7 @@ struct DocumentsRootView: View { } private func handleOpenLocalCopy( - _ id: Document.ID, + _ id: InterlinedDomain.Document.ID, documentsList: DocumentsListViewModel, editor: DocumentEditorViewModel ) { diff --git a/App/Features/Documents/PublicUserDocumentsView.swift b/App/Features/Documents/PublicUserDocumentsView.swift index 45c48bd..0f279ad 100644 --- a/App/Features/Documents/PublicUserDocumentsView.swift +++ b/App/Features/Documents/PublicUserDocumentsView.swift @@ -122,7 +122,7 @@ struct PublicUserDocumentsView: View { private struct PublicDocumentRow: View { - let document: Document + let document: InterlinedDomain.Document var body: some View { VStack(alignment: .leading, spacing: 2) { diff --git a/App/Features/Search/SearchRootView.swift b/App/Features/Search/SearchRootView.swift index dad88a5..c5479de 100644 --- a/App/Features/Search/SearchRootView.swift +++ b/App/Features/Search/SearchRootView.swift @@ -266,7 +266,7 @@ struct SearchRootView: View { /// a relative "updated" stamp. Kept local to the Search feature because /// the Documents feature's own row component is file-private. private struct DocumentSearchRow: View { - let document: Document + let document: InterlinedDomain.Document var body: some View { VStack(alignment: .leading, spacing: 6) { diff --git a/App/Features/Settings/ProfileSettingsView.swift b/App/Features/Settings/ProfileSettingsView.swift new file mode 100644 index 0000000..8b11fb6 --- /dev/null +++ b/App/Features/Settings/ProfileSettingsView.swift @@ -0,0 +1,296 @@ +// ProfileSettingsView +// +// Settings ▸ Profile (GitHub #46 / G34) — the identity half of the account. +// +// Until this pane existed **you could not edit your own display name or bio from +// the macOS app at all**, even though `UpdateUserRequest` had carried +// `displayName`, `bio` and `theme` since it was written and nothing called them. +// +// Three things here are the result of a probe rather than a guess, and each is +// noted where it bites: +// +// - **Theme is unvalidated server-side.** The picker offers three values and +// also surfaces whatever the account actually holds, so an unrecognised value +// is preserved rather than silently rewritten. +// - **The message cap is the account's, not the platform's.** The pane shows +// both numbers, because the account range (1–10000) reaches past the platform +// ceiling (5000) and the composer honours the lower one. +// - **A published profile location cannot be cleared through any route.** So it +// is shown and not editable, with the reason stated — see GitHub #57 / #91. +// +// Pure SwiftUI; the file picker is SwiftUI's `.fileImporter`, no AppKit panel. +// Per Decision 0003 this view consumes only `InterlinedDomain`. + +import SwiftUI +import InterlinedDomain +import UniformTypeIdentifiers + +struct ProfileSettingsView: View { + + @Environment(\.appEnvironment) private var environment + @State private var viewModel: ProfileSettingsViewModel? + @State private var isImportingAvatar = false + + var body: some View { + Form { + if let viewModel { + identitySection(viewModel) + avatarSection(viewModel) + appearanceSection(viewModel) + composingSection(viewModel) + locationSection(viewModel) + securitySection(viewModel) + statusSection(viewModel) + } + } + .formStyle(.grouped) + .task { + guard viewModel == nil, let environment else { return } + let model = ProfileSettingsViewModel( + userService: environment.userService, + contentLimits: environment.contentLimits, + currentUserStore: environment.currentUserStore + ) + viewModel = model + await model.load() + } + } + + // MARK: - Identity + + @ViewBuilder + private func identitySection(_ viewModel: ProfileSettingsViewModel) -> some View { + Section("Profile") { + LabeledContent("Display name") { + TextField("", text: Binding( + get: { viewModel.settings.displayName }, + set: { viewModel.settings.displayName = $0 } + )) + .textFieldStyle(.roundedBorder) + } + Text("How you appear to others. Leave it empty to use your username.") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 4) { + Text("Bio") + TextEditor(text: Binding( + get: { viewModel.settings.bio }, + set: { viewModel.settings.bio = $0 } + )) + .font(.ilBody()) + .frame(minHeight: 60) + .overlay( + RoundedRectangle(cornerRadius: ILMetric.radiusSm) + .stroke(Color.secondary.opacity(0.3)) + ) + HStack { + Text("Shown on your public profile.") + Spacer() + Text("\(viewModel.settings.bio.count)/\(ProfileSettings.bioLengthLimit)") + .monospacedDigit() + .foregroundStyle( + viewModel.settings.bio.count > ProfileSettings.bioLengthLimit + ? Color.red : Color.secondary + ) + } + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Avatar + + @ViewBuilder + private func avatarSection(_ viewModel: ProfileSettingsViewModel) -> some View { + Section("Avatar") { + HStack(spacing: 12) { + if let url = viewModel.settings.avatarURL { + AsyncImage(url: url) { image in + image.resizable().scaledToFill() + } placeholder: { + ProgressView().controlSize(.small) + } + .frame(width: 48, height: 48) + .clipShape(Circle()) + .accessibilityLabel("Current avatar") + } else { + Image(systemName: "person.crop.circle") + .font(.ilDisplay(40)) + .foregroundStyle(.secondary) + .accessibilityLabel("No avatar set") + } + // Both halves the web offers: a file and a URL. + Button("Choose File…") { isImportingAvatar = true } + .disabled(viewModel.isUpdatingAvatar) + if viewModel.isUpdatingAvatar { + ProgressView().controlSize(.small) + } + } + + LabeledContent("From a URL") { + HStack(spacing: 6) { + TextField("https://…", text: Binding( + get: { viewModel.avatarURLInput }, + set: { viewModel.avatarURLInput = $0 } + )) + .textFieldStyle(.roundedBorder) + Button("Set") { + Task { await viewModel.setAvatarFromURL() } + } + .disabled( + viewModel.isUpdatingAvatar + || viewModel.avatarURLInput.trimmingCharacters(in: .whitespaces).isEmpty + ) + } + } + } + .fileImporter( + isPresented: $isImportingAvatar, + allowedContentTypes: [.png, .jpeg, .gif, .webP] + ) { result in + guard case .success(let url) = result else { return } + Task { await importAvatar(from: url, into: viewModel) } + } + } + + /// Reads the picked file and hands the bytes to the view model. + /// + /// The security-scoped bookmark dance is required for a sandboxed app: the + /// URL `fileImporter` returns is only readable between `startAccessing…` and + /// `stopAccessing…`, and skipping it fails at runtime in a signed build + /// while working fine in a debug one. + private func importAvatar(from url: URL, into viewModel: ProfileSettingsViewModel) async { + let accessed = url.startAccessingSecurityScopedResource() + defer { if accessed { url.stopAccessingSecurityScopedResource() } } + guard let data = try? Data(contentsOf: url) else { return } + let contentType = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType + ?? "application/octet-stream" + await viewModel.uploadAvatar(imageData: data, contentType: contentType) + } + + // MARK: - Appearance + + @ViewBuilder + private func appearanceSection(_ viewModel: ProfileSettingsViewModel) -> some View { + Section("Appearance") { + Picker("Theme", selection: Binding( + get: { viewModel.settings.theme }, + set: { viewModel.settings.theme = $0 } + )) { + ForEach(viewModel.themeOptions, id: \.self) { theme in + Text(theme.displayName).tag(theme) + } + } + Text("Your theme is stored on your account and applies on the web too. The Mac app follows your system appearance.") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + } + + // MARK: - Composing + + @ViewBuilder + private func composingSection(_ viewModel: ProfileSettingsViewModel) -> some View { + Section("Composing") { + Stepper( + value: Binding( + get: { viewModel.settings.maxMessageLength }, + set: { viewModel.settings.maxMessageLength = $0 } + ), + in: ProfileSettings.maxMessageLengthRange, + step: 50 + ) { + LabeledContent("Maximum message length") { + Text(viewModel.settings.maxMessageLength.formatted()) + .monospacedDigit() + } + } + // Both numbers, named. Showing only the account's would be a number + // the composer does not honour when it sits above the ceiling. + VStack(alignment: .leading, spacing: 2) { + Text("Your own limit. InterlinedList also caps messages at \(viewModel.limits.messageMaxContentLength.formatted()) characters.") + if viewModel.accountCapExceedsPlatform { + Text("Your limit is above the platform cap, so the composer uses \(viewModel.effectiveMessageLength.formatted()).") + .foregroundStyle(.orange) + } + } + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + } + + // MARK: - Profile location (read-only — GitHub #57 / #91) + + @ViewBuilder + private func locationSection(_ viewModel: ProfileSettingsViewModel) -> some View { + Section("Profile location") { + if let location = viewModel.settings.location { + LabeledContent("Coordinates") { + Text(location.displayText).monospacedDigit() + } + Text("This location is published on your public profile.") + .font(.ilMono(10)) + .foregroundStyle(.orange) + Text("It can't be changed or removed from this app: InterlinedList has no route that clears a profile location — every form of \"unset\" is rejected. Tracked as issue #91.") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } else { + Text("No location is published on your profile.") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + Text("Setting one isn't offered here yet. A location set through InterlinedList can't currently be removed again, so this app doesn't give you a way to publish one — see issue #91.") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Security + + @ViewBuilder + private func securitySection(_ viewModel: ProfileSettingsViewModel) -> some View { + Section("Password") { + Button("Send password reset email") { + Task { await viewModel.requestPasswordReset() } + } + Text("InterlinedList doesn't offer a change-password form — you reset your password by email. We'll send a link to the address on your account.") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + } + + // MARK: - Save / status + + @ViewBuilder + private func statusSection(_ viewModel: ProfileSettingsViewModel) -> some View { + Section { + HStack(spacing: 8) { + Button("Save") { + Task { await viewModel.save() } + } + .buttonStyle(.borderedProminent) + .disabled(!viewModel.hasChanges || viewModel.isSaving) + + Button("Revert") { viewModel.revert() } + .disabled(!viewModel.hasChanges || viewModel.isSaving) + + if viewModel.isSaving || viewModel.isLoading { + ProgressView().controlSize(.small) + } + Spacer() + if let confirmation = viewModel.confirmation { + Text(confirmation) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + } + if let error = viewModel.error { + Text(error.localizedDescription) + .font(.ilMono(10)) + .foregroundStyle(.orange) + } + } + } +} diff --git a/App/Features/Settings/ProfileSettingsViewModel.swift b/App/Features/Settings/ProfileSettingsViewModel.swift new file mode 100644 index 0000000..068c6e2 --- /dev/null +++ b/App/Features/Settings/ProfileSettingsViewModel.swift @@ -0,0 +1,238 @@ +// ProfileSettingsViewModel +// +// Drives Settings ▸ Profile (GitHub #46 / G34) — the identity half of the +// account: display name, bio, avatar, theme and the per-message character cap. +// +// Until this pane existed **you could not edit your own display name or bio from +// the macOS app at all**, even though `UpdateUserRequest` had carried +// `displayName`, `bio` and `theme` since it was written and nothing called them. +// +// Reuses `PreferencesViewModel`'s idiom deliberately rather than inventing a +// second save shape: a working copy bound to the controls, a `lastSaved` +// snapshot, `hasChanges` gating Save, and a change-gated PATCH so an untouched +// field is never written. +// +// Per Decision 0003 this view model consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class ProfileSettingsViewModel { + + private let userService: UserServicing + + /// The platform-limits seam. Optional because the pane is fully usable + /// without it — `ContentLimits.default` already carries the live ceiling. + private let contentLimits: ContentLimitsProviding? + + /// The session-cached account projection, re-resolved after a save so + /// anything reading the display name or avatar off `CurrentUser` picks the + /// change up without a relaunch. Optional so tests construct this unchanged. + private let currentUserStore: CurrentUserStore? + + // MARK: - Observable state + + /// The working copy bound to the pane's controls. + var settings: ProfileSettings = ProfileSettings() + + /// The last value the server confirmed, for change detection. + private(set) var lastSaved: ProfileSettings = ProfileSettings() + + /// The platform limits, so the message-cap stepper can say which number is + /// the account's and which is the platform's. + private(set) var limits: ContentLimits = .default + + /// The URL typed into the "set avatar from a URL" field. + var avatarURLInput: String = "" + + private(set) var isLoading: Bool = false + private(set) var isSaving: Bool = false + private(set) var isUpdatingAvatar: Bool = false + + /// Surfaced error from the most recent failed load, save or avatar write. + private(set) var error: Error? + + /// A transient confirmation — "Saved", "Avatar updated", "Reset email sent". + /// Cleared at the start of the next attempt. + private(set) var confirmation: String? + + var hasChanges: Bool { settings.hasChanges(from: lastSaved) } + + /// The theme rows the picker offers: the three documented values, plus the + /// account's own when the server holds a token this build does not know. + /// + /// The second half is not hypothetical here. `theme` is **unvalidated** + /// server-side — probed 2026-09-15, `PATCH /api/user/update` stored + /// `"nonsense"` without complaint — so an unrecognised value is a thing that + /// can genuinely be on an account. Without offering it, the `Picker` would + /// have a selection matching no tag (SwiftUI renders that as a blank + /// control) and the first edit to any other field would silently rewrite it. + var themeOptions: [AppTheme] { + let selectable = AppTheme.selectable + guard !selectable.contains(settings.theme) else { return selectable } + return selectable + [settings.theme] + } + + /// The character budget the composer actually enforces — the lower of the + /// platform ceiling and the account's cap. Shown in the pane so the two + /// numbers are legible rather than mysterious. + var effectiveMessageLength: Int { + limits.effectiveMessageLength(accountCap: settings.maxMessageLength) + } + + /// True when the account's cap is above the platform ceiling, so the pane + /// can say which one is really in force instead of showing a number the + /// composer will not honour. + var accountCapExceedsPlatform: Bool { + settings.maxMessageLength > limits.messageMaxContentLength + } + + // MARK: - Init + + init( + userService: UserServicing, + contentLimits: ContentLimitsProviding? = nil, + currentUserStore: CurrentUserStore? = nil + ) { + self.userService = userService + self.contentLimits = contentLimits + self.currentUserStore = currentUserStore + } + + // MARK: - Intents + + func load() async { + isLoading = true + error = nil + confirmation = nil + defer { isLoading = false } + do { + let loaded = try await userService.profileSettings() + settings = loaded + lastSaved = loaded + } catch { + self.error = error + } + // The platform limits are a separate, soft read: the pane is fully + // usable without them, and failing the whole load because the ceiling + // could not be fetched would be a poor trade. The provider never throws + // — it falls back to `ContentLimits.default`. + if let contentLimits { + limits = await contentLimits.limits() + } + } + + func save() async { + guard hasChanges, !isSaving else { return } + isSaving = true + error = nil + confirmation = nil + defer { isSaving = false } + do { + let saved = try await userService.updateProfileSettings(settings, changedFrom: lastSaved) + settings = saved + lastSaved = saved + confirmation = "Saved." + await refreshSession() + } catch { + // The working copy is deliberately left alone: the user's typing is + // the one thing a failed save must not throw away. + self.error = error + } + } + + /// Discards unsaved edits. The pane's "Revert" affordance. + func revert() { + settings = lastSaved + error = nil + confirmation = nil + } + + func setAvatarFromURL() async { + let input = avatarURLInput.trimmingCharacters(in: .whitespacesAndNewlines) + guard !input.isEmpty, !isUpdatingAvatar else { return } + isUpdatingAvatar = true + error = nil + confirmation = nil + defer { isUpdatingAvatar = false } + do { + let url = try await userService.setAvatarFromURL(input) + // The avatar lives outside the change-gated body — it is written by + // its own route — so the working copy and the saved snapshot are + // both updated, or Save would think the avatar was an unsaved edit. + settings.avatarURL = url ?? settings.avatarURL + lastSaved.avatarURL = settings.avatarURL + avatarURLInput = "" + confirmation = "Avatar updated." + await refreshSession() + } catch { + self.error = error + } + } + + func uploadAvatar(imageData: Data, contentType: String) async { + guard !isUpdatingAvatar else { return } + isUpdatingAvatar = true + error = nil + confirmation = nil + defer { isUpdatingAvatar = false } + do { + let url = try await userService.uploadAvatar(imageData: imageData, contentType: contentType) + settings.avatarURL = url ?? settings.avatarURL + lastSaved.avatarURL = settings.avatarURL + confirmation = "Avatar updated." + await refreshSession() + } catch { + self.error = error + } + } + + /// Sends the password-reset email. + /// + /// Reset-by-email, **not** change-in-place: there is no change-password + /// route on this API (probed 2026-09-15 — only `forgot-password`, + /// `reset-password`, and an admin-only one). The pane says so rather than + /// shipping a form pointed at a route that does not exist. + func requestPasswordReset() async { + guard let email = currentUserStore?.currentUser?.email, !email.isEmpty else { + error = ProfileSettingsUIError.noEmailOnAccount + return + } + error = nil + confirmation = nil + do { + try await userService.requestPasswordReset(email: email) + confirmation = "Password reset email sent to \(email)." + } catch { + self.error = error + } + } +} + +/// Failures this pane can report that are about the pane, not the API. +enum ProfileSettingsUIError: Error, LocalizedError, Equatable { + case noEmailOnAccount + + var errorDescription: String? { + switch self { + case .noEmailOnAccount: + return "We don't have an email address for this account to send a reset link to." + } + } +} + +extension ProfileSettingsViewModel { + + /// Re-resolves the session-cached account so the sidebar avatar, the + /// composer's character budget and anything else reading `CurrentUser` pick + /// a save up without a relaunch. + /// + /// Soft on purpose: the save already succeeded, and failing to refresh a + /// cache is not a reason to tell the user their edit did not land. + fileprivate func refreshSession() async { + _ = try? await currentUserStore?.restore() + } +} diff --git a/App/Features/Settings/SettingsRootView.swift b/App/Features/Settings/SettingsRootView.swift index 53de1cd..9cf8060 100644 --- a/App/Features/Settings/SettingsRootView.swift +++ b/App/Features/Settings/SettingsRootView.swift @@ -20,7 +20,13 @@ import SwiftUI /// opens on it. enum SettingsTab: String, CaseIterable, Hashable { case linkedAccounts + // Integrations (#47/PR #93) and Profile (#46/PR #92) landed after this enum + // did — PR #94 wrote it against a `dev` that had neither. Their tabs existed + // without tags until this merge, which under `TabView(selection:)` means + // they could not be programmatically selected at all. + case integrations case account + case profile case preferences case blockedAndMuted case notifications @@ -51,6 +57,7 @@ struct SettingsRootView: View { .tabItem { Label("Integrations", systemImage: "app.connected.to.app.below.fill") } + .tag(SettingsTab.integrations) AccountSettingsView() .tabItem { @@ -58,6 +65,15 @@ struct SettingsRootView: View { } .tag(SettingsTab.account) + // Identity: display name, bio, avatar, theme and the per-message + // character cap (GitHub #46 / G34). Until this pane existed the + // display name and bio were uneditable from macOS entirely. + ProfileSettingsView() + .tabItem { + Label("Profile", systemImage: "person.text.rectangle") + } + .tag(SettingsTab.profile) + // Server-synced account preferences (work-consolidation.md — settings // storage) via `POST /api/user/update`. PreferencesView() diff --git a/App/Navigation/MainWindowView.swift b/App/Navigation/MainWindowView.swift index fd88af2..d2c8e1c 100644 --- a/App/Navigation/MainWindowView.swift +++ b/App/Navigation/MainWindowView.swift @@ -183,13 +183,19 @@ struct MainWindowView: View { // `SettingsLink` opens the window but cannot address a tab, // so the target is stored first and the scene opens on it. + // + // This targeted `.linkedAccounts` until now — not a typo, but + // the honest best available when PR #94 wrote it against a + // `dev` where no Integrations tab existed yet. PR #93 has + // since added one, so a row labelled Integrations that opens + // Linked accounts is now simply wrong. SettingsLink { Label("Integrations", systemImage: "app.connected.to.app.below.fill") .foregroundStyle(ILColor.onMasthead) } .buttonStyle(.plain) .simultaneousGesture(TapGesture().onEnded { - settingsTab = .linkedAccounts + settingsTab = .integrations }) } label: { Label("Profile", systemImage: "person.crop.circle") diff --git a/AppTests/ProfileSettingsViewModelTests.swift b/AppTests/ProfileSettingsViewModelTests.swift new file mode 100644 index 0000000..a36a82c --- /dev/null +++ b/AppTests/ProfileSettingsViewModelTests.swift @@ -0,0 +1,248 @@ +// ProfileSettingsViewModelTests +// +// BDD quartet for Settings ▸ Profile (GitHub #46 / G34). +// +// Two of these are about probe findings rather than about the view model, and +// they are the ones worth keeping: the theme field is unvalidated server-side, +// and the account's message cap can legally exceed the platform ceiling. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class ProfileSettingsViewModelTests: XCTestCase { + + private func loaded( + _ settings: ProfileSettings + ) async -> (ProfileSettingsViewModel, StubUserService) { + let stub = StubUserService() + stub.enqueueProfileSettings(success: settings) + let viewModel = ProfileSettingsViewModel(userService: stub) + await viewModel.load() + return (viewModel, stub) + } + + // MARK: - Happy path + + func test_givenAnAccount_whenLoading_thenEveryFieldArrivesAndNothingLooksUnsaved() async { + let (viewModel, _) = await loaded( + ProfileSettings(displayName: "Ada", bio: "Maths", theme: .dark, maxMessageLength: 500) + ) + + XCTAssertEqual(viewModel.settings.displayName, "Ada") + XCTAssertEqual(viewModel.settings.bio, "Maths") + XCTAssertEqual(viewModel.settings.theme, .dark) + XCTAssertEqual(viewModel.settings.maxMessageLength, 500) + XCTAssertFalse(viewModel.hasChanges, "a fresh load has nothing to save") + XCTAssertNil(viewModel.error) + } + + func test_givenAnEditedField_whenSaving_thenOnlyThatFieldIsSent() async { + // The point of the change-gated body: an untouched field must be absent + // from the PATCH, or two windows open on the same account clobber each + // other's edits. + let original = ProfileSettings(displayName: "Ada", bio: "Maths", theme: .light, maxMessageLength: 666) + let (viewModel, stub) = await loaded(original) + var saved = original + saved.bio = "Analytical engines" + stub.enqueueUpdateProfileSettings(success: saved) + + viewModel.settings.bio = "Analytical engines" + XCTAssertTrue(viewModel.hasChanges) + await viewModel.save() + + let recorded = stub.recorded + guard case .updateProfileSettings(let name, let bio, let theme, let cap)? = recorded.last?.kind else { + return XCTFail("expected updateProfileSettings, got \(String(describing: recorded.last))") + } + XCTAssertEqual(bio, "Analytical engines") + XCTAssertNil(name, "an untouched display name is not written") + XCTAssertNil(theme, "an untouched theme is not written") + XCTAssertNil(cap, "an untouched cap is not written") + XCTAssertFalse(viewModel.hasChanges, "the save resets the baseline") + XCTAssertEqual(viewModel.confirmation, "Saved.") + } + + // MARK: - Invalid input — refused before the service is called + + func test_givenNoChanges_whenSaving_thenNoCallIsMade() async { + let (viewModel, stub) = await loaded(ProfileSettings(displayName: "Ada")) + + await viewModel.save() + + let writes = stub.recorded.filter { + if case .updateProfileSettings = $0.kind { return true } else { return false } + } + XCTAssertTrue(writes.isEmpty, "a no-op PATCH is still a write, and still bumps updatedAt") + } + + func test_givenAnEmptyDisplayName_whenSaving_thenItIsAllowed() async { + // Not a validation failure: the server falls back to the username, which + // is exactly what /help/settings documents. Rejecting it would invent a + // rule the platform does not have. + let original = ProfileSettings(displayName: "Ada") + let (viewModel, stub) = await loaded(original) + stub.enqueueUpdateProfileSettings(success: ProfileSettings(displayName: "")) + + viewModel.settings.displayName = "" + await viewModel.save() + + XCTAssertNil(viewModel.error) + guard case .updateProfileSettings(let name, _, _, _)? = stub.recorded.last?.kind else { + return XCTFail("expected updateProfileSettings") + } + XCTAssertEqual(name, "") + } + + // MARK: - Upstream failure + + func test_givenASaveFailure_whenSaving_thenTheUsersTypingIsKept() async { + // The one thing a failed save must not throw away. + let (viewModel, stub) = await loaded(ProfileSettings(displayName: "Ada")) + stub.enqueueUpdateProfileSettings(failure: TestError.upstream("nope")) + + viewModel.settings.displayName = "Ada Lovelace" + await viewModel.save() + + XCTAssertNotNil(viewModel.error) + XCTAssertEqual(viewModel.settings.displayName, "Ada Lovelace", "the edit survives the failure") + XCTAssertTrue(viewModel.hasChanges, "and is still offered for saving") + } + + func test_givenALoadFailure_whenLoading_thenTheErrorIsSurfaced() async { + let stub = StubUserService() + stub.enqueueProfileSettings(failure: TestError.upstream("boom")) + let viewModel = ProfileSettingsViewModel(userService: stub) + + await viewModel.load() + + XCTAssertNotNil(viewModel.error) + XCTAssertFalse(viewModel.hasChanges) + } + + // MARK: - Boundary — the message cap against the platform ceiling + + func test_givenACapBelowThePlatformCeiling_whenComputing_thenTheAccountCapWins() async { + let (viewModel, _) = await loaded(ProfileSettings(maxMessageLength: 500)) + + XCTAssertEqual(viewModel.effectiveMessageLength, 500) + XCTAssertFalse(viewModel.accountCapExceedsPlatform) + } + + func test_givenACapAboveThePlatformCeiling_whenComputing_thenThePlatformWinsAndTheUserIsTold() async { + // This is reachable, not theoretical: the account field accepts up to + // 10000 and the platform stops at 5000. Honouring the account value here + // would let the composer accept a message the server then rejects. + let (viewModel, _) = await loaded(ProfileSettings(maxMessageLength: 9_000)) + + XCTAssertEqual(viewModel.effectiveMessageLength, ContentLimits.default.messageMaxContentLength) + XCTAssertTrue(viewModel.accountCapExceedsPlatform, "the pane says which number is really in force") + } + + func test_givenAnOutOfRangeCap_whenSet_thenItIsClampedBeforeItCanReachTheServer() async { + // The server rejects anything outside 1...10000 with a 400. Clamping in + // the setter means no caller can earn that error. + let (viewModel, _) = await loaded(ProfileSettings(maxMessageLength: 666)) + + viewModel.settings.maxMessageLength = 99_999 + XCTAssertEqual(viewModel.settings.maxMessageLength, 10_000) + + viewModel.settings.maxMessageLength = 0 + XCTAssertEqual(viewModel.settings.maxMessageLength, 1) + } + + // MARK: - Theme is unvalidated server-side + + func test_givenAnUnknownTheme_whenOfferingThePicker_thenTheAccountsOwnValueIsIncluded() async { + // `PATCH /api/user/update` stored "nonsense" without complaint when + // probed, so an unrecognised theme is a real state an account can be in. + // Without offering it the Picker would have a selection matching no tag + // — which SwiftUI renders as a blank control — and the first edit to any + // other field would silently rewrite the value. + let (viewModel, _) = await loaded(ProfileSettings(theme: .unknown("solarized"))) + + XCTAssertEqual(viewModel.themeOptions.count, 4) + XCTAssertTrue(viewModel.themeOptions.contains(.unknown("solarized"))) + } + + func test_givenAKnownTheme_whenOfferingThePicker_thenOnlyTheThreeAreOffered() async { + let (viewModel, _) = await loaded(ProfileSettings(theme: .light)) + + XCTAssertEqual(viewModel.themeOptions, AppTheme.selectable) + } + + func test_givenAnUnknownTheme_whenSavingAnotherField_thenTheThemeIsNotRewritten() async { + // The failure the picker option guards against, asserted at the wire. + let original = ProfileSettings(bio: "Maths", theme: .unknown("solarized")) + let (viewModel, stub) = await loaded(original) + stub.enqueueUpdateProfileSettings(success: original) + + viewModel.settings.bio = "Analytical engines" + await viewModel.save() + + guard case .updateProfileSettings(_, _, let theme, _)? = stub.recorded.last?.kind else { + return XCTFail("expected updateProfileSettings") + } + XCTAssertNil(theme, "an untouched unknown theme is left exactly as the server holds it") + } + + // MARK: - Avatar + + func test_givenAnAvatarURL_whenSet_thenItIsAppliedWithoutLookingLikeAnUnsavedEdit() async { + // The avatar is written by its own route, outside the change-gated body. + // If only the working copy were updated, Save would light up claiming an + // unsaved change that does not exist. + let (viewModel, stub) = await loaded(ProfileSettings(displayName: "Ada")) + stub.enqueueSetAvatarFromURL(success: URL(string: "https://example.com/a.jpg")) + + viewModel.avatarURLInput = "https://example.com/a.jpg" + await viewModel.setAvatarFromURL() + + XCTAssertEqual(viewModel.settings.avatarURL?.absoluteString, "https://example.com/a.jpg") + XCTAssertFalse(viewModel.hasChanges) + XCTAssertEqual(viewModel.avatarURLInput, "", "the field clears on success") + } + + func test_givenAnEmptyAvatarURL_whenSet_thenNoCallIsMade() async { + let (viewModel, stub) = await loaded(ProfileSettings()) + + viewModel.avatarURLInput = " " + await viewModel.setAvatarFromURL() + + let writes = stub.recorded.filter { + if case .setAvatarFromURL = $0.kind { return true } else { return false } + } + XCTAssertTrue(writes.isEmpty) + } + + // MARK: - Profile location is read-only (GitHub #57 / #91) + + func test_givenAPublishedLocation_whenLoading_thenItIsShownAndNeverSent() async { + // A location can be set through the API and cleared through nothing, so + // this client shows it and does not write it. Asserted at the wire: the + // PATCH body has no location field to carry, and a save of other fields + // must not smuggle one. + let settings = ProfileSettings( + displayName: "Ada", + location: ProfileLocation(latitude: 47.6062, longitude: -122.3321) + ) + let (viewModel, stub) = await loaded(settings) + stub.enqueueUpdateProfileSettings(success: settings) + + XCTAssertEqual(viewModel.settings.location?.displayText, "47.6062, -122.3321") + + viewModel.settings.displayName = "Ada Lovelace" + await viewModel.save() + + // `hasChanges` ignores location entirely — there is no way to edit it. + XCTAssertFalse(viewModel.hasChanges) + } + + func test_givenAHalfSetCoordinatePair_whenProjecting_thenThereIsNoLocation() async { + // Rendering "47.6, —" would be worse than rendering nothing. + XCTAssertNil(ProfileLocation(latitude: 47.6, longitude: nil)) + XCTAssertNil(ProfileLocation(latitude: nil, longitude: -122.3)) + XCTAssertNil(ProfileLocation(latitude: nil, longitude: nil)) + } +} diff --git a/AppTests/SidebarProfileGroupTests.swift b/AppTests/SidebarProfileGroupTests.swift index 29ce24b..75df471 100644 --- a/AppTests/SidebarProfileGroupTests.swift +++ b/AppTests/SidebarProfileGroupTests.swift @@ -54,9 +54,33 @@ final class SidebarProfileGroupTests: XCTestCase { func test_givenTheSettingsTabs_whenAddressed_thenTheirIdentitiesAreStable() { // The raw values are persisted in `@AppStorage`, so renaming a case // silently sends an existing user to a different pane on next launch. - XCTAssertEqual(SettingsTab.linkedAccounts.rawValue, "linkedAccounts") - XCTAssertEqual(SettingsTab.preferences.rawValue, "preferences") - XCTAssertEqual(SettingsTab.allCases.count, 9) + // + // Pinned as a full set rather than a count. The count alone was here + // before and did not catch the thing that actually went wrong: two panes + // (Integrations, Profile) shipped as `TabView` children with **no + // `.tag(...)`**, because the enum was written on a branch that predated + // both. The count was right; the tabs were unreachable. A set at least + // fails loudly when a pane arrives without an identity. + XCTAssertEqual( + Set(SettingsTab.allCases.map(\.rawValue)), + [ + "linkedAccounts", "integrations", "account", "profile", + "preferences", "blockedAndMuted", "notifications", "security", + "devices", "documentSync", "crashReporting" + ] + ) + } + + func test_givenATabTheSidebarTargets_whenResolved_thenItExists() { + // The sidebar's Settings and Integrations rows store a target before + // `SettingsLink` opens the scene, because `SettingsLink` cannot address + // a tab itself. A target naming a case that does not exist would not + // compile — but one naming the *wrong* case compiles perfectly, which is + // exactly what happened: the Integrations row pointed at + // `.linkedAccounts` while no Integrations tab existed, and stayed + // pointing there after one arrived. + XCTAssertEqual(SettingsTab(rawValue: "integrations"), .integrations) + XCTAssertEqual(SettingsTab(rawValue: "preferences"), .preferences) } func test_givenASettingsTabValue_whenRoundTripped_thenItSurvivesStorage() { diff --git a/AppTests/Support/StubUserService.swift b/AppTests/Support/StubUserService.swift index 97abf0f..4af48c0 100644 --- a/AppTests/Support/StubUserService.swift +++ b/AppTests/Support/StubUserService.swift @@ -33,6 +33,12 @@ struct RecordedUserCall: Sendable, Equatable { case linkIdentityNative(provider: String, code: String, state: String) case settings case updateSettings + case profileSettings + /// A profile save, with the change-gated body it actually sent — so a + /// test can assert that an untouched field was *not* written. + case updateProfileSettings(displayName: String?, bio: String?, theme: String?, maxMessageLength: Int?) + case setAvatarFromURL(url: String) + case requestPasswordReset(email: String) case unlinkIdentity(provider: String) case verifyIdentity(provider: String) case githubConnectionStatus @@ -59,6 +65,10 @@ final class StubUserService: UserServicing, @unchecked Sendable { private var blueskyConfiguredOutcomes: [Result] = [] private var mastodonConfiguredOutcomes: [Result] = [] private var linkIdentityNativeOutcomes: [Result] = [] + private var profileSettingsOutcomes: [Result] = [] + private var updateProfileSettingsOutcomes: [Result] = [] + private var setAvatarFromURLOutcomes: [Result] = [] + private var requestPasswordResetOutcomes: [Result] = [] private var unlinkIdentityOutcomes: [Result] = [] private var verifyIdentityOutcomes: [Result] = [] private var githubConnectionStatusOutcomes: [Result] = [] @@ -185,6 +195,39 @@ final class StubUserService: UserServicing, @unchecked Sendable { linkIdentityNativeOutcomes.append(.failure(error)) } + func enqueueProfileSettings(success settings: ProfileSettings) { + lock.lock(); defer { lock.unlock() } + profileSettingsOutcomes.append(.success(settings)) + } + func enqueueProfileSettings(failure error: Error) { + lock.lock(); defer { lock.unlock() } + profileSettingsOutcomes.append(.failure(error)) + } + func enqueueUpdateProfileSettings(success settings: ProfileSettings) { + lock.lock(); defer { lock.unlock() } + updateProfileSettingsOutcomes.append(.success(settings)) + } + func enqueueUpdateProfileSettings(failure error: Error) { + lock.lock(); defer { lock.unlock() } + updateProfileSettingsOutcomes.append(.failure(error)) + } + func enqueueSetAvatarFromURL(success url: URL?) { + lock.lock(); defer { lock.unlock() } + setAvatarFromURLOutcomes.append(.success(url)) + } + func enqueueSetAvatarFromURL(failure error: Error) { + lock.lock(); defer { lock.unlock() } + setAvatarFromURLOutcomes.append(.failure(error)) + } + func enqueueRequestPasswordReset(success: Void = ()) { + lock.lock(); defer { lock.unlock() } + requestPasswordResetOutcomes.append(.success(())) + } + func enqueueRequestPasswordReset(failure error: Error) { + lock.lock(); defer { lock.unlock() } + requestPasswordResetOutcomes.append(.failure(error)) + } + func enqueueUnlinkIdentity(success: Void = ()) { lock.lock(); defer { lock.unlock() } unlinkIdentityOutcomes.append(.success(())) @@ -380,6 +423,47 @@ final class StubUserService: UserServicing, @unchecked Sendable { set: { $0.linkIdentityNativeOutcomes = $1 } } + func profileSettings() async throws -> ProfileSettings { + try perform(label: "profileSettings", record: .profileSettings) { $0.profileSettingsOutcomes } + set: { $0.profileSettingsOutcomes = $1 } + } + + func updateProfileSettings( + _ settings: ProfileSettings, + changedFrom original: ProfileSettings + ) async throws -> ProfileSettings { + // Record the change-gated body, not the whole working copy: the point of + // the gating is that an untouched field is absent from the PATCH, and a + // test can only assert that if the stub keeps what was actually sent. + let request = settings.updateRequest(changedFrom: original) + return try perform( + label: "updateProfileSettings", + record: .updateProfileSettings( + displayName: request.displayName, + bio: request.bio, + theme: request.theme, + maxMessageLength: request.maxMessageLength + ) + ) { $0.updateProfileSettingsOutcomes } + set: { $0.updateProfileSettingsOutcomes = $1 } + } + + func setAvatarFromURL(_ url: String) async throws -> URL? { + try perform(label: "setAvatarFromURL", record: .setAvatarFromURL(url: url)) { + $0.setAvatarFromURLOutcomes + } set: { + $0.setAvatarFromURLOutcomes = $1 + } + } + + func requestPasswordReset(email: String) async throws { + let _: Void = try perform( + label: "requestPasswordReset", + record: .requestPasswordReset(email: email) + ) { $0.requestPasswordResetOutcomes } + set: { $0.requestPasswordResetOutcomes = $1 } + } + func unlinkIdentity(_ identity: LinkedIdentity) async throws { // Records the *wire token*, not the provider case: the whole point is // that Mastodon must address its own instance. diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ContentLimits.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ContentLimits.swift index 7e11923..f8c707c 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ContentLimits.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ContentLimits.swift @@ -75,3 +75,31 @@ public extension ContentLimits { ) } } + +public extension ContentLimits { + + /// The character budget the composer should enforce: the **lower** of the + /// platform ceiling and the account's own `maxMessageLength` cap + /// (GitHub #46 / G34). + /// + /// Getting this backwards is the trap the issue named, and the live numbers + /// make it reachable rather than theoretical. Probed 2026-09-15: + /// + /// - `GET /api/limits` → `message.maxContentLength: 5000` — the platform. + /// - `PATCH /api/user/update` accepts `maxMessageLength` in `1...10000` — + /// the account. + /// + /// So a user really can set a cap **above** the platform ceiling. Honouring + /// the account value there would let them write a message the server then + /// rejects; honouring only the platform value would quietly raise a cap they + /// deliberately lowered. One number, computed once, so the composer never + /// has to decide. + /// + /// - Parameter accountCap: the account's own cap, or `nil` when it has not + /// been read — in which case the platform ceiling stands alone rather than + /// a guessed default standing in for it. + func effectiveMessageLength(accountCap: Int?) -> Int { + guard let accountCap else { return messageMaxContentLength } + return min(messageMaxContentLength, accountCap) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/CurrentUser.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/CurrentUser.swift index a938b1c..f04936a 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/CurrentUser.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/CurrentUser.swift @@ -68,6 +68,23 @@ public struct CurrentUser: Sendable, Equatable, Identifiable { /// default with no extra round-trip: `CurrentUserStore` already refreshes /// this on sign-in, sign-out, and restore. public let defaultPubliclyVisible: Bool + + /// The account's **own** per-message character cap, carried for the same + /// reason as `defaultPubliclyVisible`: it arrives on the `GET /api/user` + /// payload this model already maps, so the composer gets it session-cached + /// with no extra round-trip. + /// + /// This is **not** the platform ceiling. `GET /api/limits` reports + /// `message.maxContentLength: 5000`; this field's server-accepted range is + /// `1...10000`, so a user can set a cap *above* the ceiling. The composer + /// must honour the **lower** of the two — see + /// `ContentLimits.effectiveMessageLength(accountCap:)`, which is the single + /// place that decides (GitHub #46). + /// + /// `nil` when the payload omitted it, which is different from "the user + /// chose no cap" and must not be substituted with a guess. + public let maxMessageLength: Int? + public let createdAt: Date public var id: String { summary.id } @@ -88,6 +105,7 @@ public struct CurrentUser: Sendable, Equatable, Identifiable { isEmailVerified: Bool, isPrivateAccount: Bool, defaultPubliclyVisible: Bool = true, + maxMessageLength: Int? = nil, createdAt: Date ) { self.summary = summary @@ -97,6 +115,7 @@ public struct CurrentUser: Sendable, Equatable, Identifiable { self.isEmailVerified = isEmailVerified self.isPrivateAccount = isPrivateAccount self.defaultPubliclyVisible = defaultPubliclyVisible + self.maxMessageLength = maxMessageLength self.createdAt = createdAt } } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Mappers.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Mappers.swift index 557446d..d25c846 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Mappers.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Mappers.swift @@ -44,6 +44,10 @@ extension CurrentUser { // Absent field falls back to `true`, matching `UserSettings.default` // so both readings of the same payload agree. defaultPubliclyVisible: dto.defaultPubliclyVisible ?? true, + // Left `nil` when absent rather than defaulted: "no cap on the + // payload" and "the user chose a cap of 666" are different facts, + // and `effectiveMessageLength` treats them differently. + maxMessageLength: dto.maxMessageLength, createdAt: dto.createdAt ) } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileSettings.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileSettings.swift new file mode 100644 index 0000000..49a5b4f --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileSettings.swift @@ -0,0 +1,160 @@ +import Foundation + +// MARK: - AppTheme + +/// The appearance the account prefers (GitHub #46 / G34). +/// +/// The web offers exactly three. **The server validates none of them** — +/// probed live 2026-09-15, `PATCH /api/user/update` accepted `"system"`, +/// `"dark"`, `"light"`, `"auto"` and `"nonsense"` alike, storing and returning +/// each verbatim. +/// +/// That asymmetry is the whole design of this type: the client **writes** only +/// the three it understands, and **reads** anything without failing. `.unknown` +/// is therefore not defensive padding — it is the documented behaviour of an +/// unvalidated field, and an account whose theme was set elsewhere must not +/// break the settings pane. +public enum AppTheme: Sendable, Equatable, Hashable { + case system + case light + case dark + /// A value the server holds that this client does not model. Preserved + /// verbatim so a save of *other* fields cannot silently rewrite it. + case unknown(String) + + public init(wireToken: String) { + switch wireToken.lowercased() { + case "system": self = .system + case "light": self = .light + case "dark": self = .dark + default: self = .unknown(wireToken) + } + } + + public var wireToken: String { + switch self { + case .system: return "system" + case .light: return "light" + case .dark: return "dark" + case .unknown(let raw): return raw + } + } + + /// The three the picker offers. `.unknown` is deliberately absent: it is a + /// value to preserve, never one to choose. + public static let selectable: [AppTheme] = [.system, .light, .dark] + + public var displayName: String { + switch self { + case .system: return "System" + case .light: return "Light" + case .dark: return "Dark" + case .unknown(let raw): return raw + } + } +} + +// MARK: - ProfileSettings + +/// The editable identity half of the account (GitHub #46 / G34) — the fields +/// `/help/settings` groups under **Profile settings**. +/// +/// Kept separate from `UserSettings`, which models the *behavioural* +/// preferences (feed page size, tray limit, default visibility). Same account, +/// same PATCH route, different question: this is "who am I", that is "how does +/// the app behave". +public struct ProfileSettings: Sendable, Equatable { + + /// The account's own per-message character cap. + /// + /// Server-enforced range, probed live 2026-09-15: outside `1...10000` the + /// PATCH is rejected with `400 "maxMessageLength must be a positive integer + /// between 1 and 10000"`. + public static let maxMessageLengthRange: ClosedRange = 1...10_000 + + /// The longest a bio may be before the client refuses to send it. + /// + /// The server states no bio limit, so this is a **client-side sanity + /// bound**, not a mirror of a server rule — named as such so nobody later + /// reads it as verified. + public static let bioLengthLimit = 500 + + /// How you appear to others. Empty means "fall back to the username", which + /// is what the server does, so an empty string is a legitimate value rather + /// than a validation failure. + public var displayName: String + + /// The short description on the public profile. + public var bio: String + + /// The account's appearance preference. + public var theme: AppTheme + + /// The account's own message cap. Always within `maxMessageLengthRange` — + /// the setter clamps, so no caller can route around the server's rule and + /// earn a 400. + public var maxMessageLength: Int { + get { storedMaxMessageLength } + set { storedMaxMessageLength = Self.clamp(newValue, to: Self.maxMessageLengthRange) } + } + + private var storedMaxMessageLength: Int + + /// The avatar currently in use. Read-only here — it is changed through its + /// own upload/from-URL calls, not through the PATCH body. + public var avatarURL: URL? + + /// The published profile location, when the account has one. + /// + /// **Read-only, deliberately** (GitHub #57, #91). These coordinates can be + /// set through `PATCH /api/user/update` and **cannot be cleared through any + /// route**: `null`, `""`, `false` and out-of-range values are all rejected + /// `400`, and every speculative clear key returns `200` while changing + /// nothing. Offering a setter without a clear would make this client a way + /// to publish an approximate home location on a public profile that the + /// user can never take back — so until clearing exists, macOS shows the + /// value and does not write it. + public let location: ProfileLocation? + + public init( + displayName: String = "", + bio: String = "", + theme: AppTheme = .system, + maxMessageLength: Int = 666, + avatarURL: URL? = nil, + location: ProfileLocation? = nil + ) { + self.displayName = displayName + self.bio = bio + self.theme = theme + self.storedMaxMessageLength = Self.clamp(maxMessageLength, to: Self.maxMessageLengthRange) + self.avatarURL = avatarURL + self.location = location + } + + private static func clamp(_ value: Int, to range: ClosedRange) -> Int { + min(max(value, range.lowerBound), range.upperBound) + } +} + +// MARK: - ProfileLocation + +/// A published profile location. Read-only on macOS — see +/// `ProfileSettings.location`. +public struct ProfileLocation: Sendable, Equatable, Hashable { + public let latitude: Double + public let longitude: Double + + public init?(latitude: Double?, longitude: Double?) { + // A half-set pair is not a location. The server can hold one without the + // other, and rendering "47.6, —" would be worse than rendering nothing. + guard let latitude, let longitude else { return nil } + self.latitude = latitude + self.longitude = longitude + } + + /// Formatted for display at the precision the field actually carries. + public var displayText: String { + String(format: "%.4f, %.4f", latitude, longitude) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileSettingsMappers.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileSettingsMappers.swift new file mode 100644 index 0000000..0631d9e --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileSettingsMappers.swift @@ -0,0 +1,82 @@ +import Foundation +import InterlinedKit + +// MARK: - ProfileSettings ⇄ wire + +public extension ProfileSettings { + + /// Projects the account payload. + /// + /// Note what is **not** defaulted: `theme` passes through `AppTheme.init(wireToken:)`, + /// which preserves an unrecognised value rather than collapsing it to + /// `.system`. The server does not validate this field at all (probed live + /// 2026-09-15 — it stored `"nonsense"` happily), so a value this client does + /// not know is a real possibility and must survive a round-trip. + init(from dto: UserDTO) { + self.init( + displayName: dto.displayName ?? "", + bio: dto.bio ?? "", + theme: dto.theme.map(AppTheme.init(wireToken:)) ?? .system, + maxMessageLength: dto.maxMessageLength ?? 666, + avatarURL: dto.avatar.flatMap(URL.init(string:)), + location: ProfileLocation(latitude: dto.latitude, longitude: dto.longitude) + ) + } + + /// The PATCH body for a save, carrying **only what changed**. + /// + /// `UpdateUserRequest` omits nil fields, so a change-gated body is how an + /// untouched field stays untouched. Sending the whole object every time + /// would make every save a full overwrite, and two windows open on the same + /// account would clobber each other's edits. + func updateRequest(changedFrom original: ProfileSettings) -> UpdateUserRequest { + UpdateUserRequest( + displayName: displayName == original.displayName ? nil : displayName, + bio: bio == original.bio ? nil : bio, + theme: theme == original.theme ? nil : theme.wireToken, + maxMessageLength: maxMessageLength == original.maxMessageLength ? nil : maxMessageLength + ) + } + + /// `true` when nothing differs — the Save button's enablement, and the guard + /// that stops a no-op PATCH going out at all. + func hasChanges(from original: ProfileSettings) -> Bool { + displayName != original.displayName + || bio != original.bio + || theme != original.theme + || maxMessageLength != original.maxMessageLength + } + + /// Client-side validation, run before the call. + /// + /// Only rules with real backing: + /// + /// - `maxMessageLength` is clamped by the setter, so it cannot be invalid + /// here; the server's `1...10000` is mirrored in + /// `maxMessageLengthRange`. + /// - An **empty display name is valid** — the server falls back to the + /// username, which is exactly what `/help/settings` documents, so + /// rejecting it would invent a rule. + /// - The bio bound is a **client-side sanity limit**; the server states + /// none. Flagged as such so it is never mistaken for a verified rule. + var validationError: ProfileSettingsError? { + if bio.count > Self.bioLengthLimit { + return .bioTooLong(limit: Self.bioLengthLimit) + } + return nil + } +} + +/// Failures the profile pane can report without a round-trip. +public enum ProfileSettingsError: Error, Sendable, Equatable { + case bioTooLong(limit: Int) +} + +extension ProfileSettingsError: LocalizedError { + public var errorDescription: String? { + switch self { + case .bioTooLong(let limit): + return "Your bio is longer than \(limit) characters." + } + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/UserService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/UserService.swift index 3ddfbf3..e0b63c0 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/UserService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/UserService.swift @@ -119,6 +119,41 @@ public protocol UserServicing: Sendable { /// Fetches the current account's server-synced preferences /// (work-consolidation.md — settings storage). Maps `GET /api/user`. + /// Loads the identity half of the account — display name, bio, theme, + /// message cap, avatar, and the read-only profile location (GitHub #46). + func profileSettings() async throws -> ProfileSettings + + /// Saves only the fields that differ from `original`. + /// + /// Change-gated on purpose: `UpdateUserRequest` omits nil fields, so an + /// untouched field stays untouched. Sending the whole object would make + /// every save a full overwrite, and two windows open on the same account + /// would clobber each other. + /// + /// No-ops when nothing changed, rather than spending a round-trip to write + /// what is already there. + func updateProfileSettings( + _ settings: ProfileSettings, + changedFrom original: ProfileSettings + ) async throws -> ProfileSettings + + /// Sets the avatar from a remote URL (`POST /api/user/avatar/from-url`). + /// + /// The route existed in the kit with no service method behind it, so the + /// web's "set from a URL" half of the avatar control had no macOS + /// counterpart (GitHub #46). + func setAvatarFromURL(_ url: String) async throws -> URL? + + /// Starts the password-reset email flow (`POST /api/auth/forgot-password`). + /// + /// ⚠️ **There is no change-password route.** Probed 2026-09-15: the live + /// spec carries only `/api/auth/forgot-password`, `/api/auth/reset-password` + /// and an admin-only `/api/admin/users/{userId}/password`. The web's + /// "enter your current password and a new one" form has no public endpoint, + /// so this client offers reset-by-email and says so plainly rather than + /// shipping a form pointed at a route that does not exist. + func requestPasswordReset(email: String) async throws + func settings() async throws -> UserSettings /// Persists a settings snapshot via `PATCH /api/user/update` and returns the @@ -357,6 +392,40 @@ public final class UserService: UserServicing { // MARK: - Preferences (settings storage) + public func profileSettings() async throws -> ProfileSettings { + let response = try await api.send(User.current()) + return ProfileSettings(from: response.user) + } + + public func updateProfileSettings( + _ settings: ProfileSettings, + changedFrom original: ProfileSettings + ) async throws -> ProfileSettings { + if let validationError = settings.validationError { throw validationError } + // Nothing to say: a PATCH with an empty body would still be a write, and + // the server would still bump `updatedAt`. + guard settings.hasChanges(from: original) else { return original } + let response = try await api.send( + User.update(settings.updateRequest(changedFrom: original)) + ) + return ProfileSettings(from: response.user) + } + + public func setAvatarFromURL(_ url: String) async throws -> URL? { + let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines) + // A URL the client cannot parse is one the server will reject too, and + // finding out locally costs nothing. + guard !trimmed.isEmpty, URL(string: trimmed) != nil else { + throw APIError.badRequest(serverMessage: "That doesn't look like a valid image URL.") + } + let response = try await api.send(User.avatarFromURL(trimmed)) + return URL(string: response.url) + } + + public func requestPasswordReset(email: String) async throws { + _ = try await api.send(Auth.forgotPassword(email: email)) + } + public func settings() async throws -> UserSettings { let response = try await api.send(User.current()) return UserSettings(from: response.user) diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/ProfileSettingsTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/ProfileSettingsTests.swift new file mode 100644 index 0000000..bf8e86d --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/ProfileSettingsTests.swift @@ -0,0 +1,178 @@ +// ProfileSettingsTests +// +// The domain half of Settings ▸ Profile (GitHub #46 / G34). +// +// The two rules with real consequences are the change-gated PATCH body and the +// account-cap-versus-platform-ceiling reconciliation. Both are pinned against +// the live behaviour probed 2026-09-15 rather than against an assumption. + +import XCTest +@testable import InterlinedDomain +@testable import InterlinedKit + +final class ProfileSettingsTests: XCTestCase { + + /// The live account payload, captured 2026-09-15. + private let userJSON = """ + { + "id": "15e3d575-98bc-40e5-9aba-0d9cc9e30799", + "email": "messenger@interlinedlist.com", + "username": "messenger", + "displayName": "Messenger & Recon @ InterlinedList", + "avatar": "https://example.com/avatar.jpg", + "bio": "Post it once, send it everywhere.", + "theme": "light", + "emailVerified": true, + "maxMessageLength": 666, + "defaultPubliclyVisible": false, + "latitude": null, + "longitude": null, + "isPrivateAccount": false, + "cleared": true, + "accountStatus": "active", + "customerStatus": "subscriber", + "createdAt": "2026-03-23T23:23:59.755Z" + } + """ + + private func settings(from json: String) throws -> ProfileSettings { + let dto = try JSONCoders.makeDecoder().decode(UserDTO.self, from: Data(json.utf8)) + return ProfileSettings(from: dto) + } + + // MARK: - Happy path + + func test_givenTheLiveAccountPayload_whenMapping_thenEveryProfileFieldArrives() throws { + let settings = try settings(from: userJSON) + + XCTAssertEqual(settings.displayName, "Messenger & Recon @ InterlinedList") + XCTAssertEqual(settings.bio, "Post it once, send it everywhere.") + XCTAssertEqual(settings.theme, .light) + XCTAssertEqual(settings.maxMessageLength, 666) + XCTAssertEqual(settings.avatarURL?.absoluteString, "https://example.com/avatar.jpg") + XCTAssertNil(settings.location, "this account publishes no location") + } + + // MARK: - The change-gated body + + func test_givenOneChangedField_whenBuildingTheBody_thenOnlyThatFieldIsEncoded() throws { + var edited = try settings(from: userJSON) + let original = edited + edited.bio = "Analytical engines" + + let data = try JSONCoders.makeEncoder().encode(edited.updateRequest(changedFrom: original)) + let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + + XCTAssertEqual(json["bio"] as? String, "Analytical engines") + XCTAssertNil(json["displayName"], "an untouched field is absent from the body, not sent as itself") + XCTAssertNil(json["theme"]) + XCTAssertNil(json["maxMessageLength"]) + } + + func test_givenNoChanges_whenBuildingTheBody_thenItIsEmpty() throws { + let original = try settings(from: userJSON) + + let data = try JSONCoders.makeEncoder().encode(original.updateRequest(changedFrom: original)) + let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + + XCTAssertTrue(json.isEmpty) + XCTAssertFalse(original.hasChanges(from: original)) + } + + // MARK: - Theme is unvalidated server-side + + func test_givenAnUnrecognisedTheme_whenMapping_thenItIsPreservedVerbatim() throws { + // Probed live: `PATCH /api/user/update` accepted and stored "nonsense". + // Collapsing an unknown value to `.system` here would mean the first + // save of any other field silently rewrote the user's theme. + let json = userJSON.replacingOccurrences(of: "\"theme\": \"light\"", with: "\"theme\": \"solarized\"") + let settings = try self.settings(from: json) + + XCTAssertEqual(settings.theme, .unknown("solarized")) + XCTAssertEqual(settings.theme.wireToken, "solarized", "and round-trips unchanged") + } + + func test_givenTheThreeSelectableThemes_whenRoundTripping_thenTheirTokensMatchTheServers() { + XCTAssertEqual(AppTheme.system.wireToken, "system") + XCTAssertEqual(AppTheme.light.wireToken, "light") + XCTAssertEqual(AppTheme.dark.wireToken, "dark") + XCTAssertEqual(AppTheme(wireToken: "DARK"), .dark, "the server's casing is not load-bearing") + XCTAssertFalse( + AppTheme.selectable.contains(.unknown("solarized")), + "an unknown value is one to preserve, never one to offer" + ) + } + + // MARK: - Account cap versus platform ceiling + + func test_givenACapBelowTheCeiling_whenReconciling_thenTheAccountCapWins() { + // The ordinary case: a user who deliberately lowered their own limit. + XCTAssertEqual(ContentLimits.default.effectiveMessageLength(accountCap: 500), 500) + } + + func test_givenACapAboveTheCeiling_whenReconciling_thenThePlatformWins() { + // Reachable, not theoretical: the account field accepts up to 10000 and + // the platform stops at 5000. Trusting the account value here would let + // the composer accept a message the server then rejects. + XCTAssertEqual(ContentLimits.default.effectiveMessageLength(accountCap: 9_000), 5_000) + } + + func test_givenNoAccountCap_whenReconciling_thenThePlatformCeilingStandsAlone() { + // `nil` means "not read", which is different from "the user chose no + // cap" — substituting a default here would publish a guess. + XCTAssertEqual(ContentLimits.default.effectiveMessageLength(accountCap: nil), 5_000) + } + + // MARK: - Boundary — the cap's own range + + func test_givenAnOutOfRangeCap_whenSet_thenItIsClamped() { + // The server answers `400 "maxMessageLength must be a positive integer + // between 1 and 10000"`, so clamping is what stops a caller earning it. + var settings = ProfileSettings() + settings.maxMessageLength = 99_999 + XCTAssertEqual(settings.maxMessageLength, 10_000) + settings.maxMessageLength = -5 + XCTAssertEqual(settings.maxMessageLength, 1) + XCTAssertEqual(ProfileSettings(maxMessageLength: 0).maxMessageLength, 1) + } + + // MARK: - Invalid input + + func test_givenAnOverLongBio_whenValidating_thenItIsRefusedBeforeTheCall() { + var settings = ProfileSettings() + settings.bio = String(repeating: "x", count: ProfileSettings.bioLengthLimit + 1) + + XCTAssertEqual(settings.validationError, .bioTooLong(limit: ProfileSettings.bioLengthLimit)) + } + + func test_givenAnEmptyDisplayName_whenValidating_thenItIsAccepted() { + // The server falls back to the username. Rejecting it would invent a + // rule the platform does not have. + var settings = ProfileSettings() + settings.displayName = "" + + XCTAssertNil(settings.validationError) + } + + // MARK: - The location is read-only + + func test_givenAPublishedLocation_whenBuildingAnyUpdateBody_thenNoCoordinateIsEverSent() throws { + // A location can be set through this API and cleared through nothing + // (GitHub #91), so this client never writes one. The PATCH body has no + // coordinate field at all — asserted rather than assumed, because the + // failure mode is publishing a location the user cannot take back. + let json = userJSON + .replacingOccurrences(of: "\"latitude\": null", with: "\"latitude\": 47.6062") + .replacingOccurrences(of: "\"longitude\": null", with: "\"longitude\": -122.3321") + var edited = try settings(from: json) + let original = edited + XCTAssertEqual(edited.location?.latitude, 47.6062) + + edited.displayName = "Changed" + let data = try JSONCoders.makeEncoder().encode(edited.updateRequest(changedFrom: original)) + let body = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + + XCTAssertNil(body["latitude"]) + XCTAssertNil(body["longitude"]) + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift index e2a8493..264f2d3 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift @@ -201,6 +201,21 @@ public struct UpdateUserRequest: Encodable, Sendable, Equatable { /// body on 2026-09-09. public let notificationTrayLimit: Int? + /// The account's own per-message character cap. + /// + /// On `UserDTO` since the field existed and **absent from this request**, so + /// the value was readable and unwritable (GitHub #46). Verified live + /// 2026-09-15: the server accepts `1...10000` and rejects anything outside + /// it with `400 "maxMessageLength must be a positive integer between 1 and + /// 10000"`. It accepts a numeric string too; a number is sent because that + /// is what the field is. + /// + /// - Important: this is the **user's own** cap, not the platform's. + /// `GET /api/limits` reports `message.maxContentLength: 5000`, and the + /// account range reaches 10000 — so a user can set a cap *above* the + /// platform ceiling and the composer must honour the lower of the two. + public let maxMessageLength: Int? + public init( displayName: String? = nil, bio: String? = nil, @@ -211,7 +226,8 @@ public struct UpdateUserRequest: Encodable, Sendable, Equatable { showPreviews: Bool? = nil, showAdvancedPostSettings: Bool? = nil, isPrivateAccount: Bool? = nil, - notificationTrayLimit: Int? = nil + notificationTrayLimit: Int? = nil, + maxMessageLength: Int? = nil ) { self.displayName = displayName self.bio = bio @@ -223,12 +239,13 @@ public struct UpdateUserRequest: Encodable, Sendable, Equatable { self.showAdvancedPostSettings = showAdvancedPostSettings self.isPrivateAccount = isPrivateAccount self.notificationTrayLimit = notificationTrayLimit + self.maxMessageLength = maxMessageLength } private enum CodingKeys: String, CodingKey { case displayName, bio, theme, defaultPubliclyVisible, messagesPerPage case viewingPreference, showPreviews, showAdvancedPostSettings, isPrivateAccount - case notificationTrayLimit + case notificationTrayLimit, maxMessageLength } public func encode(to encoder: Encoder) throws { @@ -241,6 +258,7 @@ public struct UpdateUserRequest: Encodable, Sendable, Equatable { try container.encodeIfPresent(viewingPreference, forKey: .viewingPreference) try container.encodeIfPresent(showPreviews, forKey: .showPreviews) try container.encodeIfPresent(showAdvancedPostSettings, forKey: .showAdvancedPostSettings) + try container.encodeIfPresent(maxMessageLength, forKey: .maxMessageLength) try container.encodeIfPresent(isPrivateAccount, forKey: .isPrivateAccount) try container.encodeIfPresent(notificationTrayLimit, forKey: .notificationTrayLimit) }