From 43514fb96a886086ac8a7d2da58da4d0e15ca17a Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 6 Oct 2026 19:35:16 +0100 Subject: [PATCH 1/5] Show the account in the sidebar instead of the version The sidebar footer now shows the settings sync account: "Account" with the signed-in email (shortened with an ellipsis), or "Sign in" when signed out. Selecting it opens Settings on the Account section with focus there, the way update notices open the Updates section. It follows account changes live. Builds without accounts keep showing the version, which also remains in Settings > Updates and on Home. Refs #992 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.test.tsx | 52 ++++++++++++++++++++++++++++++++- src/App.tsx | 41 +++++++++++++++++++++++--- src/settings/AccountSection.tsx | 16 +++++----- src/settings/SettingsView.tsx | 22 ++++++++++++-- src/ui/app.css | 6 ++++ 5 files changed, 121 insertions(+), 16 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index 8c0840ef..690f454e 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -2,7 +2,7 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "./App"; import { api, browserState } from "./api"; -import type { BluetoothState, SwitchProfile } from "./types"; +import type { AccountView, BluetoothState, SwitchProfile } from "./types"; import * as switchHooks from "./scanning/useSwitches"; import * as scanHooks from "./scanning/useScanning"; @@ -580,6 +580,56 @@ describe("Switchify PC shell", () => { await waitFor(() => expect(opener).toHaveFocus()); }); + describe("sidebar account", () => { + const signedIn: AccountView = { available: true, signedIn: true, email: "me@example.com", pendingEmail: null, keychainUnavailable: false }; + const signedOut: AccountView = { ...signedIn, signedIn: false, email: null }; + const sidebar = async () => { + await screen.findByRole("navigation"); + return within(document.querySelector("aside") as HTMLElement); + }; + const mockAccount = (view: AccountView) => { + let receive: (next: AccountView) => void = () => undefined; + vi.spyOn(api, "account").mockResolvedValue(view); + vi.spyOn(api, "onAccount").mockImplementation(async (handler) => { receive = handler; return () => undefined; }); + vi.spyOn(api, "settingsSync").mockResolvedValue({ status: "upToDate", lastSyncedAt: null, message: null }); + vi.spyOn(api, "onSettingsSync").mockResolvedValue(() => undefined); + return (next: AccountView) => receive(next); + }; + + it("replaces the version with the signed-in account", async () => { + mockAccount(signedIn); + render(); + const entry = await (await sidebar()).findByRole("button", { name: "Account, signed in as me@example.com" }); + expect(entry).toHaveTextContent("Accountme@example.com"); + expect((await sidebar()).queryByText(/^v\d/)).not.toBeInTheDocument(); + }); + + it("offers sign-in when signed out and follows account changes", async () => { + const receive = mockAccount(signedOut); + render(); + expect(await (await sidebar()).findByRole("button", { name: "Sign in to your account" })).toHaveTextContent("Sign in"); + act(() => receive(signedIn)); + expect(await (await sidebar()).findByRole("button", { name: "Account, signed in as me@example.com" })).toBeInTheDocument(); + }); + + it("opens Settings on the Account section", async () => { + mockAccount(signedIn); + render(); + fireEvent.click(await (await sidebar()).findByRole("button", { name: /^Account, signed in/ })); + expect(screen.getByRole("tab", { name: "Account" })).toHaveAttribute("aria-selected", "true"); + const section = await screen.findByRole("region", { name: "Account" }); + await waitFor(() => expect(section).toHaveFocus()); + expect(screen.getByText(/Signed in as/)).toBeInTheDocument(); + }); + + it("keeps the version in builds without accounts", async () => { + mockAccount({ ...signedOut, available: false }); + render(); + expect(await (await sidebar()).findByText(`v${browserState.version}`)).toBeInTheDocument(); + expect((await sidebar()).queryByRole("button", { name: /account/i })).not.toBeInTheDocument(); + }); + }); + it("refreshes Switch Forwarding profiles when settings sync changes them", async () => { let changed: () => void = () => undefined; vi.spyOn(api, "onProfilesChanged").mockImplementation(async (handler) => { changed = handler; return () => undefined; }); diff --git a/src/App.tsx b/src/App.tsx index df242f55..e6753b9b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,12 +2,12 @@ import { Button, Input, Select } from "./ui/controls"; import { Demonstration, DemonstrationProvider } from "./help/Demonstration"; import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; import { - Accessibility, Bluetooth, ChevronRight, CircleHelp, Download, + Accessibility, Bluetooth, ChevronRight, CircleHelp, CircleUserRound, Download, Copy, Home, Keyboard, MousePointer2, Plus, Power, RefreshCw, Save, Settings, SlidersHorizontal, Smartphone, Trash2, Wrench, X, } from "lucide-react"; import { api, type ProfileExitAction } from "./api"; -import type { AppSettings, AppState, PendingPairing, SwitchProfile, UpdateState } from "./types"; +import type { AccountView, AppSettings, AppState, PendingPairing, SwitchProfile, UpdateState } from "./types"; import { applyLocalSettings, changedSettingKeys } from "./settings/diff"; import { SettingsView } from "./settings/SettingsView"; import { MouseSettingsView } from "./settings/MouseSettingsView"; @@ -45,6 +45,20 @@ const bluetoothDescriptions: Record = { error: "Bluetooth could not start. Try restarting Switchify PC.", }; +// Replaces the version label: who is signed in, one press from managing it. +// Builds without accounts keep the version, which Settings → Updates also shows. +function SidebarAccount({ account, version, onOpen }: { account: AccountView | null; version: string; onOpen: () => void }) { + if (!account?.available) return v{version}; + const label = account.signedIn && account.email ? `Account, signed in as ${account.email}` : "Sign in to your account"; + return ; +} + function NavButton({ active, icon, children, onClick }: { active: boolean; icon: ReactNode; children: ReactNode; onClick: () => void }) { return ; } @@ -508,6 +522,8 @@ export function App() { const [settings, setSettings] = useState(null); const [busy, setBusy] = useState(false); const [focusUpdates, setFocusUpdates] = useState(false); + const [focusAccount, setFocusAccount] = useState(false); + const [account, setAccount] = useState(null); // --- Update failures away from the Updates tab ------------------------------ // Failed and cancelled are the updater states with something to act on that // the global banner deliberately leaves out: the scheduled check fails for @@ -695,6 +711,13 @@ export function App() { return () => unlisten(); }, []); + useEffect(() => { + let active = true; + void api.account().then((view) => { if (active) setAccount(view); }).catch(() => undefined); + const stop = api.onAccount((view) => setAccount(view)); + return () => { active = false; void stop.then((unlisten) => unlisten()); }; + }, []); + useEffect(() => { let unlisten: () => void = () => {}; let disposed = false; @@ -765,6 +788,16 @@ export function App() { setFocusUpdates(true); }; + const openAccount = () => { + if (viewRef.current !== "settings") { + if (profileEditorDirty.current && !window.confirm("Discard unsaved profile changes?")) return; + profileEditorDirty.current = false; + viewRef.current = "settings"; + setView("settings"); + } + setFocusAccount(true); + }; + const saveProfile = async (profile: SwitchProfile) => { setBusy(true); setError(null); try { setProfiles(await api.saveProfile(profile)); } @@ -792,7 +825,7 @@ export function App() { if (!state || !settings) return
Starting Switchify PC...
; return 0 || switches.capturing || !!switches.state?.capture.active}>
- + 0 || switches.capturing || !!switches.state?.capture.active}>
0}> {error &&
{error}
} @@ -806,7 +839,7 @@ export function App() { {view === "devices" && void perform(() => api.forgetDevice(id))} />} {view === "profiles" && { profileEditorDirty.current = dirty; }} nativeExitRequest={profileExitRequest} onConfirmNativeExit={confirmProfileExit} onCancelNativeExit={cancelProfileExit} />}
} - {view === "settings" && void perform(() => api.setTelemetryConsent(enabled))} updateAction={(action) => void runUpdate(action)} cancelUpdate={() => void cancelUpdate()} busy={busy} focusUpdates={focusUpdates} onUpdatesFocused={() => setFocusUpdates(false)} updateAttention={updateFailure?.text ?? null} onUpdatesShown={setUpdatesShown} />} + {view === "settings" && void perform(() => api.setTelemetryConsent(enabled))} updateAction={(action) => void runUpdate(action)} cancelUpdate={() => void cancelUpdate()} busy={busy} focusUpdates={focusUpdates} onUpdatesFocused={() => setFocusUpdates(false)} focusAccount={focusAccount} onAccountFocused={() => setFocusAccount(false)} updateAttention={updateFailure?.text ?? null} onUpdatesShown={setUpdatesShown} />} {view === "support" && void perform(operation)} openSetup={openSetup} openUpdates={openUpdates} />}
{setupOpen && 0} busy={busy} error={error} skip={skipSetup} finish={finishSetup} accessibility={() => perform(() => api.checkAccessibility(true))} />} diff --git a/src/settings/AccountSection.tsx b/src/settings/AccountSection.tsx index a34a1a0c..75f25032 100644 --- a/src/settings/AccountSection.tsx +++ b/src/settings/AccountSection.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type FormEvent } from "react"; +import { useEffect, useRef, useState, type FormEvent, type Ref } from "react"; import { api } from "../api"; import { Button, Input } from "../ui/controls"; import type { AccountView, SyncView } from "../types"; @@ -17,7 +17,7 @@ function screenOf(account: AccountView | null): Screen { return account.pendingEmail ? "pending" : "signedOut"; } -export function AccountSection() { +export function AccountSection({ sectionRef }: { sectionRef?: Ref } = {}) { const [account, setAccount] = useState(null); const [email, setEmail] = useState(""); const [code, setCode] = useState(""); @@ -108,22 +108,22 @@ export function AccountSection() { void run(() => api.verifySignInCode(code), () => "signOut").then((ok) => { if (ok) setCode(""); }); }; - if (!account) return

Loading…

; + if (!account) return

Loading…

; // Keyed by occurrence so the same message is announced again. const errorText = error && {error.text}; const errorProps = { "aria-invalid": Boolean(error), "aria-describedby": error ? "account-error" : undefined }; if (!account.available) { - return

Accounts are unavailable in this build.

; + return

Accounts are unavailable in this build.

; } if (account.keychainUnavailable) { - return

Your saved sign-in can't be read because this computer's keychain is locked or unavailable. Unlock it, then reopen this tab.

; + return

Your saved sign-in can't be read because this computer's keychain is locked or unavailable. Unlock it, then reopen this tab.

; } if (account.signedIn) { - return + return

Signed in as {account.email}.

{!confirmDelete && } {confirmDelete @@ -143,7 +143,7 @@ export function AccountSection() { } if (account.pendingEmail) { - return + return

We sent a code to {account.pendingEmail}. It can take a minute to arrive.