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
33 changes: 32 additions & 1 deletion App/Features/Settings/SettingsRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,59 +12,89 @@

import SwiftUI

/// The Settings tabs, as a selectable identity.
///
/// Exists so a caller outside the Settings scene can say *which* pane it wants
/// (GitHub #45). `SettingsLink` opens the window and cannot address a tab, so
/// the sidebar's "Integrations" row stores the target here first and the scene
/// opens on it.
enum SettingsTab: String, CaseIterable, Hashable {
case linkedAccounts
case account
case preferences
case blockedAndMuted
case notifications
case security
case devices
case documentSync
case crashReporting
}

struct SettingsRootView: View {

/// The open tab, persisted so it survives the window closing — which is
/// also what lets another surface preselect it before opening.
@AppStorage("settings.selectedTab") private var selectedTab: SettingsTab = .linkedAccounts

var body: some View {
TabView {
TabView(selection: $selectedTab) {
LinkedAccountsView()
.tabItem {
Label("Linked accounts", systemImage: "link")
}
.tag(SettingsTab.linkedAccounts)

AccountSettingsView()
.tabItem {
Label("Account", systemImage: "person.crop.circle")
}
.tag(SettingsTab.account)

// Server-synced account preferences (work-consolidation.md — settings
// storage) via `POST /api/user/update`.
PreferencesView()
.tabItem {
Label("Preferences", systemImage: "slider.horizontal.3")
}
.tag(SettingsTab.preferences)

// Web-parity (work-consolidation.md G2) — blocked / muted account
// management with inline unblock / unmute.
BlockedAndMutedView()
.tabItem {
Label("Blocked & Muted", systemImage: "hand.raised")
}
.tag(SettingsTab.blockedAndMuted)

// Server-driven notification event catalogue (work-consolidation.md G18).
NotificationPreferencesView()
.tabItem {
Label("Notifications", systemImage: "bell")
}
.tag(SettingsTab.notifications)

// Active sessions with per-row revoke (work-consolidation.md G19).
SecuritySessionsView()
.tabItem {
Label("Security", systemImage: "lock.shield")
}
.tag(SettingsTab.security)

// Synced-settings device registry (work-consolidation.md G17) — the
// machines registered under this app's key.
DevicesView()
.tabItem {
Label("Devices", systemImage: "desktopcomputer")
}
.tag(SettingsTab.devices)

// Document sync agent (work-consolidation.md §3b) — enable the background helper
// that mirrors documents to a local folder for Obsidian.
DocumentSyncSettingsView()
.tabItem {
Label("Document Sync", systemImage: "arrow.triangle.2.circlepath")
}
.tag(SettingsTab.documentSync)

// Crash reporting (GitHub issue #29) — opt in to being asked, on
// the launch after a crash, whether to file a GitHub issue. A
Expand All @@ -74,6 +104,7 @@ struct SettingsRootView: View {
.tabItem {
Label("Crash Reporting", systemImage: "ladybug")
}
.tag(SettingsTab.crashReporting)
}
.frame(width: 620, height: 520)
}
Expand Down
121 changes: 99 additions & 22 deletions App/Navigation/MainWindowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,44 @@ struct MainWindowView: View {

@State private var selection: SidebarSection? = .timeline

/// Whether the Profile group is open. Persisted so the choice survives a
/// relaunch — a group that silently re-collapses every launch is a group the
/// user stops trusting.
///
/// Defaults to expanded: on first run the three rows that moved into it were
/// previously top-level, and collapsing them by default would look like they
/// had been removed.
@AppStorage("sidebar.profileGroup.expanded") private var isProfileGroupExpanded = true

/// The Settings tab the scene should open on. Shares its key with
/// `SettingsRootView`, which is how a sidebar row preselects a pane that
/// `SettingsLink` itself cannot address.
@AppStorage("settings.selectedTab") private var settingsTab: SettingsTab = .linkedAccounts

/// Selects a sidebar section, opening the Profile group first when the
/// target lives inside it.
///
/// Every deep link goes through here (GitHub #45). A menu command or a
/// notification that lands on Organizations while the group is collapsed
/// would otherwise select a row the user cannot see — the detail pane
/// changes and the sidebar appears to have ignored them.
///
/// Expanding is deliberately one-way: a deep link to a top-level row does
/// **not** collapse the group, because the user's own choice to keep it open
/// is not something a menu command should undo.
private func select(_ section: SidebarSection) {
if Self.profileGroupSections.contains(section) {
isProfileGroupExpanded = true
}
selection = section
}

/// The rows that live inside the Profile group.
///
/// One list, used by both the group's contents and `select(_:)`, so a row
/// cannot be moved into the group without deep links learning about it.
static let profileGroupSections: Set<SidebarSection> = [.profile, .organizations, .connections]

// M7 — Data Export sheet state. `pendingExportType` is set by each
// `ExportMenuCommands` notification before `showExportSheet` is flipped
// so `ExportView` receives the right initial type and can auto-start
Expand Down Expand Up @@ -109,15 +147,54 @@ struct MainWindowView: View {
Label(SidebarSection.documents.rawValue, systemImage: SidebarSection.documents.systemImage)
.tag(SidebarSection.documents)
.foregroundStyle(ILColor.onMasthead)
Label(SidebarSection.organizations.rawValue, systemImage: SidebarSection.organizations.systemImage)
.tag(SidebarSection.organizations)
.foregroundStyle(ILColor.onMasthead)
Label(SidebarSection.profile.rawValue, systemImage: SidebarSection.profile.systemImage)
.tag(SidebarSection.profile)
.foregroundStyle(ILColor.onMasthead)
Label(SidebarSection.connections.rawValue, systemImage: SidebarSection.connections.systemImage)
.tag(SidebarSection.connections)
.foregroundStyle(ILColor.onMasthead)
// The account-shaped rows, grouped (GitHub #45 / G31). On the
// web these live inside the profile/account menu; macOS had ten
// flat peers with no account grouping at all.
//
// Settings is an *additional* entrance, never a relocation: the
// panes stay in the `⌘,` scene, because macOS convention is that
// Settings is its own window and re-homing it into the detail
// column would break both that expectation and the keyboard
// shortcut users already have.
DisclosureGroup(isExpanded: $isProfileGroupExpanded) {
Label(SidebarSection.profile.rawValue, systemImage: SidebarSection.profile.systemImage)
.tag(SidebarSection.profile)
.foregroundStyle(ILColor.onMasthead)
Label(SidebarSection.organizations.rawValue, systemImage: SidebarSection.organizations.systemImage)
.tag(SidebarSection.organizations)
.foregroundStyle(ILColor.onMasthead)
Label(SidebarSection.connections.rawValue, systemImage: SidebarSection.connections.systemImage)
.tag(SidebarSection.connections)
.foregroundStyle(ILColor.onMasthead)
// `SettingsLink` opens the real Settings scene, so `⌘,`,
// File ▸ Settings and this row are three doors into one
// window rather than two different Settings.
SettingsLink {
Label("Settings", systemImage: "gearshape")
.foregroundStyle(ILColor.onMasthead)
}
.buttonStyle(.plain)
.simultaneousGesture(TapGesture().onEnded {
// Land on Preferences rather than on whichever tab was
// open last: a row labelled "Settings" that opens
// Crash Reporting reads as a bug.
settingsTab = .preferences
})

// `SettingsLink` opens the window but cannot address a tab,
// so the target is stored first and the scene opens on it.
SettingsLink {
Label("Integrations", systemImage: "app.connected.to.app.below.fill")
.foregroundStyle(ILColor.onMasthead)
}
.buttonStyle(.plain)
.simultaneousGesture(TapGesture().onEnded {
settingsTab = .linkedAccounts
})
} label: {
Label("Profile", systemImage: "person.crop.circle")
.foregroundStyle(ILColor.onMasthead)
}
}
.listStyle(.sidebar)
.scrollContentBackground(.hidden)
Expand Down Expand Up @@ -152,28 +229,28 @@ struct MainWindowView: View {
// selection without the menu commands needing to know about
// the view tree.
.onReceive(NotificationCenter.default.publisher(for: .notificationsShow)) { _ in
selection = .notifications
select(.notifications)
}
// Web-parity (work-consolidation.md G5) — the ⌘F menu command posts
// `.searchShow`; switch the sidebar to Search, then re-post
// `.searchFocus` so the field takes focus once it is on screen.
.onReceive(NotificationCenter.default.publisher(for: .searchShow)) { _ in
selection = .search
select(.search)
NotificationCenter.default.post(name: .searchFocus, object: nil)
}
// A document search hit has no typed deep-link target, so the
// search view routes here to land the user on the Documents
// section.
.onReceive(NotificationCenter.default.publisher(for: .searchShowDocuments)) { _ in
selection = .documents
select(.documents)
}
// Direct Messages (work-consolidation.md G1) — the ⌥⌘M menu command and the
// profile "Message" button post `.directMessagesShow` to route the
// sidebar to Messages. The `.directMessagesOpenThread` event that
// may accompany it is observed by `DirectMessagesRootView` itself,
// which selects the target conversation once it is on screen.
.onReceive(NotificationCenter.default.publisher(for: .directMessagesShow)) { _ in
selection = .messages
select(.messages)
}
// M5.x — System notification banner deep-link. `AppDelegate` posts
// `.notificationDeepLink` with the resolved `NotificationTarget` as
Expand All @@ -185,30 +262,30 @@ struct MainWindowView: View {
guard let target = note.object as? NotificationTarget else { return }
switch target {
case .message(let id):
selection = .timeline
select(.timeline)
pendingMessageDeepLinkID = id
case .list:
selection = .lists
select(.lists)
case .user:
// The user who acted is shown in the Connections panel
// (followers / following / requests roster).
selection = .connections
select(.connections)
case .organization:
selection = .organizations
select(.organizations)
case .unknown:
// No typed target — fall back to the notifications tray
// so the user sees the item that triggered the banner.
selection = .notifications
select(.notifications)
}
}
.onReceive(NotificationCenter.default.publisher(for: .socialShowFollowers)) { _ in
selection = .connections
select(.connections)
}
.onReceive(NotificationCenter.default.publisher(for: .socialShowFollowing)) { _ in
selection = .connections
select(.connections)
}
.onReceive(NotificationCenter.default.publisher(for: .socialShowRequests)) { _ in
selection = .connections
select(.connections)
}
// M7 — Export menu commands. Each notification maps to one ExportType;
// the sheet auto-starts the corresponding download on appear.
Expand Down Expand Up @@ -247,7 +324,7 @@ struct MainWindowView: View {
) {
if let parsed = pendingShare {
ResolveShareView(parsed: parsed, environment: environment) { _ in
selection = parsed.kind == .list ? .lists : .documents
select(parsed.kind == .list ? .lists : .documents)
}
}
}
Expand Down
70 changes: 70 additions & 0 deletions AppTests/SidebarProfileGroupTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// SidebarProfileGroupTests
//
// The sidebar's Profile group (GitHub #45 / G31).
//
// The view itself is not rendered — the project's rule is that SwiftUI views are
// verified by the build and by hand. What is tested here is the part that has
// real logic and a real failure mode: which rows belong to the group, and the
// rule that a deep link into a collapsed group must open it.
//
// That rule is the one worth guarding. Without it, a menu command that selects
// Organizations while the group is shut changes the detail pane while the
// sidebar appears to have ignored the user entirely.

import XCTest
@testable import InterlinedList

final class SidebarProfileGroupTests: XCTestCase {

// MARK: - Happy path

func test_givenTheProfileGroup_whenListingItsRows_thenItHoldsTheAccountShapedOnes() {
XCTAssertEqual(
MainWindowView.profileGroupSections,
[.profile, .organizations, .connections]
)
}

func test_givenATopLevelSection_whenAskingIfItIsGrouped_thenItIsNot() {
// The reading surfaces stay top level. Grouping Messages or Lists under
// an account menu would be the web's information architecture applied
// where it does not fit.
for section in [SidebarSection.timeline, .lists, .documents, .messages, .notifications, .search, .scheduled] {
XCTAssertFalse(
MainWindowView.profileGroupSections.contains(section),
"\(section.rawValue) should stay a top-level row"
)
}
}

// MARK: - Boundary — every section is accounted for

func test_givenEverySidebarSection_whenPartitioned_thenNoneIsOrphaned() {
// A new section added without a decision about where it goes should show
// up here rather than silently landing top-level.
let grouped = MainWindowView.profileGroupSections
let topLevel = Set(SidebarSection.allCases).subtracting(grouped)
XCTAssertEqual(grouped.count + topLevel.count, SidebarSection.allCases.count)
XCTAssertEqual(grouped.count, 3)
XCTAssertEqual(topLevel.count, 7)
}

// MARK: - Settings tabs are addressable

func test_givenTheSettingsTabs_whenAddressed_thenTheirIdentitiesAreStable() {
// The raw values are persisted in `@AppStorage`, so renaming a case
// silently sends an existing user to a different pane on next launch.
XCTAssertEqual(SettingsTab.linkedAccounts.rawValue, "linkedAccounts")
XCTAssertEqual(SettingsTab.preferences.rawValue, "preferences")
XCTAssertEqual(SettingsTab.allCases.count, 9)
}

func test_givenASettingsTabValue_whenRoundTripped_thenItSurvivesStorage() {
// `@AppStorage` round-trips a `RawRepresentable` through its raw value;
// an unreadable stored value has to fall back rather than crash.
for tab in SettingsTab.allCases {
XCTAssertEqual(SettingsTab(rawValue: tab.rawValue), tab)
}
XCTAssertNil(SettingsTab(rawValue: "a-tab-that-was-renamed"))
}
}