From fb53d58cd01809cade50a54f4c566ce2724a38cc Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:52:16 +0530 Subject: [PATCH 1/5] feat(onboarding): make Privacy choices a counted step with one-line choices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Privacy choices is now onboarding's first step, counted in the progress bar and shown every time onboarding runs, with earlier answers chosen. Back from the first model step returns to it. Each choice is one line, with the full description and every host behind an info icon and a link to the exact request or report. Yes and No are one control whose chosen half is filled. Settings › About uses the same text and info icon. --- docs/architecture/updates.md | 2 +- docs/architecture/usage-statistics.md | 6 +- .../onboarding/onboarding-page.test.tsx | 103 ++++++++++++++---- .../features/onboarding/onboarding-page.tsx | 76 +++++++------ .../privacy-step/privacy-choice-row.tsx | 66 +++++++---- .../onboarding/privacy-step/privacy-step.tsx | 102 ++++++++--------- .../features/privacy-choices/choice-text.ts | 71 ++++++++++++ .../privacy-choices/details-tooltip.tsx | 36 ++++++ .../privacy-choices/stub-privacy-bridge.ts | 10 +- .../usage-statistics-settings.test.tsx | 48 +++++++- .../usage-statistics-settings.tsx | 17 ++- .../src/features/updates/update-settings.tsx | 69 +++++------- surfsense_local/frontend/translations/de.json | 18 +-- surfsense_local/frontend/translations/en.json | 18 +-- surfsense_local/frontend/translations/es.json | 18 +-- surfsense_local/frontend/translations/fr.json | 18 +-- surfsense_local/frontend/translations/hi.json | 18 +-- surfsense_local/frontend/translations/ja.json | 18 +-- surfsense_local/frontend/translations/ko.json | 18 +-- .../frontend/translations/pt-BR.json | 18 +-- surfsense_local/frontend/translations/ru.json | 18 +-- .../frontend/translations/zh-CN.json | 18 +-- 22 files changed, 518 insertions(+), 268 deletions(-) create mode 100644 surfsense_local/frontend/src/features/privacy-choices/choice-text.ts create mode 100644 surfsense_local/frontend/src/features/privacy-choices/details-tooltip.tsx diff --git a/docs/architecture/updates.md b/docs/architecture/updates.md index 6196d046e2..32fa72abe7 100644 --- a/docs/architecture/updates.md +++ b/docs/architecture/updates.md @@ -12,7 +12,7 @@ How to cut a release is in [`surfsense_local/RELEASE.md`](../../surfsense_local/ Updates are a call the app makes on its own, so they are off until the user turns them on. -- A new install asks in onboarding's Privacy choices step, beside usage statistics, with neither answer chosen for the user ([usage statistics](usage-statistics.md#consent)). An install that finished onboarding earlier keeps the setting it had. +- A new install asks in onboarding's first step, Privacy choices, beside usage statistics, with neither answer chosen for the user ([usage statistics](usage-statistics.md#consent)). An install that finished onboarding earlier keeps the setting it had. - The preference is `updates.json` in Electron's user data: `automatic`, false by default; `lastCheckedAt`, which Settings › Network shows as the App updates row's last call; and `lastAutomaticDay`, the UTC day of the last automatic check. - The automatic check runs at most once a UTC day: at launch, and from an hourly tick once the day turns while the app stays open. The day is marked only once a check reads a manifest, from either host. A check that reads none is tried again on the hourly tick, at most three times a UTC day. A day's automatic checks therefore approximate the installs open that day, with no identifier on the wire; a retry after a failed check can add a second event from one install. - Check now in Settings › About, the sidebar's update row and Check for Updates… in the macOS app menu share one check (`useCheckForUpdates`), which asks first while `automatic` is off, naming the host it asks. The menu shows only that prompt; the sidebar and Settings show the progress. Allow turns `automatic` on, which also enables the daily check. diff --git a/docs/architecture/usage-statistics.md b/docs/architecture/usage-statistics.md index 9acbdc6b28..95b650ce93 100644 --- a/docs/architecture/usage-statistics.md +++ b/docs/architecture/usage-statistics.md @@ -32,9 +32,9 @@ No identifier ties one install's reports together, but the values that never cha ## Consent -- A new install answers in the Privacy choices step, right after the welcome in onboarding: two rows, update checks and usage statistics, each with equal Yes and No buttons, neither chosen beforehand, and Continue only once both are answered. Continue calls `completeFirstRun`, which writes both answers and `firstRunAsked`. Each row opens exactly what it sends. -- An install that finished onboarding never sees the step, and one that answered (`firstRunAsked`) skips it. Its usage statistics stay `null`, read as off, until the user turns them on. A bridge that cannot read its state skips the step too, with both left off, rather than holding setup on an empty card. -- Settings › About has the Usage statistics switch beside App updates, with "Show exactly what's sent": the next report as Electron main would build it now (`previewUsageReport`, not sent), the last report sent and when, and the host. Settings › Network lists the same switch as a row with its host and its last call. +- Privacy choices is onboarding's first step, right after the welcome, counted in its progress like the model steps: two rows, update checks and usage statistics, each with equal Yes and No buttons, neither chosen beforehand, and Continue only once both are answered. Continue calls `completeFirstRun`, which writes both answers and `firstRunAsked`. Each row says in one line what it does; the info icon beside its title shows the full description with every host its answer reaches, and the link below opens exactly what it sends. +- Onboarding always shows the step in the desktop app. An install that answered already (`firstRunAsked`), or that comes back from the first model step, sees its answers chosen. An install that finished onboarding never sees it; its usage statistics stay `null`, read as off, until the user turns them on. A bridge that cannot read its state skips the step, with both left off, rather than holding setup on an empty card. +- Settings › About has the Usage statistics switch beside App updates. Both say what they do as onboarding does, in the same one-line summary with the full description behind an info icon, and each has "Show exactly what's sent". For usage statistics that shows the next report as Electron main would build it now (`previewUsageReport`, not sent), the last report sent and when, and the host. Settings › Network lists the same switch as a row with its host and its last call. - Turning the switch off stops the next report; nothing already sent can be recalled, because no identifier marks which reports were this install's. ## Policy diff --git a/surfsense_local/frontend/src/features/onboarding/onboarding-page.test.tsx b/surfsense_local/frontend/src/features/onboarding/onboarding-page.test.tsx index 7e21df4d00..a3accdf3a4 100644 --- a/surfsense_local/frontend/src/features/onboarding/onboarding-page.test.tsx +++ b/surfsense_local/frontend/src/features/onboarding/onboarding-page.test.tsx @@ -1342,6 +1342,16 @@ describe("the privacy choices", () => { await screen.findByRole("heading", { name: "Privacy choices" }) } + // The full description is the title's tooltip. + async function hoverDetails( + user: ReturnType, + label: string + ) { + await user.hover(screen.getByRole("button", { name: label })) + const tooltip = await screen.findByRole("tooltip") + return tooltip.textContent ?? "" + } + function choice(row: string, answer: "Yes" | "No") { return within(screen.getByRole("group", { name: row })).getByRole( "button", @@ -1356,8 +1366,9 @@ describe("the privacy choices", () => { render( undefined} />) await toPrivacyStep(user) - // Not a model step: the progress bar starts after it. - expect(screen.queryByLabelText(/Onboarding step/)).toBeNull() + // The first step like any other: counted, with no way back to the welcome. + expect(screen.getByLabelText("Onboarding step 1 of 7")).toBeTruthy() + expect(screen.queryByRole("button", { name: "Back" })).toBeNull() for (const row of ["Check for updates", "Share usage statistics"]) for (const answer of ["Yes", "No"] as const) expect(choice(row, answer).getAttribute("aria-pressed")).toBe("false") @@ -1387,17 +1398,32 @@ describe("the privacy choices", () => { render( undefined} />) await toPrivacyStep(user) + // One line each; the hosts are in the info icon's description. const updates = screen.getByRole("region", { name: "Check for updates" }) - expect(updates.textContent).toContain("updates.surfsense.com") - expect(updates.textContent).toContain("github.com") - expect(updates.textContent).toContain("no ID") - // The download and where it comes from, as the update prompt says. - expect(updates.textContent).toContain("your IP address") - expect(updates.textContent).toContain("githubusercontent.com") - expect(updates.textContent).toContain("the next time you quit") + expect(updates.textContent).toContain("No ID sent.") + expect(updates.textContent).not.toContain("updates.surfsense.com") const usage = screen.getByRole("region", { name: "Share usage statistics" }) - expect(usage.textContent).toContain("usage.surfsense.com") expect(usage.textContent).toContain( + "Never your documents, prompts or an ID." + ) + + // Every host, and the download, as the update prompt says. + const updatesDetails = await hoverDetails( + user, + "What an update check sends" + ) + expect(updatesDetails).toContain("updates.surfsense.com") + expect(updatesDetails).toContain("github.com") + expect(updatesDetails).toContain("your IP address") + expect(updatesDetails).toContain("githubusercontent.com") + expect(updatesDetails).toContain("the next time you quit") + await user.unhover( + screen.getByRole("button", { name: "What an update check sends" }) + ) + await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull()) + const usageDetails = await hoverDetails(user, "What usage statistics send") + expect(usageDetails).toContain("usage.surfsense.com") + expect(usageDetails).toContain( "Never documents, prompts, file names, model names, hosts, your license key or an ID." ) }) @@ -1411,7 +1437,7 @@ describe("the privacy choices", () => { await toPrivacyStep(user) const usage = screen.getByRole("region", { name: "Share usage statistics" }) await user.click( - within(usage).getByRole("button", { name: "See exactly what’s sent" }) + within(usage).getByRole("button", { name: "See the exact report" }) ) const dialog = await screen.findByRole("dialog", { @@ -1433,7 +1459,7 @@ describe("the privacy choices", () => { await toPrivacyStep(user) const updates = screen.getByRole("region", { name: "Check for updates" }) await user.click( - within(updates).getByRole("button", { name: "See exactly what’s sent" }) + within(updates).getByRole("button", { name: "See the exact request" }) ) const dialog = await screen.findByRole("dialog", { @@ -1490,7 +1516,12 @@ describe("the privacy choices", () => { expect(updates.textContent).toContain("Updates come from the Snap Store") expect(updates.textContent).not.toContain("Once a day") expect( - within(updates).queryByRole("button", { name: "See exactly what’s sent" }) + within(updates).queryByRole("button", { name: "See the exact request" }) + ).toBeNull() + expect( + within(updates).queryByRole("button", { + name: "What an update check sends", + }) ).toBeNull() expect( screen.queryByRole("group", { name: "Check for updates" }) @@ -1521,16 +1552,50 @@ describe("the privacy choices", () => { ).toBeNull() }) - it("never asks an install that already answered", async () => { + it("asks an install that already answered again, with its answers chosen", async () => { vi.stubGlobal("fetch", backend()) - stubPrivacyBridge({ firstRunAsked: true }) + stubPrivacyBridge({ + firstRunAsked: true, + updates: { automatic: true }, + usageStatistics: { enabled: false }, + }) const user = userEvent.setup() render( undefined} />) - // Whether or not its answer was read before the welcome was left. - await toChatStep(user) + await toPrivacyStep(user) expect( - screen.queryByRole("heading", { name: "Privacy choices" }) - ).toBeNull() + choice("Check for updates", "Yes").getAttribute("aria-pressed") + ).toBe("true") + expect( + choice("Share usage statistics", "No").getAttribute("aria-pressed") + ).toBe("true") + expect( + screen.getByRole("button", { name: "Continue" }).hasAttribute("disabled") + ).toBe(false) + }) + + it("goes back to the choices from the first model step, keeping them", async () => { + vi.stubGlobal("fetch", backend({ selections: { ...chatChosen } })) + stubPrivacyBridge() + const user = userEvent.setup() + render( undefined} />) + + await toPrivacyStep(user) + await user.click(choice("Check for updates", "No")) + await user.click(choice("Share usage statistics", "Yes")) + await user.click(screen.getByRole("button", { name: "Continue" })) + await screen.findByRole("heading", { + name: "Choose a text generation model", + }) + expect(screen.getByLabelText("Onboarding step 2 of 7")).toBeTruthy() + + await user.click(screen.getByRole("button", { name: "Back" })) + await screen.findByRole("heading", { name: "Privacy choices" }) + expect(choice("Check for updates", "No").getAttribute("aria-pressed")).toBe( + "true" + ) + expect( + choice("Share usage statistics", "Yes").getAttribute("aria-pressed") + ).toBe("true") }) }) diff --git a/surfsense_local/frontend/src/features/onboarding/onboarding-page.tsx b/surfsense_local/frontend/src/features/onboarding/onboarding-page.tsx index a32213034f..b93054b7d5 100644 --- a/surfsense_local/frontend/src/features/onboarding/onboarding-page.tsx +++ b/surfsense_local/frontend/src/features/onboarding/onboarding-page.tsx @@ -16,14 +16,15 @@ import { PrivacyStep } from "./privacy-step/privacy-step" import { useFinishOnboarding } from "./use-finish-onboarding" /** - * The steps, in order, and the ones the progress bar counts. The welcome is still - * onboarding, and still gated by the same marker, but it is an introduction, - * not a step to complete. Whichever step is last finishes onboarding. + * The model steps, in order. The privacy choices come before them as the first + * step the progress bar counts. The welcome is still onboarding, and still gated + * by the same marker, but it is an introduction, not a step to complete. + * Whichever step is last finishes onboarding. * * The embedding model comes last and is the one step that is not a slot: its * choice is sent with the finish that fixes it, never held across steps. */ -const STEPS = [ +const MODEL_STEPS = [ "text_gen", "image_gen", "image_edit", @@ -32,13 +33,12 @@ const STEPS = [ "embedding", ] as const satisfies readonly OnboardingStepKind[] -type Step = (typeof STEPS)[number] -// The privacy choices sit between the welcome and the steps, and like the -// welcome they are not a step the progress bar counts. -type Screen = "welcome" | "privacy" | Step +type ModelStepKind = (typeof MODEL_STEPS)[number] +type Step = "privacy" | ModelStepKind +type Screen = "welcome" | Step /** Finishing needs a chat model, so only this step cannot be skipped. */ -const REQUIRED_STEP: Step = "text_gen" +const REQUIRED_STEP: ModelStepKind = "text_gen" function OnboardingBrand() { return ( @@ -60,8 +60,14 @@ function OnboardingBrand() { ) } -function OnboardingProgress({ screen }: { screen: Step }) { - const step = STEPS.indexOf(screen) + 1 +function OnboardingProgress({ + screen, + steps, +}: { + screen: Step + steps: readonly Step[] +}) { + const step = steps.indexOf(screen) + 1 const label = intl.formatMessage( { id: "onboarding_progress_aria", @@ -69,13 +75,13 @@ function OnboardingProgress({ screen }: { screen: Step }) { }, { step, - total: STEPS.length, + total: steps.length, } ) return ( label} className="mx-auto w-full max-w-28 **:data-[slot=progress-indicator]:rounded-full **:data-[slot=progress-indicator]:duration-300 **:data-[slot=progress-indicator]:ease-out **:data-[slot=progress-track]:bg-border **:data-[slot=progress-indicator]:motion-reduce:transition-none" @@ -172,20 +178,22 @@ function WelcomeStep({ onNext }: { onNext: () => void }) { ) } -/** One step in the order of `STEPS`: the last one finishes, the rest move on. */ +/** One model step in the order of `steps`: the last one finishes, the rest move on. */ function OnboardingStep({ step, + steps, onGo, onComplete, }: { - step: Step + step: ModelStepKind + steps: readonly Step[] onGo: (step: Step) => void onComplete: (selection: ModelSelection) => void }) { const { finish, finishing, error } = useFinishOnboarding(onComplete) - const index = STEPS.indexOf(step) - const previous = STEPS[index - 1] - const next = STEPS[index + 1] + const index = steps.indexOf(step) + const previous = steps[index - 1] + const next = steps[index + 1] // The last step finishes, sending its value: the embedding choice, or null. const advance = (value: string | null) => next ? onGo(next) : void finish(value) @@ -209,14 +217,16 @@ export function OnboardingPage({ }) { const [chosen, setScreen] = useState("welcome") const { state: privacy, failed: privacyUnreadable } = usePrivacyState() - // Asked once, on a new install: never outside the desktop app, or once - // answered. An unreadable state skips it, leaving both off, rather than - // holding setup on an empty card. - const privacyAnswered = - privacy === null || privacy?.firstRunAsked === true || privacyUnreadable - // Left the welcome before the answer was read, and none was needed. + // Always asked in the desktop app. Outside it there is nothing to save to, + // and an unreadable state skips it, leaving both off, rather than holding + // setup on an empty card. + const asksPrivacy = privacy !== null && !privacyUnreadable + const steps: readonly Step[] = asksPrivacy + ? ["privacy", ...MODEL_STEPS] + : MODEL_STEPS + // Left the welcome before the state was read, and it could not be. const screen: Screen = - chosen === "privacy" && privacyAnswered ? STEPS[0] : chosen + chosen === "privacy" && !asksPrivacy ? MODEL_STEPS[0] : chosen return (
- {screen === "welcome" || screen === "privacy" ? ( + {screen === "welcome" ? ( // The progress bar's height, kept so the hero sits exactly where it did.