Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions App/Composition/AppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
13 changes: 13 additions & 0 deletions App/Composition/UserPreferencesStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
48 changes: 47 additions & 1 deletion App/Features/Compose/ComposerViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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 = ""
Expand Down Expand Up @@ -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
Expand Down
59 changes: 52 additions & 7 deletions App/Features/Compose/ComposerWindowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 11 additions & 2 deletions App/Features/Notifications/NotificationsListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion App/Features/Notifications/NotificationsRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
65 changes: 63 additions & 2 deletions App/Features/Settings/PreferencesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions App/Features/Settings/PreferencesViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int> { UserSettings.messagesPerPageRange }

/// The inclusive range the notification-tray stepper offers
/// (`min=10 max=40`, default 20).
var notificationTrayLimitRange: ClosedRange<Int> { 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,
Expand All @@ -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
Expand Down
Loading