diff --git a/App/Composition/AppEnvironment.swift b/App/Composition/AppEnvironment.swift index 4780be6..1da2c0f 100644 --- a/App/Composition/AppEnvironment.swift +++ b/App/Composition/AppEnvironment.swift @@ -73,6 +73,23 @@ final class AppEnvironment: ObservableObject { currentUserStore.currentUser?.defaultVisibility ?? .public } + // MARK: - View preferences (G35 / issue #43) + // + // One additive block reading the account's server-synced View Preferences + // off `userPreferences`, so features get them without importing the store + // directly. All three fall back to `UserSettings.default` before the first + // load resolves, so nothing waits on a preferences round-trip. + + /// The scope a newly-opened timeline window starts on, from the account's + /// stored `viewingPreference`. + var defaultTimelineScope: TimelineScope { userPreferences.defaultTimelineScope } + + /// How many rows the notification bell tray renders (10...40, default 20). + var notificationTrayLimit: Int { userPreferences.notificationTrayLimit } + + /// Whether the composer opens with its advanced post options revealed. + var showsAdvancedPostOptionsByDefault: Bool { userPreferences.showAdvancedPostSettings } + /// Re-resolves the signed-in account's `customerStatus` (PLAN.md §8 — a /// gated call returning 403 means the subscription lapsed mid-session, so /// the UI must re-gate). The composer calls this when a gated `createPost` diff --git a/App/Composition/UserPreferencesStore.swift b/App/Composition/UserPreferencesStore.swift index 47c0096..30125c9 100644 --- a/App/Composition/UserPreferencesStore.swift +++ b/App/Composition/UserPreferencesStore.swift @@ -35,6 +35,19 @@ final class UserPreferencesStore { /// Settings ▸ Preferences ▸ Reading ▸ "Show link previews". var showLinkPreviews: Bool { settings.showPreviews } + /// The feed slice the account asked for, mapped to the timeline's own scope + /// vocabulary. Seeds a newly-opened timeline window so an account whose + /// viewing preference was set on the web sees that filter honoured on + /// launch (G35 / issue #43 acceptance criterion). + var defaultTimelineScope: TimelineScope { settings.viewingPreference.defaultScope } + + /// How many rows the notification bell tray renders (10...40, default 20). + var notificationTrayLimit: Int { settings.notificationTrayLimit } + + /// Whether the composer opens with its advanced post options (media / + /// schedule / cross-post) already revealed — the web's "gear" preference. + var showAdvancedPostSettings: Bool { settings.showAdvancedPostSettings } + init(userService: UserServicing) { self.userService = userService } diff --git a/App/Features/Compose/ComposerViewModel.swift b/App/Features/Compose/ComposerViewModel.swift index 8bf25a6..ed4c2c6 100644 --- a/App/Features/Compose/ComposerViewModel.swift +++ b/App/Features/Compose/ComposerViewModel.swift @@ -170,6 +170,19 @@ final class ComposerViewModel { return false } + /// Whether the advanced post options (media / schedule / cross-post) are + /// currently revealed, behind the gear affordance. + /// + /// Seeded from the account's `showAdvancedPostSettings` preference and + /// flipped by `toggleAdvancedOptions()`. This is what makes Settings ▸ + /// Preferences ▸ "Show advanced post options" real: before G35 / issue #43 + /// the toggle was persisted and read back by the Preferences pane and by + /// nothing else, so turning it off changed nothing in the composer. + private(set) var showsAdvancedOptions: Bool + + /// True while the gear's write-back to the account is in flight. + private(set) var isSavingAdvancedOptionsPreference: Bool = false + /// The primary-action label. Reflects the schedule-vs-send-now affordance /// (PLAN.md §6 M6) for a new message; falls back to the mode's label for an /// edit. @@ -228,7 +241,8 @@ final class ComposerViewModel { userService: UserServicing? = nil, contentLimits: ContentLimitsProviding? = nil, linkedIn: LinkedInServicing? = nil, - initialVisibility: Visibility = .public + initialVisibility: Visibility = .public, + initialShowsAdvancedOptions: Bool = true ) { self.messages = messages self.eventBus = eventBus @@ -240,6 +254,10 @@ final class ComposerViewModel { self.contentLimits = contentLimits self.linkedIn = linkedIn self.scheduledAt = Date().addingTimeInterval(3600) + // Defaults to `true` so previews and existing tests that don't pass a + // preference keep the pre-G35 behaviour (options always revealed). + // Production passes the account's real preference. + self.showsAdvancedOptions = initialShowsAdvancedOptions switch mode { case .newPost: self.body = "" @@ -269,6 +287,34 @@ final class ComposerViewModel { self.visibility = visibility } + /// Reveals or hides the advanced post options and persists the new state to + /// the account, mirroring the web gear exactly: its click handler flips the + /// panel *and* PATCHes `{ showAdvancedPostSettings }` (verified against the + /// live bundle 2026-09-09), so the choice sticks across sessions and + /// clients. + /// + /// The local flip is optimistic and is rolled back if the write fails, so + /// the panel never shows a state the account does not hold. A `nil` + /// `userService` (previews / tests) still toggles locally — the affordance + /// must work without a network seam wired. + func toggleAdvancedOptions() async { + guard !isSavingAdvancedOptionsPreference else { return } + let snapshot = showsAdvancedOptions + let desired = !snapshot + showsAdvancedOptions = desired + guard let userService else { return } + isSavingAdvancedOptionsPreference = true + defer { isSavingAdvancedOptionsPreference = false } + do { + let updated = try await userService.setShowAdvancedPostSettings(desired) + // Trust the server's answer over the optimistic guess. + showsAdvancedOptions = updated.showAdvancedPostSettings + } catch { + showsAdvancedOptions = snapshot + self.error = error + } + } + /// Adds picked / dropped file URLs as attachments. Unsupported file types /// are surfaced as an error rather than silently dropped. No-op for /// non-subscribers (the affordance is disabled in the view, but this is diff --git a/App/Features/Compose/ComposerWindowView.swift b/App/Features/Compose/ComposerWindowView.swift index fda629b..d223df6 100644 --- a/App/Features/Compose/ComposerWindowView.swift +++ b/App/Features/Compose/ComposerWindowView.swift @@ -87,7 +87,12 @@ struct ComposerWindowView: View { // The account's public/private default for new posts. Read // synchronously off the session-cached `CurrentUser`, so the // picker opens on the right value with no fetch and no flicker. - initialVisibility: environment.defaultComposeVisibility + initialVisibility: environment.defaultComposeVisibility, + // G35 / issue #43: the account's "Show advanced post + // 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 ) } if assistant == nil, let environment { @@ -150,15 +155,22 @@ struct ComposerWindowView: View { visibilityPicker(viewModel: viewModel) - // M6 — subscriber-gated controls, new messages only. + // M6 — subscriber-gated controls, new messages only. The gear + // reveals/hides them and persists the choice to the account + // (G35 / issue #43), matching the web's own affordance: "Show + // the gear icon next to the message input so you can attach + // images, video, and cross-post when composing". if viewModel.showsSubscriberControls { Divider() - if !viewModel.canUseSubscriberFeatures { - upsellHint + advancedOptionsToggle(viewModel: viewModel) + if viewModel.showsAdvancedOptions { + if !viewModel.canUseSubscriberFeatures { + upsellHint + } + mediaSection(viewModel: viewModel) + scheduleSection(viewModel: viewModel) + crossPostSection(viewModel: viewModel) } - mediaSection(viewModel: viewModel) - scheduleSection(viewModel: viewModel) - crossPostSection(viewModel: viewModel) } if let error = viewModel.error { @@ -190,6 +202,39 @@ struct ComposerWindowView: View { } } + // MARK: - Advanced options gear + + /// The gear that reveals the media / schedule / cross-post sections. Always + /// present for a new message so the controls are never simply missing; its + /// initial state comes from the account's "Show advanced post options" + /// preference and flipping it writes that preference back. + @ViewBuilder + private func advancedOptionsToggle(viewModel: ComposerViewModel) -> some View { + HStack(spacing: 6) { + Button { + Task { await viewModel.toggleAdvancedOptions() } + } label: { + Label( + viewModel.showsAdvancedOptions ? "Hide posting options" : "Posting options", + systemImage: "gearshape" + ) + .font(.ilMono(11)) + } + .buttonStyle(.plain) + .disabled(viewModel.isSavingAdvancedOptionsPreference) + .help("Media, scheduling, and cross-posting. Your choice is saved to your account.") + .accessibilityLabel("Posting options") + .accessibilityValue(viewModel.showsAdvancedOptions ? "Shown" : "Hidden") + + if viewModel.isSavingAdvancedOptionsPreference { + ProgressView() + .controlSize(.small) + .accessibilityHidden(true) + } + Spacer() + } + } + // MARK: - Upsell private var upsellHint: some View { diff --git a/App/Features/Notifications/NotificationsListViewModel.swift b/App/Features/Notifications/NotificationsListViewModel.swift index 0407918..aeb051f 100644 --- a/App/Features/Notifications/NotificationsListViewModel.swift +++ b/App/Features/Notifications/NotificationsListViewModel.swift @@ -27,6 +27,13 @@ final class NotificationsListViewModel { private let service: NotificationsServicing private let bus: NotificationsEventBus? + /// The account's `notificationTrayLimit` (10...40, default 20), passed to + /// every tray load so the bell never renders more rows than the user asked + /// for (G35 / issue #43 — the preference had no reader before this). + /// Optional so existing tests and previews construct the view model + /// unchanged and get the server's own page size. + private let trayLimit: Int? + // MARK: - Observable state /// The rendered tray rows, newest-first as the server returns them. @@ -56,10 +63,12 @@ final class NotificationsListViewModel { init( service: NotificationsServicing, - notificationsEventBus: NotificationsEventBus? = nil + notificationsEventBus: NotificationsEventBus? = nil, + trayLimit: Int? = nil ) { self.service = service self.bus = notificationsEventBus + self.trayLimit = trayLimit } // MARK: - Intents @@ -72,7 +81,7 @@ final class NotificationsListViewModel { isLoading = true defer { isLoading = false } do { - let tray = try await service.tray() + let tray = try await service.tray(limit: trayLimit) items = tray.items unreadCount = tray.unreadCount error = nil diff --git a/App/Features/Notifications/NotificationsRootView.swift b/App/Features/Notifications/NotificationsRootView.swift index a2f5346..589ca57 100644 --- a/App/Features/Notifications/NotificationsRootView.swift +++ b/App/Features/Notifications/NotificationsRootView.swift @@ -81,7 +81,9 @@ struct NotificationsRootView: View { if viewModel == nil { let vm = NotificationsListViewModel( service: environment.notificationsService, - notificationsEventBus: environment.notificationsEventBus + notificationsEventBus: environment.notificationsEventBus, + // G35 / issue #43 — the account's notification tray limit. + trayLimit: environment.notificationTrayLimit ) viewModel = vm await vm.load() diff --git a/App/Features/Settings/PreferencesView.swift b/App/Features/Settings/PreferencesView.swift index fb17cbf..a784e89 100644 --- a/App/Features/Settings/PreferencesView.swift +++ b/App/Features/Settings/PreferencesView.swift @@ -7,6 +7,12 @@ // // SwiftUI-only (no AppKit). The pane reads `AppEnvironment.userService` and // builds its view model on first appearance, mirroring the other Settings panes. +// +// G35 / issue #43 completes the "View preferences" card against the web's own +// controls (verified live 2026-09-09): posts-per-page is `10...30` (it shipped +// as `5...100 step 5`, which could save values the web cannot represent), the +// Viewing picker and the notification-tray stepper are new, and the advanced +// post options toggle now actually drives the composer's gear. import SwiftUI import InterlinedDomain @@ -44,20 +50,44 @@ struct PreferencesView: View { Section("Posting") { Toggle("New posts are public by default", isOn: boolBinding(viewModel, \.defaultPubliclyVisible)) Toggle("Show advanced post options", isOn: boolBinding(viewModel, \.showAdvancedPostSettings)) + Text("Reveals the composer's options gear — media, scheduling, and cross-posting — without clicking it first.") + .font(.ilMono(10)) + .foregroundStyle(.secondary) } Section("Reading") { Toggle("Show link previews", isOn: boolBinding(viewModel, \.showPreviews)) + + // Range mirrors the web's `min=10 max=30` exactly; step 1 + // because the web's number input has no step attribute. The + // old `5...100 step 5` could save 5 or 100, values the web + // control cannot represent (G35 / issue #43). Stepper( "Posts per page: \(viewModel.settings.messagesPerPage)", value: Binding( get: { viewModel.settings.messagesPerPage }, set: { viewModel.settings.messagesPerPage = $0 } ), - in: 5...100, - step: 5 + in: viewModel.messagesPerPageRange, + step: 1 ) .accessibilityLabel("Posts per page") + + viewingPreferencePicker(viewModel: viewModel) + + Stepper( + "Notifications in tray: \(viewModel.settings.notificationTrayLimit)", + value: Binding( + get: { viewModel.settings.notificationTrayLimit }, + set: { viewModel.settings.notificationTrayLimit = $0 } + ), + in: viewModel.notificationTrayLimitRange, + step: 1 + ) + .accessibilityLabel("Notifications in tray") + Text("How many notifications the bell tray holds before older ones drop off.") + .font(.ilMono(10)) + .foregroundStyle(.secondary) } Section("Privacy") { @@ -90,6 +120,37 @@ struct PreferencesView: View { .disabled(viewModel.isLoading) } + /// The account's default feed slice. All four documented values are offered + /// rather than hiding the two the backend cannot serve yet: hiding them + /// would silently rewrite a preference the user set on the web the moment + /// they saved anything else here. Selecting an unserved value shows an + /// honest note instead — the timeline renders its existing "coming soon" + /// empty state for that scope (P1-G, re-verified 2026-09-09). + @ViewBuilder + private func viewingPreferencePicker(viewModel: PreferencesViewModel) -> some View { + Picker( + "Viewing", + selection: Binding( + get: { viewModel.settings.viewingPreference }, + set: { viewModel.settings.viewingPreference = $0 } + ) + ) { + ForEach(viewModel.viewingPreferenceOptions, id: \.self) { preference in + Text(preference.displayName).tag(preference) + } + } + .accessibilityLabel("Viewing preference") + + if !viewModel.selectedViewingPreferenceIsServed { + Label( + "This feed isn't available yet — the timeline will show a \"coming soon\" state for it. Your choice is still saved and honoured on the web.", + systemImage: "clock.badge.exclamationmark" + ) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + } + /// A two-way binding into one boolean field of the working-copy settings. private func boolBinding( _ viewModel: PreferencesViewModel, diff --git a/App/Features/Settings/PreferencesViewModel.swift b/App/Features/Settings/PreferencesViewModel.swift index 76360c6..e40589a 100644 --- a/App/Features/Settings/PreferencesViewModel.swift +++ b/App/Features/Settings/PreferencesViewModel.swift @@ -57,6 +57,34 @@ final class PreferencesViewModel { /// Drives the Save button's enabled state. var hasChanges: Bool { settings != lastSaved } + /// The rows the Viewing picker offers: the four documented values, plus the + /// account's current value when the server sent a token this build does not + /// recognise. + /// + /// Without the second half a `.other` token would leave the `Picker` with a + /// selection matching no tag, which SwiftUI renders as a blank control — + /// and the first edit to any other field would silently rewrite the + /// unrecognised preference. Offering it keeps the round-trip honest. + var viewingPreferenceOptions: [ViewingPreference] { + let selectable = ViewingPreference.selectable + guard !selectable.contains(settings.viewingPreference) else { return selectable } + return selectable + [settings.viewingPreference] + } + + /// The inclusive range the Posts-per-page stepper offers. Mirrors the web + /// control exactly (`min=10 max=30`) so no value saved here is + /// unrepresentable there. + var messagesPerPageRange: ClosedRange { UserSettings.messagesPerPageRange } + + /// The inclusive range the notification-tray stepper offers + /// (`min=10 max=40`, default 20). + var notificationTrayLimitRange: ClosedRange { UserSettings.notificationTrayLimitRange } + + /// Whether the selected viewing preference names a feed the API can serve. + /// `false` for Followers Only / Following Only until P1-G lands; the pane + /// says so rather than implying the filter is in effect. + var selectedViewingPreferenceIsServed: Bool { settings.viewingPreference.hasBackendFeed } + init( userService: UserServicing, preferencesStore: UserPreferencesStore? = nil, @@ -75,6 +103,14 @@ final class PreferencesViewModel { error = nil defer { isLoading = false } do { + // `UserSettings` clamps `messagesPerPage` and `notificationTrayLimit` + // in its initializer, so an account that still stores an + // out-of-range value — the pane used to offer `5...100`, which the + // web's `10...30` control cannot represent — is corrected on the way + // in. Both `settings` and `lastSaved` therefore hold the clamped + // value: the pane opens clean (no phantom unsaved change), and the + // next Save for any reason writes the legal value rather than + // re-sending the illegal one. let loaded = try await userService.settings() settings = loaded lastSaved = loaded diff --git a/App/Features/Timeline/TimelineRootView.swift b/App/Features/Timeline/TimelineRootView.swift index 83978c3..8337aa0 100644 --- a/App/Features/Timeline/TimelineRootView.swift +++ b/App/Features/Timeline/TimelineRootView.swift @@ -92,6 +92,13 @@ struct TimelineRootView: View { if viewModel == nil, let environment { let model = TimelineViewModel( messages: environment.messages, + // G35 / issue #43 — open on the account's stored Viewing + // preference, so a filter set on the web is honoured on + // launch instead of always starting at All. Read + // synchronously off the preferences store, which holds + // `UserSettings.default` (All Messages) until the first + // load resolves, so this never blocks first paint. + scope: environment.defaultTimelineScope, eventBus: environment.composerEventBus ) viewModel = model @@ -247,10 +254,11 @@ struct TimelineRootView: View { ) { Text("All").tag(TimelineScope.all) Text("Mine").tag(TimelineScope.mine) + Text("Followers").tag(TimelineScope.followers) Text("Following").tag(TimelineScope.following) } .pickerStyle(.segmented) - .frame(maxWidth: 220) + .frame(maxWidth: 300) .accessibilityLabel("Timeline scope") HStack(spacing: 6) { @@ -295,12 +303,14 @@ struct TimelineRootView: View { @ViewBuilder private func content(viewModel: TimelineViewModel) -> some View { - // Following has no API endpoint yet — always show the coming-soon - // state regardless of load / error / empty conditions (App Store - // Guideline 2.1: every visible control must work or show a graceful - // unavailable state). - if viewModel.scope == .following { - followingComingSoonState + // Neither follower scope has an API endpoint yet — always show the + // coming-soon state regardless of load / error / empty conditions (App + // Store Guideline 2.1: every visible control must work or show a + // graceful unavailable state). Re-verified 2026-09-09: the API ignores + // every follower-filter parameter and returns the full timeline, so an + // "empty feed" here would actually be a mislabelled complete one. + if !viewModel.scope.hasBackendFeed { + followerFeedComingSoonState(scope: viewModel.scope) } else if let error = viewModel.error, viewModel.messagesLoaded.isEmpty { errorState(error: error, viewModel: viewModel) } else if viewModel.messagesLoaded.isEmpty, viewModel.isLoading { @@ -370,14 +380,15 @@ struct TimelineRootView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - private var followingComingSoonState: some View { - VStack(spacing: 8) { + private func followerFeedComingSoonState(scope: TimelineScope) -> some View { + let name = scope == .followers ? "Followers" : "Following" + return VStack(spacing: 8) { Image(systemName: "person.2") .font(.ilDisplay(36)) .foregroundStyle(.secondary) - Text("Following feed coming soon") + Text("\(name) feed coming soon") .font(.ilSubtitle()) - Text("The Following timeline is not yet available.") + Text("The \(name) timeline is not yet available.") .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/AppTests/PreferencesViewModelTests.swift b/AppTests/PreferencesViewModelTests.swift index c8f6192..b50f65e 100644 --- a/AppTests/PreferencesViewModelTests.swift +++ b/AppTests/PreferencesViewModelTests.swift @@ -16,14 +16,18 @@ final class PreferencesViewModelTests: XCTestCase { showPreviews: Bool = true, showAdvancedPostSettings: Bool = false, isPrivateAccount: Bool = false, - messagesPerPage: Int = 20 + messagesPerPage: Int = 20, + viewingPreference: ViewingPreference = .allMessages, + notificationTrayLimit: Int = 20 ) -> UserSettings { UserSettings( defaultPubliclyVisible: defaultPubliclyVisible, showPreviews: showPreviews, showAdvancedPostSettings: showAdvancedPostSettings, isPrivateAccount: isPrivateAccount, - messagesPerPage: messagesPerPage + messagesPerPage: messagesPerPage, + viewingPreference: viewingPreference, + notificationTrayLimit: notificationTrayLimit ) } @@ -73,14 +77,14 @@ final class PreferencesViewModelTests: XCTestCase { stub.enqueueSettings(success: settings(messagesPerPage: 20)) let viewModel = PreferencesViewModel(userService: stub) await viewModel.load() - viewModel.settings.messagesPerPage = 50 - stub.enqueueUpdateSettings(success: settings(messagesPerPage: 50)) + viewModel.settings.messagesPerPage = 28 + stub.enqueueUpdateSettings(success: settings(messagesPerPage: 28)) await viewModel.save() XCTAssertFalse(viewModel.hasChanges) - XCTAssertEqual(viewModel.settings.messagesPerPage, 50) - XCTAssertEqual(stub.lastUpdatedSettings?.messagesPerPage, 50) + XCTAssertEqual(viewModel.settings.messagesPerPage, 28) + XCTAssertEqual(stub.lastUpdatedSettings?.messagesPerPage, 28) XCTAssertNil(viewModel.error) } @@ -100,7 +104,7 @@ final class PreferencesViewModelTests: XCTestCase { stub.enqueueSettings(success: settings(messagesPerPage: 20)) let viewModel = PreferencesViewModel(userService: stub) await viewModel.load() - viewModel.settings.messagesPerPage = 40 + viewModel.settings.messagesPerPage = 25 stub.enqueueUpdateSettings(failure: URLError(.timedOut)) await viewModel.save() diff --git a/AppTests/Support/StubNotificationsService.swift b/AppTests/Support/StubNotificationsService.swift index 0294a64..30e2834 100644 --- a/AppTests/Support/StubNotificationsService.swift +++ b/AppTests/Support/StubNotificationsService.swift @@ -10,7 +10,9 @@ import InterlinedDomain struct RecordedNotificationsCall: Sendable, Equatable { enum Kind: Sendable, Equatable { - case tray + /// Carries the `limit` the view model asked for, so a test can assert + /// the account's tray preference actually reached the service. + case tray(limit: Int?) case markRead(id: String) case markAllRead } @@ -34,8 +36,8 @@ actor StubNotificationsService: NotificationsServicing { func enqueueMarkAllReadSuccess() { markAllReadOutcomes.append(.success(())) } func enqueueMarkAllRead(failure error: Error) { markAllReadOutcomes.append(.failure(error)) } - func tray() async throws -> NotificationTray { - recorded.append(.init(kind: .tray)) + func tray(limit: Int?) async throws -> NotificationTray { + recorded.append(.init(kind: .tray(limit: limit))) return try take(&trayOutcomes, label: "tray") } diff --git a/AppTests/Support/StubUserService.swift b/AppTests/Support/StubUserService.swift index f0f8e59..30ac71f 100644 --- a/AppTests/Support/StubUserService.swift +++ b/AppTests/Support/StubUserService.swift @@ -32,6 +32,8 @@ struct RecordedUserCall: Sendable, Equatable { case linkIdentityNative(provider: String, code: String, state: String) case settings case updateSettings + /// The composer gear's single-field write, with the value it sent. + case setShowAdvancedPostSettings(enabled: Bool) } let kind: Kind } @@ -54,6 +56,7 @@ final class StubUserService: UserServicing, @unchecked Sendable { private var linkIdentityNativeOutcomes: [Result] = [] private var settingsOutcomes: [Result] = [] private var updateSettingsOutcomes: [Result] = [] + private var setShowAdvancedPostSettingsOutcomes: [Result] = [] /// The settings snapshot passed to the most recent `updateSettings` call, /// so a test can assert what was sent. @@ -181,6 +184,15 @@ final class StubUserService: UserServicing, @unchecked Sendable { updateSettingsOutcomes.append(.failure(error)) } + func enqueueSetShowAdvancedPostSettings(success settings: UserSettings) { + lock.lock(); defer { lock.unlock() } + setShowAdvancedPostSettingsOutcomes.append(.success(settings)) + } + func enqueueSetShowAdvancedPostSettings(failure error: Error) { + lock.lock(); defer { lock.unlock() } + setShowAdvancedPostSettingsOutcomes.append(.failure(error)) + } + /// The settings snapshot passed to the most recent `updateSettings` call. var lastUpdatedSettings: UserSettings? { lock.lock(); defer { lock.unlock() } @@ -325,6 +337,14 @@ final class StubUserService: UserServicing, @unchecked Sendable { set: { $0.updateSettingsOutcomes = $1 } } + func setShowAdvancedPostSettings(_ enabled: Bool) async throws -> UserSettings { + try perform( + label: "setShowAdvancedPostSettings", + record: .setShowAdvancedPostSettings(enabled: enabled) + ) { $0.setShowAdvancedPostSettingsOutcomes } + set: { $0.setShowAdvancedPostSettingsOutcomes = $1 } + } + /// Synchronous lock-guarded write so the `async` `updateSettings` never /// touches `NSLock` directly (Swift 6 forbids `lock()`/`unlock()` in an /// async context — mirrors `readCachedOrganizations`). diff --git a/AppTests/ViewPreferencesParityTests.swift b/AppTests/ViewPreferencesParityTests.swift new file mode 100644 index 0000000..347d248 --- /dev/null +++ b/AppTests/ViewPreferencesParityTests.swift @@ -0,0 +1,289 @@ +// ViewPreferencesParityTests +// +// BDD-named tests for the App-layer half of the View Preferences parity fix +// (work-consolidation.md G35 / issue #43): +// +// • `PreferencesViewModel` — the corrected page-size range, the new Viewing +// picker options, and the tray-limit control; +// • `ComposerViewModel` — the gear that finally makes "Show advanced post +// options" do something; +// • `NotificationsListViewModel` — the tray limit reaching the service. +// +// View models only, against stubs — no SwiftUI rendering (per the skill's +// view-layer rule). + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class ViewPreferencesParityTests: XCTestCase { + + private func settings( + showAdvancedPostSettings: Bool = false, + messagesPerPage: Int = 20, + viewingPreference: ViewingPreference = .allMessages, + notificationTrayLimit: Int = 20 + ) -> UserSettings { + UserSettings( + defaultPubliclyVisible: true, + showPreviews: true, + showAdvancedPostSettings: showAdvancedPostSettings, + isPrivateAccount: false, + messagesPerPage: messagesPerPage, + viewingPreference: viewingPreference, + notificationTrayLimit: notificationTrayLimit + ) + } + + // MARK: - Preferences pane: ranges + + func test_givenPreferencesPane_whenReadingControlRanges_thenMatchesTheWebsBounds() { + // Happy path: the pane offers exactly what the web offers, so nothing + // saved from macOS is unrepresentable there. It used to offer + // `5...100 step 5`. + let viewModel = PreferencesViewModel(userService: StubUserService()) + + XCTAssertEqual(viewModel.messagesPerPageRange, 10...30) + XCTAssertEqual(viewModel.notificationTrayLimitRange, 10...40) + } + + func test_givenOutOfRangeStoredPageSize_whenLoading_thenClampsAndOpensClean() async { + // Boundary: an account still holding a value the old macOS control + // could save (5) must be corrected on load, not re-sent. + let stub = StubUserService() + stub.enqueueSettings(success: settings(messagesPerPage: 5, notificationTrayLimit: 99)) + let viewModel = PreferencesViewModel(userService: stub) + + await viewModel.load() + + XCTAssertEqual(viewModel.settings.messagesPerPage, 10) + XCTAssertEqual(viewModel.settings.notificationTrayLimit, 40) + XCTAssertFalse( + viewModel.hasChanges, + "The clamp is applied to both the working copy and the baseline, so the pane opens clean" + ) + } + + func test_givenTrayLimitEdit_whenSaving_thenSendsTheNewLimit() async { + // Happy path for the new control: it round-trips like any other field. + let stub = StubUserService() + stub.enqueueSettings(success: settings(notificationTrayLimit: 20)) + let viewModel = PreferencesViewModel(userService: stub) + await viewModel.load() + + viewModel.settings.notificationTrayLimit = 35 + stub.enqueueUpdateSettings(success: settings(notificationTrayLimit: 35)) + await viewModel.save() + + XCTAssertNil(viewModel.error) + XCTAssertEqual(stub.lastUpdatedSettings?.notificationTrayLimit, 35) + XCTAssertEqual(viewModel.settings.notificationTrayLimit, 35) + } + + // MARK: - Preferences pane: viewing preference + + func test_givenEachViewingPreference_whenSaved_thenRoundTripsThroughTheService() async { + // The issue's happy-path case: all four values survive a save. The + // account starts on a *different* value each time so `hasChanges` is + // genuinely true — otherwise `save()` correctly short-circuits and the + // assertion below would be testing nothing. + for preference in ViewingPreference.selectable { + let stub = StubUserService() + stub.enqueueSettings(success: settings(viewingPreference: .other("unset"))) + let viewModel = PreferencesViewModel(userService: stub) + await viewModel.load() + + viewModel.settings.viewingPreference = preference + stub.enqueueUpdateSettings(success: settings(viewingPreference: preference)) + await viewModel.save() + + XCTAssertEqual(stub.lastUpdatedSettings?.viewingPreference, preference) + XCTAssertEqual(viewModel.settings.viewingPreference, preference) + XCTAssertNil(viewModel.error) + } + } + + func test_givenKnownViewingPreference_whenOfferingOptions_thenListsTheFourDocumentedValues() async { + let stub = StubUserService() + stub.enqueueSettings(success: settings(viewingPreference: .followingOnly)) + let viewModel = PreferencesViewModel(userService: stub) + + await viewModel.load() + + XCTAssertEqual(viewModel.viewingPreferenceOptions, ViewingPreference.selectable) + } + + func test_givenUnrecognisedViewingPreference_whenOfferingOptions_thenKeepsItSelectable() async { + // Invalid input: a token this build doesn't know must still appear in + // the picker. Dropping it would leave the control blank and let the + // next unrelated save silently rewrite the account's preference. + let stub = StubUserService() + stub.enqueueSettings(success: settings(viewingPreference: .other("mentions_only"))) + let viewModel = PreferencesViewModel(userService: stub) + + await viewModel.load() + + XCTAssertEqual(viewModel.viewingPreferenceOptions.count, 5) + XCTAssertEqual(viewModel.viewingPreferenceOptions.last, .other("mentions_only")) + XCTAssertTrue( + viewModel.selectedViewingPreferenceIsServed, + "An unknown filter falls back to the All feed, which the API does serve" + ) + } + + func test_givenFollowerViewingPreference_whenSelected_thenPaneReportsTheFeedUnavailable() async { + // Both follower feeds are backend-less (P1-G, re-verified 2026-09-09), + // so the pane must say so rather than implying the filter is live. + let stub = StubUserService() + stub.enqueueSettings(success: settings(viewingPreference: .followersOnly)) + let viewModel = PreferencesViewModel(userService: stub) + await viewModel.load() + + XCTAssertFalse(viewModel.selectedViewingPreferenceIsServed) + + viewModel.settings.viewingPreference = .followingOnly + XCTAssertFalse(viewModel.selectedViewingPreferenceIsServed) + + viewModel.settings.viewingPreference = .myMessages + XCTAssertTrue(viewModel.selectedViewingPreferenceIsServed) + } + + func test_givenViewingPreferenceSaveFails_whenSaving_thenSurfacesErrorAndKeepsTheEdit() async { + // Upstream failure: the pane keeps the user's edit so they can retry, + // and the working copy is not replaced by a value the server never took. + let stub = StubUserService() + stub.enqueueSettings(success: settings(viewingPreference: .allMessages)) + let viewModel = PreferencesViewModel(userService: stub) + await viewModel.load() + + viewModel.settings.viewingPreference = .myMessages + stub.enqueueUpdateSettings(failure: URLError(.timedOut)) + await viewModel.save() + + XCTAssertNotNil(viewModel.error) + XCTAssertTrue(viewModel.hasChanges) + XCTAssertEqual(viewModel.settings.viewingPreference, .myMessages) + XCTAssertEqual(viewModel.lastSaved.viewingPreference, .allMessages) + } + + // MARK: - Composer gear (makes "Show advanced post options" real) + + func test_givenPreferenceOn_whenComposerOpens_thenAdvancedOptionsStartRevealed() { + let viewModel = ComposerViewModel( + messages: StubMessagesService(), + eventBus: ComposerEventBus(), + initialShowsAdvancedOptions: true + ) + + XCTAssertTrue(viewModel.showsAdvancedOptions) + } + + func test_givenPreferenceOff_whenComposerOpens_thenAdvancedOptionsStartHidden() { + // The bug this fixes: before G35 the preference was persisted and read + // by the Preferences pane and by nothing else, so turning it off + // changed nothing in the composer. + let viewModel = ComposerViewModel( + messages: StubMessagesService(), + eventBus: ComposerEventBus(), + initialShowsAdvancedOptions: false + ) + + XCTAssertFalse(viewModel.showsAdvancedOptions) + } + + func test_givenGearTapped_whenToggling_thenRevealsOptionsAndPersistsThePreference() async { + // Happy path, mirroring the web gear: flip the panel *and* PATCH the + // single `showAdvancedPostSettings` key. + let stub = StubUserService() + stub.enqueueSetShowAdvancedPostSettings(success: settings(showAdvancedPostSettings: true)) + let viewModel = ComposerViewModel( + messages: StubMessagesService(), + eventBus: ComposerEventBus(), + userService: stub, + initialShowsAdvancedOptions: false + ) + + await viewModel.toggleAdvancedOptions() + + XCTAssertTrue(viewModel.showsAdvancedOptions) + XCTAssertTrue(stub.recorded.contains { $0.kind == .setShowAdvancedPostSettings(enabled: true) }) + XCTAssertNil(viewModel.error) + } + + func test_givenGearWriteFails_whenToggling_thenRollsBackAndSurfacesTheError() async { + // Upstream failure + optimistic-UI rollback: the panel must not sit in + // a state the account does not hold. + let stub = StubUserService() + stub.enqueueSetShowAdvancedPostSettings(failure: URLError(.notConnectedToInternet)) + let viewModel = ComposerViewModel( + messages: StubMessagesService(), + eventBus: ComposerEventBus(), + userService: stub, + initialShowsAdvancedOptions: true + ) + + await viewModel.toggleAdvancedOptions() + + XCTAssertTrue(viewModel.showsAdvancedOptions, "The optimistic hide must be rolled back") + XCTAssertNotNil(viewModel.error) + } + + func test_givenServerDisagrees_whenToggling_thenAdoptsTheServersAnswer() async { + // The server is authoritative: if it refuses to flip the flag, the UI + // follows it rather than the optimistic guess. + let stub = StubUserService() + stub.enqueueSetShowAdvancedPostSettings(success: settings(showAdvancedPostSettings: false)) + let viewModel = ComposerViewModel( + messages: StubMessagesService(), + eventBus: ComposerEventBus(), + userService: stub, + initialShowsAdvancedOptions: false + ) + + await viewModel.toggleAdvancedOptions() + + XCTAssertFalse(viewModel.showsAdvancedOptions) + } + + func test_givenNoUserService_whenTogglingTheGear_thenStillFlipsLocally() async { + // Boundary: previews and test hosts have no network seam wired; the + // affordance must still work rather than appearing dead. + let viewModel = ComposerViewModel( + messages: StubMessagesService(), + eventBus: ComposerEventBus(), + initialShowsAdvancedOptions: false + ) + + await viewModel.toggleAdvancedOptions() + + XCTAssertTrue(viewModel.showsAdvancedOptions) + XCTAssertNil(viewModel.error) + } + + // MARK: - Notification tray limit reaches the service + + func test_givenTrayLimit_whenLoadingNotifications_thenPassesItToTheService() async { + // The preference had no reader at all before this. + let stub = StubNotificationsService() + await stub.enqueueTray(success: NotificationTray(unreadCount: 0, items: [])) + let viewModel = NotificationsListViewModel(service: stub, trayLimit: 25) + + await viewModel.load() + + let recorded = await stub.recorded + XCTAssertEqual(recorded.first?.kind, .tray(limit: 25)) + } + + func test_givenNoTrayLimit_whenLoadingNotifications_thenLeavesThePageSizeToTheServer() async { + // Boundary: the parameter is optional so existing callers are unchanged. + let stub = StubNotificationsService() + await stub.enqueueTray(success: NotificationTray(unreadCount: 0, items: [])) + let viewModel = NotificationsListViewModel(service: stub) + + await viewModel.load() + + let recorded = await stub.recorded + XCTAssertEqual(recorded.first?.kind, .tray(limit: nil)) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/TimelineScope.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/TimelineScope.swift index 3c7c6b1..8160b77 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/TimelineScope.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/TimelineScope.swift @@ -17,16 +17,38 @@ public enum TimelineScope: Sendable, Equatable, Hashable, CaseIterable { /// 2.1 requires every visible control to have a graceful unavailable /// state rather than a broken or missing one). case following + /// Feed of accounts that follow the signed-in user — the destination for + /// the account's `followers_only` viewing preference (G35 / issue #43). + /// + /// Like `.following`, it has no API endpoint. VERIFIED live 2026-09-09: + /// `GET /api/messages` honours only `onlyMine`; `?scope=following`, + /// `?viewingPreference=followers_only` and `?filter=followers_only` each + /// return the identical unfiltered page. The case exists so a web-set + /// `followers_only` preference lands on an honest "coming soon" state + /// instead of being silently rewritten to the full timeline. + case followers /// The `onlyMine` query flag this scope maps to. `nil` means "do not send /// the parameter", which the API treats as the full timeline. - /// `.following` is short-circuited in the service before this property - /// is ever consulted — the value here is kept as `nil` for safety. + /// `.following` / `.followers` are short-circuited in the service before + /// this property is ever consulted — the values here are kept as `nil` + /// for safety. public var onlyMine: Bool? { switch self { case .all: return nil case .mine: return true case .following: return nil + case .followers: return nil + } + } + + /// Whether the API can serve this scope today. `false` means the UI must + /// render the "coming soon" empty state rather than an empty feed that + /// looks like "you have no messages". + public var hasBackendFeed: Bool { + switch self { + case .all, .mine: return true + case .following, .followers: return false } } } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/UserSettings.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/UserSettings.swift index ee89527..1c066bc 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/UserSettings.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/UserSettings.swift @@ -2,58 +2,106 @@ import Foundation import InterlinedKit /// The user's server-synced account preferences (work-consolidation.md — settings -/// storage). Read from `GET /api/user` and written with `POST /api/user/update`. +/// storage; G35 / issue #43 for the View Preferences completion). Read from +/// `GET /api/user` and written with `PATCH /api/user/update`. /// /// This is the domain projection of the editable preference fields on `UserDTO`. -/// It intentionally covers only the settings with clear, verified semantics — -/// booleans and a page-size integer. `theme` and `viewingPreference` are carried -/// on the wire too, but their valid value sets aren't confirmed yet, so they are -/// left out of this typed surface (a `POST /api/user/update` omits nil fields, so -/// leaving them out never clobbers them). Adding them is a follow-up once the -/// server documents the allowed values. +/// `theme` is still carried on the wire only — its valid value set is unverified, +/// and an update omits nil fields, so leaving it out never clobbers it. +/// +/// **Ranges are enforced here, not just in the UI.** The web's View Preferences +/// card validates `messagesPerPage` to 10...30 and `notificationTrayLimit` to +/// 10...40 before it will PATCH (verified against the live client 2026-09-09), so +/// a value outside those bounds is unrepresentable on the web. Both fields are +/// therefore clamped on *read* as well as on *write*: an account that already +/// stores an out-of-range value (the macOS pane used to offer `5...100`) is +/// corrected on load rather than re-sending the bad value. public struct UserSettings: Sendable, Equatable { + /// Values the web's "Messages per page" control accepts (`min=10 max=30`). + public static let messagesPerPageRange: ClosedRange = 10...30 + + /// Values the web's "Notifications in tray" control accepts + /// (`min=10 max=40`, default 20). + public static let notificationTrayLimitRange: ClosedRange = 10...40 + /// New posts default to public visibility rather than private. public var defaultPubliclyVisible: Bool /// Render rich link-preview cards on posts. public var showPreviews: Bool - /// Reveal the advanced post options (scheduling / cross-post / media) by - /// default in the composer. + /// Reveal the advanced post options (media / scheduling / cross-post) by + /// default in the composer — the "gear" affordance the web describes. public var showAdvancedPostSettings: Bool /// The account is private (followers must be approved). public var isPrivateAccount: Bool - /// How many posts a feed page requests. - public var messagesPerPage: Int + /// Which slice of the feed the account wants by default. + public var viewingPreference: ViewingPreference + + /// How many posts a feed page requests. Always within + /// `messagesPerPageRange` — the setter clamps. + public var messagesPerPage: Int { + get { storedMessagesPerPage } + set { storedMessagesPerPage = Self.clamp(newValue, to: Self.messagesPerPageRange) } + } + + /// How many rows the notification bell tray holds. Always within + /// `notificationTrayLimitRange` — the setter clamps. + public var notificationTrayLimit: Int { + get { storedNotificationTrayLimit } + set { storedNotificationTrayLimit = Self.clamp(newValue, to: Self.notificationTrayLimitRange) } + } + + /// Backing storage for the two clamped properties. Private so no caller can + /// route around the clamp; `Equatable` still compares them because synthesised + /// conformance uses stored properties. + private var storedMessagesPerPage: Int + private var storedNotificationTrayLimit: Int public init( defaultPubliclyVisible: Bool, showPreviews: Bool, showAdvancedPostSettings: Bool, isPrivateAccount: Bool, - messagesPerPage: Int + messagesPerPage: Int, + viewingPreference: ViewingPreference = .allMessages, + notificationTrayLimit: Int = 20 ) { self.defaultPubliclyVisible = defaultPubliclyVisible self.showPreviews = showPreviews self.showAdvancedPostSettings = showAdvancedPostSettings self.isPrivateAccount = isPrivateAccount - self.messagesPerPage = messagesPerPage + self.viewingPreference = viewingPreference + self.storedMessagesPerPage = Self.clamp(messagesPerPage, to: Self.messagesPerPageRange) + self.storedNotificationTrayLimit = Self.clamp( + notificationTrayLimit, + to: Self.notificationTrayLimitRange + ) } /// Sensible fallbacks used for any field the server omits, so a partial /// payload never yields a nonsensical setting (e.g. a zero page size). + /// Matches the server's own defaults: `all_messages`, tray limit 20. public static let `default` = UserSettings( defaultPubliclyVisible: true, showPreviews: true, showAdvancedPostSettings: false, isPrivateAccount: false, - messagesPerPage: 20 + messagesPerPage: 20, + viewingPreference: .allMessages, + notificationTrayLimit: 20 ) + + private static func clamp(_ value: Int, to range: ClosedRange) -> Int { + min(max(value, range.lowerBound), range.upperBound) + } } public extension UserSettings { /// Maps the editable preference fields off a decoded `UserDTO`, falling back - /// to `default` for any absent field. + /// to `default` for any absent field. Out-of-range integers are clamped by + /// the initializer, so a legacy stored value (e.g. `messagesPerPage: 5` + /// saved by an older macOS build) is corrected on load. init(from dto: UserDTO) { let fallback = UserSettings.default self.init( @@ -61,21 +109,29 @@ public extension UserSettings { showPreviews: dto.showPreviews ?? fallback.showPreviews, showAdvancedPostSettings: dto.showAdvancedPostSettings ?? fallback.showAdvancedPostSettings, isPrivateAccount: dto.isPrivateAccount ?? fallback.isPrivateAccount, - messagesPerPage: dto.messagesPerPage ?? fallback.messagesPerPage + messagesPerPage: dto.messagesPerPage ?? fallback.messagesPerPage, + viewingPreference: dto.viewingPreference.map(ViewingPreference.init(wireToken:)) + ?? fallback.viewingPreference, + notificationTrayLimit: dto.notificationTrayLimit ?? fallback.notificationTrayLimit ) } - /// The `POST /api/user/update` body that persists this settings snapshot. - /// All five managed fields are sent so the pane's "Save" applies the full + /// The `PATCH /api/user/update` body that persists this settings snapshot. + /// All seven managed fields are sent so the pane's "Save" applies the full /// current state; other profile fields (name, bio, theme, …) are left nil /// and therefore untouched. + /// + /// The two integers are read through the clamped accessors, so this body can + /// never carry a value the web would reject. var updateRequest: UpdateUserRequest { UpdateUserRequest( defaultPubliclyVisible: defaultPubliclyVisible, messagesPerPage: messagesPerPage, + viewingPreference: viewingPreference.wireToken, showPreviews: showPreviews, showAdvancedPostSettings: showAdvancedPostSettings, - isPrivateAccount: isPrivateAccount + isPrivateAccount: isPrivateAccount, + notificationTrayLimit: notificationTrayLimit ) } } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ViewingPreference.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ViewingPreference.swift new file mode 100644 index 0000000..805c682 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ViewingPreference.swift @@ -0,0 +1,111 @@ +import Foundation + +/// Which slice of the feed the account's stored "Viewing" preference asks for +/// (work-consolidation.md G35 / issue #43). +/// +/// This is the domain projection of `UserDTO.viewingPreference`, which was +/// decoded but deliberately excluded from `UserSettings` because the valid value +/// set was unconfirmed. It is confirmed now. +/// +/// **Wire vocabulary — VERIFIED live 2026-09-09.** The web Settings form's own +/// ``), not guessed, so these cases +/// double as a contract record: if the server ever renames a token, the +/// round-trip test is what will notice. +final class ViewingPreferenceTests: XCTestCase { + + // MARK: - Happy path + + func test_givenDocumentedTokens_whenDecoding_thenMapsToTypedCases() { + XCTAssertEqual(ViewingPreference(wireToken: "my_messages"), .myMessages) + XCTAssertEqual(ViewingPreference(wireToken: "all_messages"), .allMessages) + XCTAssertEqual(ViewingPreference(wireToken: "followers_only"), .followersOnly) + XCTAssertEqual(ViewingPreference(wireToken: "following_only"), .followingOnly) + } + + func test_givenTypedCases_whenEncoding_thenProducesTheDocumentedTokens() { + XCTAssertEqual(ViewingPreference.myMessages.wireToken, "my_messages") + XCTAssertEqual(ViewingPreference.allMessages.wireToken, "all_messages") + XCTAssertEqual(ViewingPreference.followersOnly.wireToken, "followers_only") + XCTAssertEqual(ViewingPreference.followingOnly.wireToken, "following_only") + } + + func test_givenEachSelectableValue_whenResolvingScope_thenPicksTheMatchingFeed() { + XCTAssertEqual(ViewingPreference.myMessages.defaultScope, .mine) + XCTAssertEqual(ViewingPreference.allMessages.defaultScope, .all) + XCTAssertEqual(ViewingPreference.followersOnly.defaultScope, .followers) + XCTAssertEqual(ViewingPreference.followingOnly.defaultScope, .following) + } + + func test_givenSelectableList_whenOfferedToTheUser_thenMatchesTheWebFormsOptions() { + XCTAssertEqual( + ViewingPreference.selectable.map(\.wireToken), + ["my_messages", "all_messages", "followers_only", "following_only"] + ) + XCTAssertEqual( + ViewingPreference.selectable.map(\.displayName), + ["My Messages", "All Messages", "Followers Only", "Following Only"] + ) + } + + // MARK: - Invalid input + + func test_givenUnrecognisedToken_whenDecoding_thenPreservesItAndDefaultsToAll() { + let preference = ViewingPreference(wireToken: "mentions_only") + + XCTAssertEqual(preference, .other("mentions_only")) + XCTAssertEqual(preference.defaultScope, .all, "An unknown filter must not blank the feed") + XCTAssertEqual(preference.wireToken, "mentions_only", "The token must survive a round-trip") + XCTAssertFalse( + ViewingPreference.selectable.contains(preference), + "An unknown token is never offered as a choice" + ) + } + + // MARK: - Upstream / backend availability + + /// Re-verified live 2026-09-09: `GET /api/messages` honours only + /// `onlyMine`; every follower-filter parameter returns the full timeline. + /// So both follower feeds must report themselves unavailable, which is what + /// drives the timeline's "coming soon" state instead of a mislabelled feed. + func test_givenFollowerPreferences_whenCheckingBackendSupport_thenReportsUnavailable() { + XCTAssertFalse(ViewingPreference.followersOnly.hasBackendFeed) + XCTAssertFalse(ViewingPreference.followingOnly.hasBackendFeed) + XCTAssertFalse(TimelineScope.followers.hasBackendFeed) + XCTAssertFalse(TimelineScope.following.hasBackendFeed) + + XCTAssertTrue(ViewingPreference.myMessages.hasBackendFeed) + XCTAssertTrue(ViewingPreference.allMessages.hasBackendFeed) + XCTAssertTrue(TimelineScope.all.hasBackendFeed) + XCTAssertTrue(TimelineScope.mine.hasBackendFeed) + } + + // MARK: - Boundary + + func test_givenBlankToken_whenDecoding_thenTreatsItAsUnrecognisedRatherThanCrashing() { + // Whitespace-only and empty tokens are boundary inputs the server has + // never sent but could; they must degrade to the safe `.all` scope. + XCTAssertEqual(ViewingPreference(wireToken: "").defaultScope, .all) + XCTAssertEqual(ViewingPreference(wireToken: " ").defaultScope, .all) + XCTAssertEqual(ViewingPreference(wireToken: " ").wireToken, "") + } + + func test_givenPaddedToken_whenDecoding_thenTrimsBeforeMatching() { + // The trim exists so a stray newline in a payload doesn't demote a + // perfectly good preference to `.other`. + XCTAssertEqual(ViewingPreference(wireToken: " following_only\n"), .followingOnly) + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift index b8c2918..28c6c76 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift @@ -163,8 +163,18 @@ public struct UserDTO: Decodable, Sendable, Equatable { // MARK: - UpdateUserRequest -/// Request body for `POST /api/user/update`. Every field is optional so a +/// Request body for `PATCH /api/user/update`. Every field is optional so a /// caller patches only what changed; nil fields are omitted from the wire body. +/// +/// VERIFIED live 2026-09-09 (work-consolidation.md G35 / issue #43): the web +/// client's own "View Preferences" card PATCHes exactly +/// `{ messagesPerPage, viewingPreference, showPreviews, notificationTrayLimit }` +/// to this route, which settles two open questions — `notificationTrayLimit` is +/// an accepted key here (it was previously read-only on `UserDTO`), and +/// `viewingPreference` is a snake_case token, not a display string. The web +/// validates `messagesPerPage` to 10...30 and `notificationTrayLimit` to +/// 10...40 before sending; `UserSettings` clamps to the same ranges so a value +/// saved from macOS is always representable on the web. public struct UpdateUserRequest: Encodable, Sendable, Equatable { public let displayName: String? public let bio: String? @@ -175,6 +185,10 @@ public struct UpdateUserRequest: Encodable, Sendable, Equatable { public let showPreviews: Bool? public let showAdvancedPostSettings: Bool? public let isPrivateAccount: Bool? + /// How many rows the notification bell tray holds (10...40, default 20). + /// Accepted by this route — confirmed against the web client's own PATCH + /// body on 2026-09-09. + public let notificationTrayLimit: Int? public init( displayName: String? = nil, @@ -185,7 +199,8 @@ public struct UpdateUserRequest: Encodable, Sendable, Equatable { viewingPreference: String? = nil, showPreviews: Bool? = nil, showAdvancedPostSettings: Bool? = nil, - isPrivateAccount: Bool? = nil + isPrivateAccount: Bool? = nil, + notificationTrayLimit: Int? = nil ) { self.displayName = displayName self.bio = bio @@ -196,11 +211,13 @@ public struct UpdateUserRequest: Encodable, Sendable, Equatable { self.showPreviews = showPreviews self.showAdvancedPostSettings = showAdvancedPostSettings self.isPrivateAccount = isPrivateAccount + self.notificationTrayLimit = notificationTrayLimit } private enum CodingKeys: String, CodingKey { case displayName, bio, theme, defaultPubliclyVisible, messagesPerPage case viewingPreference, showPreviews, showAdvancedPostSettings, isPrivateAccount + case notificationTrayLimit } public func encode(to encoder: Encoder) throws { @@ -214,6 +231,7 @@ public struct UpdateUserRequest: Encodable, Sendable, Equatable { try container.encodeIfPresent(showPreviews, forKey: .showPreviews) try container.encodeIfPresent(showAdvancedPostSettings, forKey: .showAdvancedPostSettings) try container.encodeIfPresent(isPrivateAccount, forKey: .isPrivateAccount) + try container.encodeIfPresent(notificationTrayLimit, forKey: .notificationTrayLimit) } } diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/NotificationsEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/NotificationsEndpoint.swift index 006170c..1aab7e7 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/NotificationsEndpoint.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/NotificationsEndpoint.swift @@ -18,11 +18,23 @@ import Foundation public enum Notifications { /// `GET /api/notifications` (with `scope=tray`). - public static func tray(scope: String = "tray") -> Request { + /// + /// `limit` is optional and omitted by default. VERIFIED live 2026-09-09 + /// (G35 / issue #43): the route honours `limit` in its *unscoped* form + /// (`?limit=5` → 5 rows, `?limit=40` → 36 rows on the test account) and + /// falls back to the account's `notificationTrayLimit` when absent + /// (no params → exactly 20 rows, matching the stored preference). Under + /// `scope=tray` the server currently ignores `limit` and returns unread + /// rows only, so the preference is *also* enforced client-side by + /// `NotificationsService.tray(limit:)` — see the note there. + public static func tray(scope: String = "tray", limit: Int? = nil) -> Request { Request( method: .get, path: "/api/notifications", - query: [.string("scope", scope)], + query: [ + .string("scope", scope), + .int("limit", limit) + ], auth: .bearer ) } diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/NotificationsEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/NotificationsEndpointTests.swift index 71f1703..a0c395e 100644 --- a/Packages/InterlinedKit/Tests/InterlinedKitTests/NotificationsEndpointTests.swift +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/NotificationsEndpointTests.swift @@ -34,6 +34,24 @@ final class NotificationsEndpointTests: XCTestCase { XCTAssertEqual(Notifications.markAllRead().path, "/api/notifications/mark-all-read") } + // MARK: - Tray limit (G35 / issue #43) + + func test_givenTrayLimit_whenTrayBuilt_thenSendsItAlongsideScope() { + // Happy path: the account's `notificationTrayLimit` reaches the wire. + let request = Notifications.tray(limit: 25) + + XCTAssertEqual(request.query.first(where: { $0.name == "scope" })?.value, "tray") + XCTAssertEqual(request.query.first(where: { $0.name == "limit" })?.value, "25") + } + + func test_givenNoTrayLimit_whenTrayBuilt_thenOmitsTheParameter() { + // Boundary: `nil` must leave the page size to the server rather than + // sending `limit=` and risking a zero-row tray. + let request = Notifications.tray() + + XCTAssertNil(request.query.first(where: { $0.name == "limit" })?.value) + } + // MARK: - Happy path func test_givenTrayBody_whenTraySent_thenDecodesUnreadCountAndItems() async throws { diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift index 9e5d701..8e72888 100644 --- a/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift @@ -194,6 +194,44 @@ final class UserEndpointTests: XCTestCase { XCTAssertTrue(body.isEmpty) } + // MARK: - View Preferences body (G35 / issue #43) + + func test_givenViewPreferences_whenUpdateBuilt_thenEncodesTheWebsFourKeys() throws { + // Happy path: the exact body the web's own "View Preferences" card + // PATCHes, verified against the live bundle 2026-09-09. + let request = User.update(UpdateUserRequest( + messagesPerPage: 15, + viewingPreference: "followers_only", + showPreviews: false, + notificationTrayLimit: 35 + )) + + let body = try encodedBody(request) + XCTAssertEqual(body["messagesPerPage"] as? Int, 15) + XCTAssertEqual(body["viewingPreference"] as? String, "followers_only") + XCTAssertEqual(body["showPreviews"] as? Bool, false) + XCTAssertEqual(body["notificationTrayLimit"] as? Int, 35) + } + + func test_givenNoTrayLimit_whenUpdateBuilt_thenOmitsItSoItIsNeverClobbered() throws { + // Invalid-by-omission guard: patching an unrelated field must not send + // `notificationTrayLimit: null` and wipe the account's stored value. + let request = User.update(UpdateUserRequest(displayName: "Ada")) + + let body = try encodedBody(request) + XCTAssertNil(body["notificationTrayLimit"]) + XCTAssertNil(body["viewingPreference"]) + } + + func test_givenBoundaryTrayLimits_whenUpdateBuilt_thenEncodesThemVerbatim() throws { + // Boundary: the request body is a faithful mirror — range enforcement + // is the domain layer's job (`UserSettings` clamps), not the DTO's. + for limit in [10, 40] { + let body = try encodedBody(User.update(UpdateUserRequest(notificationTrayLimit: limit))) + XCTAssertEqual(body["notificationTrayLimit"] as? Int, limit) + } + } + func test_givenValidPatch_whenUpdateSent_thenReturnsUpdatedUser() async throws { let (client, transport, _) = makeClient() await transport.enqueue(.json(userEnvelopeJSON)) diff --git a/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Mapping/MessageRecordMapping.swift b/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Mapping/MessageRecordMapping.swift index 8a079b1..2829be6 100644 --- a/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Mapping/MessageRecordMapping.swift +++ b/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Mapping/MessageRecordMapping.swift @@ -101,9 +101,11 @@ extension TimelineScope { switch self { case .all: return "all" case .mine: return "mine" - // Following has no API backing yet; a stable key is still needed so - // any future cache entries survive enum expansion without corruption. + // Neither follower scope has API backing yet; stable keys are still + // needed so any future cache entries survive enum expansion without + // corruption. case .following: return "following" + case .followers: return "followers" } } }