diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.test.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.test.tsx new file mode 100644 index 000000000000..607e6918c396 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.test.tsx @@ -0,0 +1,102 @@ +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const { Children, harness } = vi.hoisted( + (): { + Children: (props: { children?: ReactNode }) => ReactNode; + harness: { cloudConfigured: boolean }; + } => ({ + Children: ({ children }) => children, + harness: { cloudConfigured: false }, + }), +); + +vi.mock("react-native", () => ({ View: Children, Platform: { OS: "ios" } })); +vi.mock("@clerk/expo", () => ({ + useAuth: () => ({ isLoaded: true, isSignedIn: false }), + useUser: () => ({ user: null }), +})); +vi.mock("@react-navigation/native", () => ({ useNavigation: () => ({}) })); +vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) })); +vi.mock("../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => harness.cloudConfigured })); +vi.mock("../workspace/navigation-tab-order", () => ({ + navigationCustomizationAvailable: () => false, + overflowNavigationTabs: () => [], +})); +vi.mock("../workspace/navigation-tabs", () => ({ + NAVIGATION_TAB_ICONS: {}, + WORKSPACE_TAB_LABELS: {}, + useNavigationTabOrder: () => [], + useHiddenNavigationTabs: () => [], +})); +vi.mock("../workspace/workspace-selection-store", () => ({ + currentWorkspaceSelections: () => ({}), +})); +vi.mock("../workspace/workspace-tab-navigation", () => ({ rememberedSettingsPage: () => null })); +vi.mock("../workspace/useSelectWorkspaceTab", () => ({ useSelectWorkspaceTab: () => vi.fn() })); +vi.mock("../workspace/WorkspaceTabsContext", () => ({ useWorkspaceTabs: () => null })); +vi.mock("../workspace/useWorkspaceSurface", () => ({ + useWorkspaceSurface: () => ({ className: "bg-screen" }), +})); +vi.mock("../layout/adaptive-workspace-context", () => ({ + useAdaptiveWorkspaceLayout: () => ({ layout: { usesSplitView: false } }), +})); +vi.mock("../../native/native-layout-metrics", () => ({ useNativeColumnLayoutMetrics: () => null })); +vi.mock("../../native/v5-workspace-context", async () => { + const { createContext } = await import("react"); + return { NativePrimaryColumnContext: createContext(null) }; +}); +vi.mock("../../state/use-remote-environment-registry", () => ({ + useSavedRemoteConnections: () => ({ savedConnectionsById: {} }), +})); +vi.mock("../../components/ui/ScreenScrollView", () => ({ ScreenScrollView: Children })); +vi.mock("../../components/ui/EmptyState", () => ({ EmptyState: () => null })); +vi.mock("./components/SettingsScreen", () => ({ SettingsScreen: Children })); +vi.mock("./components/SettingsSection", async () => { + const { createContext } = await import("react"); + return { SettingsSection: Children, SettingsSidebarContext: createContext(null) }; +}); +vi.mock("./components/SettingsEnvironmentFilterHeader", () => ({ + SettingsEnvironmentFilterHeader: () => null, + AndroidSettingsEnvironmentFilter: () => null, +})); +vi.mock("./components/SettingsRow", () => ({ + SettingsRow: ({ label }: { label: string }) => , +})); +vi.mock("./settings-environment-filter", () => ({ + useSettingsEnvironmentFilter: () => ({ + selectedTargets: [], + projectGroups: [], + selectedProjectKey: null, + }), +})); + +import { SettingsListScreen } from "./SettingsRouteScreen"; + +interface TestScreen extends Disposable { + readonly container: HTMLDivElement; +} + +function renderSettings(): TestScreen { + const container: HTMLDivElement = document.createElement("div"); + const root: Root = createRoot(container); + try { + act(() => root.render()); + return { container, [Symbol.dispose]: () => act(() => root.unmount()) }; + } catch (error) { + act(() => root.unmount()); + throw error; + } +} + +it.each([false, true])("shows notification settings with cloudConfigured=%s", (cloudConfigured) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + harness.cloudConfigured = cloudConfigured; + using screen = renderSettings(); + const notifications: HTMLButtonElement[] = Array.from( + screen.container.querySelectorAll("button"), + ).filter((button) => button.textContent === "Notifications"); + expect(notifications).toHaveLength(1); +}); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index c2fd57962211..aa2f26bd1138 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -127,13 +127,7 @@ function ConfiguredSettingsRouteScreen() { disabled: !isLoaded, onPress: () => navigation.navigate("SettingsAuth"), }, - environmentsRow(Object.keys(savedConnectionsById).length), - { - id: "SettingsNotifications", - icon: "bell.badge", - label: "Notifications", - target: "SettingsNotifications", - }, + ...connectionRows(Object.keys(savedConnectionsById).length), ]} /> ); @@ -141,18 +135,26 @@ function ConfiguredSettingsRouteScreen() { function LocalSettingsRouteScreen() { const { savedConnectionsById } = useSavedRemoteConnections(); - return ; + return ; } -function environmentsRow(count: number): SettingsListRow { - return { - id: "SettingsEnvironments", - icon: "desktopcomputer", - label: "Environments", - value: `${count}`, - valuePosition: "trailing", - target: "SettingsEnvironments", - }; +function connectionRows(count: number): SettingsListRow[] { + return [ + { + id: "SettingsEnvironments", + icon: "desktopcomputer", + label: "Environments", + value: `${count}`, + valuePosition: "trailing", + target: "SettingsEnvironments", + }, + { + id: "SettingsNotifications", + icon: "bell.badge", + label: "Notifications", + target: "SettingsNotifications", + }, + ]; } function SettingsList(props: { readonly connections: readonly SettingsListRow[] }) { diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 5e2e5a8e3560..e1b2dd056931 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -23,6 +23,7 @@ const reactTests = [ "src/features/files/ZipArchiveView.test.tsx", "src/features/pull-requests/guide/useReviewGuide.test.tsx", "src/features/settings/SettingsServerControlsRouteScreen.test.tsx", + "src/features/settings/SettingsRouteScreen.test.tsx", "src/features/settings/components/BranchNamingSettings.test.tsx", "src/features/settings/components/SettingsDragHandle.test.tsx", "src/features/threads/ThreadMarkdownImage.test.tsx", diff --git a/wiki/log.md b/wiki/log.md index a9ba4734ad98..5e07891a589c 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1,5 +1,9 @@ # Wiki update log +## 2026-11-04 + +- **Compatibility boundary**: [Paired Android notifications](paired-notifications.md) must remain reachable from mobile Settings without T3 Connect configuration. Recorded the local-only menu entry lost during the November upstream integration and its regression coverage. + ## 2026-11-02 - **Integration**: [PR #52](https://github.com/NicholasZolton/t3code/pull/52) reached fork `main` at `9a73f5dd04`. Refreshed [upstream integration](upstream-integration.md#completed-november-2-sync): all 3,337 incoming commits through `dd6c05ec31` are incorporated with ancestry preserved and zero main gap. The main local checkout is fast-forwarded; remote deployment remains separate. diff --git a/wiki/paired-notifications.md b/wiki/paired-notifications.md index 297a36d47add..d06a63bb865b 100644 --- a/wiki/paired-notifications.md +++ b/wiki/paired-notifications.md @@ -6,7 +6,7 @@ tags: [fork, android, notifications, privacy] status: experimental generated: by: opencode/gpt-6.1-sol - at: 2026-10-12T00:22:28Z + at: 2026-11-04T00:47:48Z sources: - id: paired-delivery resource: ../apps/server/src/notifications/PairedNotifications.ts @@ -17,6 +17,18 @@ sources: - id: desktop-credentials resource: ../apps/desktop/src/shell/DesktopShellEnvironment.ts title: macOS Keychain fallback before local backend startup + - id: paired-settings-entry + resource: ../apps/mobile/src/features/settings/SettingsRouteScreen.tsx + title: Shared notification settings entry for local-only and Connect-configured builds + - id: paired-settings-test + resource: ../apps/mobile/src/features/settings/SettingsRouteScreen.test.tsx + title: Notification settings remain visible without cloud configuration + - id: original-settings-entry + resource: https://github.com/NicholasZolton/t3code/commit/faf154ec9fa11671e1cdf348637ddd8899c94551 + title: October 10 paired notifications added the local-only settings entry + - id: settings-regression-integration + resource: https://github.com/NicholasZolton/t3code/commit/ed52b86b6a9ffb6afad94980cc3d7d8cb5c851aa + title: November upstream settings integration omitted the fork's local-only notification entry --- # Paired Android notifications @@ -25,6 +37,8 @@ The fork supports Google notification delivery without routing activity through Reuse the shared Connect notification policy and native presentation, not a second set of alert rules. Rich titles, status and thread routes are allowed inside encrypted paired payloads; do not send them to FCM in plaintext or add conversation content. The phone retains the private key and supplies only its public key through the existing authenticated registration. Preserve registration binding, freshness, ordering, opt-out, session revocation and independent per-environment state. Connect sign-out must not clear paired keys or activity. Thread access after a notification tap still requires the environment's normal authentication.[^paired-delivery] +Keep the mobile notification settings entry available without T3 Connect configuration. The paired settings screen already handles that build mode; gating its navigation on cloud configuration makes Android opt-in unreachable. The October 10 fork added this entry, but the November upstream settings integration dropped it from the local-only list. Shared connection rows and a cloud-configuration regression test preserve this boundary.[^paired-settings-entry][^paired-settings-test][^original-settings-entry][^settings-regression-integration] + Google still processes device identifiers and delivery metadata. This reduces transmitted content, not a compliance guarantee. Private binaries must retain their package and signing identity and use the same Firebase project as the sending server.[^paired-operations] Finder/Dock launches cannot rely on a fnox-wrapped shell. The macOS desktop can load the existing Firebase credential from login Keychain before spawning its local server, while preserving an explicitly supplied environment variable. Keep this fallback desktop-local: it must not forward credentials to SSH hosts or require a checkout-specific secret manager.[^desktop-credentials] @@ -34,3 +48,11 @@ Finder/Dock launches cannot rely on a fnox-wrapped shell. The macOS desktop can [^paired-operations]: [Android notification operations](../docs/operations/android-notifications.md#self-hosted-paired-delivery). [^desktop-credentials]: [Desktop startup environment](../apps/desktop/src/shell/DesktopShellEnvironment.ts). + +[^paired-settings-entry]: [Mobile settings connections](../apps/mobile/src/features/settings/SettingsRouteScreen.tsx). + +[^paired-settings-test]: [Cloud-independent notification navigation test](../apps/mobile/src/features/settings/SettingsRouteScreen.test.tsx). + +[^original-settings-entry]: [Original paired-notification settings entry](https://github.com/NicholasZolton/t3code/commit/faf154ec9fa11671e1cdf348637ddd8899c94551). + +[^settings-regression-integration]: [Upstream settings integration](https://github.com/NicholasZolton/t3code/commit/ed52b86b6a9ffb6afad94980cc3d7d8cb5c851aa).