From 98c9ec36bc3eca9c041243f67e790c632ab6ae8c Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Tue, 15 Sep 2026 04:58:30 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(settings):=20a=20Profile=20pane=20?= =?UTF-8?q?=E2=80=94=20display=20name,=20bio,=20avatar,=20theme=20and=20th?= =?UTF-8?q?e=20message=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. This adds the pane, and answers the four questions the issue said to probe before building rather than guessing at any of them. Theme is unvalidated server-side. PATCH /api/user/update stored "system", "dark", "light", "auto" and "nonsense" alike. So `.unknown(String)` is not defensive padding, it is the documented behaviour — and the picker has to *offer* the account's own value when it is unrecognised, or the Picker has a selection matching no tag and the first edit to any other field silently rewrites the theme. There is a test asserting the PATCH body omits an untouched unknown theme. There is no change-password route. The live spec has only forgot-password, reset-password and an admin-only one; the web's "current password and a new one" form has no public endpoint. The pane ships reset-by-email and says so, rather than pointing a form at a route that does not exist. The message-cap trap is real, not theoretical. GET /api/limits reports a platform ceiling of 5000 while the account field accepts up to 10000, so a user can set a cap above the ceiling. `ContentLimits.effectiveMessageLength(accountCap:)` is now the single place that decides, returning the lower of the two. The composer was reading the platform ceiling alone and ignoring a cap the user had deliberately set; it now takes the account's cap from the session-cached CurrentUser, which carries it for the same reason it carries defaultPubliclyVisible — it is on the same payload. The setter clamps to 1...10000 so no caller can earn the server's 400, and the pane shows both numbers and says which one is really in force. Both avatar routes exist, so the pane offers a file picker and a URL field like the web. The avatar is written outside the change-gated body, so the saved snapshot is updated alongside the working copy — otherwise Save would light up claiming an unsaved change that does not exist. Profile location is shown and not editable, folded in from #57. That issue was re-scoped after its own probe found that a location can be set through this API and cleared through nothing (#91). Showing it closes the "invisible" half; offering a setter would make this client a way to publish an approximate home location on a public profile that the user can never take back. The pane states that plainly instead of hiding the field. ProfileSettings is deliberately separate from UserSettings: same account, same PATCH route, different question — this is "who am I", that is "how does the app behave". The save reuses PreferencesView's change-gated idiom rather than introducing a second one, so an untouched field is absent from the body and two windows on the same account cannot clobber each other. The account theme does not drive the app's appearance. Storing it and making the Mac follow the system is what ships; driving NSApp.appearance from a server preference is a behaviour change worth its own decision, and the pane says what the setting does today rather than implying more. Refs #46, #57 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016gSWb3scYobtxLJioV1qF9 --- App/Features/Compose/ComposerViewModel.swift | 23 +- App/Features/Compose/ComposerWindowView.swift | 8 +- .../Settings/ProfileSettingsView.swift | 296 ++++++++++++++++++ .../Settings/ProfileSettingsViewModel.swift | 238 ++++++++++++++ App/Features/Settings/SettingsRootView.swift | 8 + AppTests/ProfileSettingsViewModelTests.swift | 248 +++++++++++++++ AppTests/Support/StubUserService.swift | 84 +++++ .../Models/ContentLimits.swift | 28 ++ .../InterlinedDomain/Models/CurrentUser.swift | 19 ++ .../InterlinedDomain/Models/Mappers.swift | 4 + .../Models/ProfileSettings.swift | 160 ++++++++++ .../Models/ProfileSettingsMappers.swift | 82 +++++ .../Services/UserService.swift | 69 ++++ .../ProfileSettingsTests.swift | 178 +++++++++++ .../Sources/InterlinedKit/DTOs/UserDTO.swift | 22 +- 15 files changed, 1462 insertions(+), 5 deletions(-) create mode 100644 App/Features/Settings/ProfileSettingsView.swift create mode 100644 App/Features/Settings/ProfileSettingsViewModel.swift create mode 100644 AppTests/ProfileSettingsViewModelTests.swift create mode 100644 Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileSettings.swift create mode 100644 Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileSettingsMappers.swift create mode 100644 Packages/InterlinedDomain/Tests/InterlinedDomainTests/ProfileSettingsTests.swift 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/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 f8fd8a7..c0c323d 100644 --- a/App/Features/Settings/SettingsRootView.swift +++ b/App/Features/Settings/SettingsRootView.swift @@ -26,6 +26,14 @@ struct SettingsRootView: View { Label("Account", systemImage: "person.crop.circle") } + // 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") + } + // Server-synced account preferences (work-consolidation.md — settings // storage) via `POST /api/user/update`. PreferencesView() 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/Support/StubUserService.swift b/AppTests/Support/StubUserService.swift index cdacef6..351c46d 100644 --- a/AppTests/Support/StubUserService.swift +++ b/AppTests/Support/StubUserService.swift @@ -35,6 +35,12 @@ struct RecordedUserCall: Sendable, Equatable { case updateSettings /// The composer gear's single-field write, with the value it sent. case setShowAdvancedPostSettings(enabled: Bool) + 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) } let kind: Kind } @@ -58,6 +64,10 @@ final class StubUserService: UserServicing, @unchecked Sendable { private var linkIdentityNativeOutcomes: [Result] = [] private var settingsOutcomes: [Result] = [] private var updateSettingsOutcomes: [Result] = [] + private var profileSettingsOutcomes: [Result] = [] + private var updateProfileSettingsOutcomes: [Result] = [] + private var setAvatarFromURLOutcomes: [Result] = [] + private var requestPasswordResetOutcomes: [Result] = [] private var setShowAdvancedPostSettingsOutcomes: [Result] = [] /// The settings snapshot passed to the most recent `updateSettings` call, @@ -179,6 +189,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 enqueueSettings(success settings: UserSettings) { lock.lock(); defer { lock.unlock() } settingsOutcomes.append(.success(settings)) @@ -349,6 +392,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 settings() async throws -> UserSettings { try perform(label: "settings", record: .settings) { $0.settingsOutcomes } set: { $0.settingsOutcomes = $1 } 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 6d21d0f..592923b 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/UserService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/UserService.swift @@ -97,6 +97,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 @@ -315,6 +350,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 d4d216e..e48829e 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) } From 2437e544c1a491e197cc8bf5cb4dffec8a0d4f7f Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 12:45:19 -0700 Subject: [PATCH 2/2] fix(build): qualify InterlinedDomain.Document so the app compiles on Xcode 27 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dev` stopped building when Xcode was updated on this machine mid-session. The macOS 27 SDK adds a `Document` protocol to SwiftUI — public protocol Document: ReadableDocument, WritableDocument — which collides with the domain's `Document` struct in any file importing both. That is every documents-feature view, and the same unchanged source went from compiling to eleven `'Document' is ambiguous for type lookup` errors across eight files. Only the SwiftUI-importing files are affected, which is what makes the diagnosis unambiguous: the view models import Foundation, Observation and InterlinedDomain but not SwiftUI, and they compile untouched. The fix is to qualify the type at the use sites. Three alternatives were considered and rejected. Renaming the domain model is the tail wagging the dog — `Document` is the right name, and it is correct across Kit, Domain, Persistence and their tests. A module-level typealias would shorten the use sites at the cost of giving one concept two names, so the next reader has to learn they are the same thing. Dropping `import SwiftUI` is not available; these are views. Every edit is a type position. No user-facing string, accessibility label or other identifier containing the word Document is touched — the diff is eleven lines, each one a `Document` that the compiler itself pointed at. Worth knowing rather than fixing: this is a standing hazard. Any domain type sharing a name with a SwiftUI symbol is one SDK update away from the same break, and the diagnosis is written down at the top of DocumentsListView so the next occurrence takes minutes. Refs #98 Co-Authored-By: Claude Opus 5 --- App/Features/AI/AIDocumentSheet.swift | 2 +- .../Documents/ConflictBannerView.swift | 2 +- .../Documents/DocumentEditorView.swift | 2 +- .../DocumentTemplatePickerView.swift | 2 +- .../Documents/DocumentsListView.swift | 23 ++++++++++++++++--- .../Documents/DocumentsRootView.swift | 4 ++-- .../Documents/PublicUserDocumentsView.swift | 2 +- App/Features/Search/SearchRootView.swift | 2 +- 8 files changed, 28 insertions(+), 11 deletions(-) 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/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 9613180..8914bef 100644 --- a/App/Features/Documents/PublicUserDocumentsView.swift +++ b/App/Features/Documents/PublicUserDocumentsView.swift @@ -111,7 +111,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) {