mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-21 06:51:24 +02:00
feat: add tips
This commit is contained in:
1 parent
3a0e5a41c0
commit
a01901ef07
83 files changed
+8032
-2732
No files matched your search
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user