diff --git a/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift b/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift index 9f0035dd22..1c3d47c35d 100644 --- a/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift +++ b/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift @@ -134,6 +134,18 @@ final class NativeGridKeyboardRouter { } func handleKeyDown(_ event: NSEvent) -> Bool { + if model.authStore.isModalPresented, !model.overlayKeyboardCaptureActive { + guard let key = KeyEvent(nsEvent: event) else { return false } + if key.key == .named(.escape), key.modifiers.isEmpty { + model.authStore.closeModal() + return true + } + if key.matches(KeyChord(modifiers: [.command], token: .character("k"))) { + model.toggleCommandPalette() + return true + } + return false + } if handleUndoRedoKeyDown(event) { return true } diff --git a/apps/calendar-macos/Compass/Notifications/CompassNotificationCenter.swift b/apps/calendar-macos/Compass/Notifications/CompassNotificationCenter.swift index 57c97da62a..3d02b288d8 100644 --- a/apps/calendar-macos/Compass/Notifications/CompassNotificationCenter.swift +++ b/apps/calendar-macos/Compass/Notifications/CompassNotificationCenter.swift @@ -1,4 +1,5 @@ import AppKit +import CompassData import CompassKit import UserNotifications @@ -25,6 +26,7 @@ final class CompassNotificationCenter { } func requestAuthorizationIfNeeded() async { + guard UITestLaunchPolicy.allowsSystemNotifications else { return } let center = UNUserNotificationCenter.current() let settings = await center.notificationSettings() if permissionString(for: settings) == "default", !didRequestAuthorization { @@ -35,6 +37,7 @@ final class CompassNotificationCenter { @discardableResult func showNative(_ payload: DesktopNotificationPayload) async -> Bool { + guard UITestLaunchPolicy.allowsSystemNotifications else { return false } let center = UNUserNotificationCenter.current() let settings = await center.notificationSettings() guard permissionString(for: settings) == "granted" else { diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+CalendarList.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+CalendarList.swift index 855a765b04..4898187d59 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+CalendarList.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+CalendarList.swift @@ -19,10 +19,16 @@ extension NativeCalendarRootModel { /// Web `PICK_KEY_LABELS`: the first 12 rows of a focused list. public static let calendarPickKeys: [Character] = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-", "="] + public var anonymousCalendar: CompassCalendar? { + guard !isSignedIn, eventsStore.source == .local, let localCalendarId else { return nil } + return sidebarCalendars.first { $0.id == localCalendarId } + } + public var calendarListGroups: [CalendarListGroup] { var order: [String] = [] var byKey: [String: (account: String?, calendars: [CompassCalendar])] = [:] - for calendar in sidebarCalendars { + let anonymousId = anonymousCalendar?.id + for calendar in sidebarCalendars where calendar.id != anonymousId { let account = isSignedIn ? calendar.accountEmail : nil let key = account.map { "\(calendar.provider):\($0)" } ?? "local" if byKey[key] == nil { diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift index 039df81c9a..f30bc5dd9b 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift @@ -27,6 +27,7 @@ public final class NativeCalendarRootModel { /// Title of the focused grid event for native UI tests and accessibility probes. public private(set) var gridFocusAccessibilityLabel: String? public private(set) var calendars: [CompassCalendar] = [] + var localCalendarId: String? public var isSignedIn: Bool { authStore.authenticated } public var shortcutRegistry: ShortcutRegistry { environment.shortcutRegistry } public private(set) var contentTrackWidth: CGFloat = 1010 @@ -1108,11 +1109,13 @@ public final class NativeCalendarRootModel { if let demoPresentation { let calendar = demoPresentation.calendarListItem() try calendarRepository.upsert(calendars: [calendar]) + localCalendarId = calendar.id } else { let sentinel = try LocalCalendarSentinel.calendarId( repository: environment.userMetadataRepository) let calendar = LocalCalendarSentinel.synthesizeCalendar(id: sentinel) try calendarRepository.upsert(calendars: [calendar]) + localCalendarId = calendar.id } calendars = try calendarRepository.fetchAll() rebuildPresentation() diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/UITestLaunchPolicy.swift b/apps/calendar-macos/CompassData/Sources/CompassData/UITestLaunchPolicy.swift index 1521d551ff..e9f2a034b1 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/UITestLaunchPolicy.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/UITestLaunchPolicy.swift @@ -17,6 +17,15 @@ public enum UITestLaunchPolicy { arguments.contains(uiTestLaunchArgument) } + /// Fixture interaction tests must not prompt for or post real system notifications. + public static var allowsSystemNotifications: Bool { + allowsSystemNotifications(arguments: ProcessInfo.processInfo.arguments) + } + + public static func allowsSystemNotifications(arguments: [String]) -> Bool { + !arguments.contains(uiTestLaunchArgument) + } + /// When set, focuses this grid event once the demo fixture layout is ready (no pointer hint). public static var initialGridFocusEventId: String? { let args = ProcessInfo.processInfo.arguments diff --git a/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelCalendarListTests.swift b/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelCalendarListTests.swift index 4850e70c50..169e91e07f 100644 --- a/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelCalendarListTests.swift +++ b/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelCalendarListTests.swift @@ -8,7 +8,7 @@ final class NativeCalendarRootModelCalendarListTests: XCTestCase { override func setUp() async throws { let fixture = try DemoSeedFixture.load() - let environment = try NativeCalendarEnvironment(inMemoryDatabase: true) + let environment = try NativeCalendarEnvironment(inMemoryDatabase: true, sessionStore: MemorySessionStore(tokens: nil)) model = NativeCalendarRootModel(environment: environment, demoPresentation: fixture) await model.start() } @@ -27,7 +27,8 @@ final class NativeCalendarRootModelCalendarListTests: XCTestCase { try XCTUnwrap(model.calendarListGroups.first?.calendars.first) } - func testNumberKeyTogglesRowInFocusedList() throws { + func testNumberKeyTogglesRowInFocusedList() async throws { + await model.authStore.bootstrap(forceDemoSignedIn: true) let calendar = try firstCalendar() XCTAssertFalse(model.pickCalendar(key: "1"), "No list has focus yet") @@ -39,7 +40,8 @@ final class NativeCalendarRootModelCalendarListTests: XCTestCase { XCTAssertFalse(model.pickCalendar(key: "="), "Key past the last row does nothing") } - func testCollapsedListIgnoresNumberKeys() throws { + func testCollapsedListIgnoresNumberKeys() async throws { + await model.authStore.bootstrap(forceDemoSignedIn: true) let calendar = try firstCalendar() let key = model.calendarListGroups[0].id model.toggleAccountCollapsed(key) @@ -53,7 +55,34 @@ final class NativeCalendarRootModelCalendarListTests: XCTestCase { XCTAssertTrue(model.calendarVisibilityStore.isVisible(calendar.id)) } + func testAnonymousCalendarHasNoVisibilityDigitPick() throws { + let calendar = try XCTUnwrap(model.anonymousCalendar) + XCTAssertTrue(model.calendarListGroups.isEmpty) + model.focusCalendarList() + XCTAssertFalse(model.pickCalendar(key: "1")) + XCTAssertTrue(model.calendarVisibilityStore.isVisible(calendar.id)) + } + + func testAdditionalCalendarsRemainToggleableInLocalMode() async throws { + let fixture = try DemoSeedFixture.load() + let environment = try NativeCalendarEnvironment(inMemoryDatabase: true, sessionStore: MemorySessionStore(tokens: nil)) + let event = try StoreSampleEvents.timedEvent(id: "additional-local", title: "Additional local event") + let extra = LocalCalendarSentinel.synthesizeCalendar(id: event.calendarId) + try environment.calendarRepository.upsert(calendars: [extra]) + try environment.localEventRepository.put(LocalEventRecord(id: event.id, event: event, isDemo: false)) + let model = NativeCalendarRootModel(environment: environment, demoPresentation: fixture) + await model.start() + + XCTAssertNotNil(model.anonymousCalendar) + XCTAssertEqual(model.calendarListGroups.flatMap(\.calendars).map(\.id), [extra.id]) + model.focusCalendarList() + XCTAssertTrue(model.pickCalendar(key: "1")) + XCTAssertFalse(model.calendarVisibilityStore.isVisible(extra.id)) + XCTAssertTrue(model.calendarVisibilityStore.isVisible(try XCTUnwrap(model.anonymousCalendar).id)) + } + func testUndoRestoresCalendarVisibility() async throws { + await model.authStore.bootstrap(forceDemoSignedIn: true) let calendar = try firstCalendar() model.toggleCalendarVisibility(calendar) XCTAssertFalse(model.calendarVisibilityStore.isVisible(calendar.id)) diff --git a/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelUpNextTests.swift b/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelUpNextTests.swift index 9cbb496086..e851299979 100644 --- a/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelUpNextTests.swift +++ b/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelUpNextTests.swift @@ -56,10 +56,11 @@ final class NativeCalendarRootModelUpNextTests: XCTestCase { /// agenda still showed the event ("Untitled event in 2m"). func testUndoRedoUndoOfACreateClearsUpNext() async throws { let environment = try NativeCalendarEnvironment(inMemoryDatabase: true) + // Start with a local calendar but no sample events competing for Up next. + try environment.userMetadataRepository.upsert(key: DemoDataSeeder.migrationKey, value: true) let model = NativeCalendarRootModel(environment: environment) await model.start() - // The redo re-creates on a writable calendar, so reuse the seed's. - let calendarId = try XCTUnwrap(model.loadedEvents.first?.calendarId.rawValue) + let calendarId = try XCTUnwrap(model.calendars.first?.id) let event = try Self.timedEvent(title: "Just created", startingIn: 2 * 60, calendarId: calendarId) let upNextTitle = { model.upNextState.snapshot.upNext?.title } try environment.localEventRepository.putMany([ diff --git a/apps/calendar-macos/CompassData/Tests/CompassDataTests/UITestLaunchPolicyTests.swift b/apps/calendar-macos/CompassData/Tests/CompassDataTests/UITestLaunchPolicyTests.swift index 8a6368193b..7a3435f25c 100644 --- a/apps/calendar-macos/CompassData/Tests/CompassDataTests/UITestLaunchPolicyTests.swift +++ b/apps/calendar-macos/CompassData/Tests/CompassDataTests/UITestLaunchPolicyTests.swift @@ -19,6 +19,12 @@ final class UITestLaunchPolicyTests: XCTestCase { arguments: ["Compass", "-COMPASS_UI_TEST", "-COMPASS_UI_TEST_OPEN_FOCUSED_EVENT_FORM"])) } + func testOnlyInteractionTestsSuppressSystemNotificationSideEffects() { + XCTAssertTrue(UITestLaunchPolicy.allowsSystemNotifications(arguments: ["/Applications/Compass.app"])) + XCTAssertTrue(UITestLaunchPolicy.allowsSystemNotifications(arguments: ["Compass", "-COMPASS_FIXTURE", "demo"])) + XCTAssertFalse(UITestLaunchPolicy.allowsSystemNotifications(arguments: ["Compass", "-COMPASS_UI_TEST"])) + } + func testUITestLaunchAttachesAccessibilityProbes() { XCTAssertTrue( UITestLaunchPolicy.attachesAccessibilityProbes(arguments: ["Compass", "-COMPASS_UI_TEST"])) diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/AnonymousCalendarRowView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/AnonymousCalendarRowView.swift new file mode 100644 index 0000000000..f99f85662c --- /dev/null +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/AnonymousCalendarRowView.swift @@ -0,0 +1,46 @@ +import CompassKit +import SwiftUI + +struct AnonymousCalendarRowView: View { + @Environment(\.nativeWebTheme) private var theme + let calendar: CompassCalendar + let onStartTrial: () -> Void + @State private var showsInfo = false + + var body: some View { + Button { showsInfo.toggle() } label: { + HStack(spacing: 8) { + Circle() + .fill(EventCardColorParser.nsColor(hex: calendar.backgroundColor).map(Color.init(nsColor:)) ?? theme.textColor) + .frame(width: 14, height: 14) + Text("On this device only") + .font(.rubik(12)) + .foregroundStyle(theme.textMutedColor) + Spacer(minLength: 0) + } + .padding(.horizontal, 4) + .padding(.vertical, 2) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Start your free trial to save this calendar") + .help("Events stay on this Mac until you connect an account.") + .popover(isPresented: $showsInfo, arrowEdge: .bottom) { + VStack(alignment: .leading, spacing: 8) { + Text("Events stay on this Mac until you connect an account.") + Text("Start your free trial to sync") + Button("Start free trial") { + showsInfo = false + onStartTrial() + } + .buttonStyle(.borderedProminent) + .tint(theme.accentColor) + } + .font(.rubik(12)) + .foregroundStyle(theme.textColor) + .padding(12) + .frame(width: 240, alignment: .leading) + .background(theme.surfaceColor) + } + } +} diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/CalendarListView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/CalendarListView.swift index 4e60411984..9fb68c325e 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/CalendarListView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/CalendarListView.swift @@ -12,6 +12,9 @@ struct CalendarListView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { + if let calendar = model.anonymousCalendar { + AnonymousCalendarRowView(calendar: calendar) { model.authStore.openModal(.signUp) } + } if showsConnectButtons { connectButtons } diff --git a/apps/calendar-macos/CompassUITests/AccessibilityIdentifierAuditTests.swift b/apps/calendar-macos/CompassUITests/AccessibilityIdentifierAuditTests.swift index 8686a89851..c8daf06e0b 100644 --- a/apps/calendar-macos/CompassUITests/AccessibilityIdentifierAuditTests.swift +++ b/apps/calendar-macos/CompassUITests/AccessibilityIdentifierAuditTests.swift @@ -22,6 +22,7 @@ final class AccessibilityIdentifierAuditTests: XCTestCase { @MainActor func testWelcomeModalIdentifierExistsOnFreshLaunch() { let app = XCUIApplication.compassUITest() + app.launchArguments += ["-\(OnboardingStorageKeys.hasSeenWelcome)", "false"] app.launch() let window = app.windows["Compass"] diff --git a/apps/calendar-macos/CompassUITests/AnonymousCalendarRowTests.swift b/apps/calendar-macos/CompassUITests/AnonymousCalendarRowTests.swift new file mode 100644 index 0000000000..32a0e2a57d --- /dev/null +++ b/apps/calendar-macos/CompassUITests/AnonymousCalendarRowTests.swift @@ -0,0 +1,24 @@ +import XCTest + +final class AnonymousCalendarRowTests: XCTestCase { + @MainActor + func testLocalCalendarExplainsSyncAndOpensSignupWithoutHidingEvents() { + let app = XCUIApplication.compassUITest() + app.launchArguments += ["-COMPASS_FIXTURE", "demo"] + app.launch() + let window = app.windows["Compass"] + let row = window.buttons["Start your free trial to save this calendar"] + XCTAssertTrue(row.waitForExistence(timeout: 10)) + let standup = window.buttons["Morning standup"] + XCTAssertTrue(standup.exists) + + row.click() + XCTAssertTrue(standup.exists) + app.buttons["Start free trial"].click() + let authModal = window.descendants(matching: .any)["compass-native-auth-modal"] + XCTAssertTrue(authModal.waitForExistence(timeout: 8)) + window.typeKey(.escape, modifierFlags: []) + XCTAssertTrue(authModal.waitForNonExistence(timeout: 8)) + XCTAssertTrue(window.buttons["Morning standup"].waitForExistence(timeout: 8)) + } +} diff --git a/apps/calendar-macos/CompassUITests/NativeLaunchTests.swift b/apps/calendar-macos/CompassUITests/NativeLaunchTests.swift index 3674cee115..13159dd47c 100644 --- a/apps/calendar-macos/CompassUITests/NativeLaunchTests.swift +++ b/apps/calendar-macos/CompassUITests/NativeLaunchTests.swift @@ -1,4 +1,5 @@ import AppKit +import CompassKit import XCTest final class NativeLaunchTests: XCTestCase { @@ -43,6 +44,7 @@ final class NativeLaunchTests: XCTestCase { @MainActor func testFreshLaunchShowsWelcomeModal() { let app = XCUIApplication.compassUITest() + app.launchArguments += ["-\(OnboardingStorageKeys.hasSeenWelcome)", "false"] app.launch() let window = app.windows["Compass"]