feat: add tips

This commit is contained in:
zhom committed 2026-09-09 06:16:14 -07:00
1 parent 3a0e5a41c0
commit a01901ef07
83 files changed
+8032 -2732

No files matched your search

+3
View File
@@ -39,6 +39,7 @@ export type BackendErrorCode =
| "EXTENSION_UNSUPPORTED_FILE_TYPE"
| "EXTENSION_DIR_NOT_FOUND"
| "EXTENSION_NOT_A_DIRECTORY"
| "EXTENSION_PATH_INVALID"
| "EXTENSION_MANIFEST_MISSING"
| "EXTENSION_MANIFEST_INVALID"
| "EXTENSION_DIR_TOO_LARGE"
@@ -395,6 +396,8 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.extensionDirNotFound");
case "EXTENSION_NOT_A_DIRECTORY":
return t("backendErrors.extensionNotADirectory");
case "EXTENSION_PATH_INVALID":
return t("backendErrors.extensionPathInvalid");
case "EXTENSION_MANIFEST_MISSING":
return t("backendErrors.extensionManifestMissing");
case "EXTENSION_MANIFEST_INVALID":
+10
View File
@@ -31,6 +31,7 @@ export interface ShortcutDef {
export type ShortcutId =
| "openPalette"
| "openShortcuts"
| "openTips"
| "importProfile"
| "goProfiles"
| "goProxies"
@@ -59,6 +60,15 @@ export const SHORTCUTS: ShortcutDef[] = [
key: "/",
mod: true,
},
{
// Mod+Shift+H, "hints". Plain Mod+H hides the window on macOS.
id: "openTips",
labelKey: "shortcuts.openTips",
group: "actions",
key: "h",
mod: true,
shift: true,
},
{
id: "importProfile",
labelKey: "shortcuts.importProfile",
+80
View File
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { isPlanTip, pickAutoTip, TIPS, tipsFor } from "./tips.ts";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const LOCALES = path.join(HERE, "..", "i18n", "locales");
const NONE = {
active: false,
cloudBackup: false,
cookieBot: false,
crossOsFingerprints: false,
browserAutomation: false,
agentAutomation: false,
teamCollaboration: false,
remoteControl: false,
};
test("tip ids are unique and every tip has copy in every locale", () => {
const ids = TIPS.map((tip) => tip.id);
assert.equal(new Set(ids).size, ids.length);
const locales = readdirSync(LOCALES).filter((name) => name.endsWith(".json"));
assert.ok(locales.length >= 2, "expected several locale files");
for (const name of locales) {
const bundle = JSON.parse(readFileSync(path.join(LOCALES, name), "utf8"));
for (const id of ids) {
const item = bundle.tips?.items?.[id];
assert.ok(item, `${name} is missing tips.items.${id}`);
for (const field of ["label", "title", "body", "action"]) {
assert.equal(
typeof item[field],
"string",
`${name}: tips.items.${id}.${field} must be a string`,
);
assert.ok(
item[field].trim().length > 0,
`${name}: tips.items.${id}.${field} is empty`,
);
}
}
}
});
test("a free install is offered every essential and no plan tip", () => {
const offered = tipsFor(NONE);
assert.ok(offered.length > 0);
assert.ok(offered.every((tip) => !isPlanTip(tip)));
assert.equal(offered.length, TIPS.filter((tip) => !isPlanTip(tip)).length);
});
test("a plan tip needs an active plan with that capability", () => {
const solo = { ...NONE, active: true, cloudBackup: true, cookieBot: true };
const offered = tipsFor(solo).map((tip) => tip.id);
assert.ok(offered.includes("cloudBackup"));
assert.ok(offered.includes("cookieBot"));
assert.ok(!offered.includes("team"), "solo has no team collaboration");
assert.ok(!offered.includes("remoteControl"));
const lapsed = { ...solo, active: false };
assert.ok(
tipsFor(lapsed).every((tip) => !isPlanTip(tip)),
"a lapsed plan is offered only the essentials",
);
});
test("the automatic flow picks the first unseen tip in catalog order", () => {
const offered = tipsFor(NONE);
assert.equal(pickAutoTip(offered, []), offered[0]);
assert.equal(pickAutoTip(offered, [offered[0].id]), offered[1]);
assert.equal(
pickAutoTip(
offered,
offered.map((tip) => tip.id),
),
null,
);
});
+161
View File
@@ -0,0 +1,161 @@
import type { AppPage } from "@/components/rail-nav";
import type { Entitlements } from "@/types";
/**
* The plan capabilities a tip can be gated on. A tip that names one of these
* is offered only when the signed-in plan grants it, so nobody is walked
* through a feature they cannot open.
*/
export type TipRequirement = Extract<
keyof Entitlements,
| "cloudBackup"
| "cookieBot"
| "crossOsFingerprints"
| "browserAutomation"
| "agentAutomation"
| "teamCollaboration"
| "remoteControl"
>;
/** Where a tip's action button takes the user. */
export type TipAction =
| { kind: "page"; page: AppPage }
| { kind: "settings"; section: string }
| { kind: "palette" };
export type TipId =
| "dnsBlocklist"
| "proxyCheck"
| "groups"
| "commandPalette"
| "fingerprintGate"
| "profilePassword"
| "clearOnClose"
| "defaultBrowser"
| "extensionGroups"
| "selfHostedSync"
| "trash"
| "localApi"
| "importProfiles"
| "cloudBackup"
| "cookieBot"
| "crossOs"
| "automation"
| "agent"
| "team"
| "remoteControl";
export interface TipDefinition {
id: TipId;
action: TipAction;
requires?: TipRequirement;
}
/**
* Every tip, in the order the automatic flow offers them. The essentials come
* first because they apply to every install; the plan tips follow, and each
* one is skipped for a plan that lacks the capability.
*
* The copy lives under `tips.items.<id>` in every locale: `label`, `title`,
* `body` and `action`. `src/lib/tips.test.mjs` checks that no tip is missing its text.
*/
export const TIPS: readonly TipDefinition[] = [
{ id: "dnsBlocklist", action: { kind: "settings", section: "dns" } },
{ id: "proxyCheck", action: { kind: "page", page: "proxies" } },
{ id: "groups", action: { kind: "page", page: "groups" } },
{ id: "commandPalette", action: { kind: "palette" } },
{
id: "fingerprintGate",
action: { kind: "settings", section: "advanced" },
},
{ id: "profilePassword", action: { kind: "page", page: "profiles" } },
{ id: "clearOnClose", action: { kind: "page", page: "profiles" } },
{ id: "defaultBrowser", action: { kind: "settings", section: "default" } },
{ id: "extensionGroups", action: { kind: "page", page: "extensions" } },
{ id: "selfHostedSync", action: { kind: "page", page: "account" } },
{ id: "trash", action: { kind: "page", page: "trash" } },
{ id: "localApi", action: { kind: "page", page: "integrations" } },
{ id: "importProfiles", action: { kind: "page", page: "import" } },
{
id: "cloudBackup",
action: { kind: "page", page: "account" },
requires: "cloudBackup",
},
{
id: "cookieBot",
action: { kind: "page", page: "cookieBot" },
requires: "cookieBot",
},
{
id: "crossOs",
action: { kind: "page", page: "profiles" },
requires: "crossOsFingerprints",
},
{
id: "automation",
action: { kind: "page", page: "integrations" },
requires: "browserAutomation",
},
{
id: "agent",
action: { kind: "page", page: "agent" },
requires: "agentAutomation",
},
{
id: "team",
action: { kind: "page", page: "account" },
requires: "teamCollaboration",
},
{
id: "remoteControl",
action: { kind: "page", page: "integrations" },
requires: "remoteControl",
},
];
/** How long after the app settles the automatic tip waits before opening. */
export const TIP_AUTO_DELAY_MS = 2500;
/**
* A sign-in younger than this counts as "just came back from the website",
* which is when a paid account seen for the first time gets its welcome.
*/
export const FRESH_LOGIN_WINDOW_MS = 15 * 60 * 1000;
export function isPlanTip(tip: TipDefinition): boolean {
return tip.requires !== undefined;
}
/** The tips this plan may see: every essential, plus the plan tips it unlocks. */
export function tipsFor(
entitlements: Pick<Entitlements, "active" | TipRequirement>,
): TipDefinition[] {
return TIPS.filter(
(tip) =>
tip.requires === undefined ||
(entitlements.active && entitlements[tip.requires]),
);
}
/** The first tip the user has not seen yet, or null when they have seen them all. */
export function pickAutoTip(
tips: readonly TipDefinition[],
seen: readonly string[],
): TipDefinition | null {
return tips.find((tip) => !seen.includes(tip.id)) ?? null;
}
export function tipTextKeys(id: TipId): {
/** The short name the catalog lists the tip under. */
label: string;
title: string;
body: string;
action: string;
} {
return {
label: `tips.items.${id}.label`,
title: `tips.items.${id}.title`,
body: `tips.items.${id}.body`,
action: `tips.items.${id}.action`,
};
}