@tollbooth-dpyc/web 1.7.1 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/dist/client.d.ts +3 -2
- package/dist/client.js +21 -18
- package/dist/config.d.ts +4 -1
- package/dist/config.js +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/react/CouponsPanel.js +1 -1
- package/dist/react/NostrProfilePanel.js +1 -1
- package/dist/react/NpubGate.js +2 -2
- package/dist/react/SessionKeyClaim.js +2 -2
- package/dist/react/SignInLinks.js +1 -1
- package/dist/react/SignInSteps.js +1 -1
- package/dist/react/UsageSummary.js +1 -1
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.js +2 -0
- package/dist/react/useTour.d.ts +46 -0
- package/dist/react/useTour.js +128 -0
- package/dist/toolResult.d.ts +11 -0
- package/dist/toolResult.js +27 -0
- package/dist/tour/idle.d.ts +19 -0
- package/dist/tour/idle.js +24 -0
- package/dist/tour/lint.d.ts +18 -0
- package/dist/tour/lint.js +47 -0
- package/dist/tour/run.d.ts +47 -0
- package/dist/tour/run.js +163 -0
- package/dist/tour/state.d.ts +34 -0
- package/dist/tour/state.js +54 -0
- package/dist/tour/tours.d.ts +28 -0
- package/dist/tour/tours.js +54 -0
- package/dist/tour/types.d.ts +35 -0
- package/dist/tour/types.js +10 -0
- package/dist/tourPrefs.d.ts +21 -0
- package/dist/tourPrefs.js +70 -0
- package/package.json +7 -2
- package/tour.css +103 -0
package/README.md
CHANGED
|
@@ -261,6 +261,48 @@ with the step under way lit from the gate's real stage, and `SignInLinks` —
|
|
|
261
261
|
headless and exported on their own; `classNames.steps` / `classNames.links`
|
|
262
262
|
restyle them inside the gate. `steps={false}` / `links={false}` remove them.
|
|
263
263
|
|
|
264
|
+
### Guided tour
|
|
265
|
+
|
|
266
|
+
A page can walk itself through: small popovers in turn, each pointing at a
|
|
267
|
+
control and saying what it is for. The package carries the mechanics — the
|
|
268
|
+
popover (driver.js), the preference, who sees it, the offer, the copy lint
|
|
269
|
+
and the two tours every site shares (the sign-in gate and the account page's
|
|
270
|
+
panels). A site supplies its `data-tour` names and its own words.
|
|
271
|
+
|
|
272
|
+
```tsx
|
|
273
|
+
import { signInTour, accountTour, type Tour } from "@tollbooth-dpyc/web";
|
|
274
|
+
import { TourProvider, useTour, useTourSwitch, useTourReplay, tourTarget } from "@tollbooth-dpyc/web/react";
|
|
275
|
+
|
|
276
|
+
const TOURS: Record<string, Tour> = {
|
|
277
|
+
signin: signInTour(),
|
|
278
|
+
account: accountTour("account", [{ target: "account.sound", title: "Sound", says: "The hive hums. Switch it off here." }]),
|
|
279
|
+
play: { page: "play", steps: [
|
|
280
|
+
{ target: "play.practice", title: "Practice", says: "A round against the house, free. Tap it to learn the moves." },
|
|
281
|
+
]},
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
<TourProvider tours={TOURS}>…</TourProvider> // once, around the app
|
|
285
|
+
useTour("play", { actions: { "open-lobby": () => setMode("live") } }); // once per page
|
|
286
|
+
<button {...tourTarget("play.practice")}>Practice</button>
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Who sees it: a new device starts with the live tutorial on; a device that
|
|
290
|
+
already holds a patron npub starts with it off. On a page that is due, after
|
|
291
|
+
ten seconds untouched (`configureTollbooth({ tourIdleMs })`, or
|
|
292
|
+
`TourProvider idleMs`), a centred popover asks whether they would like a tour;
|
|
293
|
+
Yes starts it, No marks the page. A step whose control is absent is skipped;
|
|
294
|
+
a step with `before` has the page open something first, by the name the page
|
|
295
|
+
registered in `actions`. "Stop the tutorial" sits in every popover. The site's
|
|
296
|
+
own switch and replay button take their mechanics from `useTourSwitch()` and
|
|
297
|
+
`useTourReplay()`.
|
|
298
|
+
|
|
299
|
+
Import `@tollbooth-dpyc/web/tour.css` beside `theme.css`; the popover takes the
|
|
300
|
+
site's `--tb-*` tokens, and `.tb-tour` can be restyled further. The package's
|
|
301
|
+
markup carries `tb.gate.*` and `tb.account.*` names (`TB_TARGETS`); the two
|
|
302
|
+
shared tours point at them. `lintTours(TOURS)` in a site's test holds the copy
|
|
303
|
+
to the voice: one or two short sentences saying what a thing is. A sentence
|
|
304
|
+
carrying "not", "no", "never", "instead of" or "rather than" fails it.
|
|
305
|
+
|
|
264
306
|
### Debug panel
|
|
265
307
|
|
|
266
308
|
`callTool` logs to a shared, scrubbed ring buffer; `DebugPanel` shows it as a
|
package/dist/client.d.ts
CHANGED
|
@@ -24,8 +24,9 @@ type ProofExpiredListener = (message: string) => void;
|
|
|
24
24
|
export declare function onProofExpired(cb: ProofExpiredListener): () => void;
|
|
25
25
|
export interface CallOptions {
|
|
26
26
|
/**
|
|
27
|
-
* A non-essential call — diagnostics, personalization
|
|
28
|
-
* patron out
|
|
27
|
+
* A non-essential call — diagnostics, personalization, a board polled all
|
|
28
|
+
* match. A soft proof miss here never logs the patron out; a stored grant
|
|
29
|
+
* the service refused for good (expired, revoked) still does.
|
|
29
30
|
*/
|
|
30
31
|
bestEffort?: boolean;
|
|
31
32
|
timeoutMs?: number;
|
package/dist/client.js
CHANGED
|
@@ -17,7 +17,7 @@ import { clearStoredProof, forgetRecentLogin, getStoredNpub, getStoredProof, } f
|
|
|
17
17
|
import { signInlineProof } from "./inlineProof.js";
|
|
18
18
|
import { isTransportFailure, NetworkError } from "./networkError.js";
|
|
19
19
|
import { clearSessionNsec, hasSessionNsec, sessionNsecNpub } from "./sessionNsec.js";
|
|
20
|
-
import { errorText, looksFailed, proofBounceMessage, readToolResult, } from "./toolResult.js";
|
|
20
|
+
import { errorText, deadGrantMessage, looksFailed, proofBounceMessage, readToolResult, } from "./toolResult.js";
|
|
21
21
|
/**
|
|
22
22
|
* Standard tools whose wheel signature takes no npub/proof envelope. Pydantic
|
|
23
23
|
* strict mode rejects unexpected kwargs, so they must not get one.
|
|
@@ -77,14 +77,14 @@ function proofFor(tool) {
|
|
|
77
77
|
const npub = getStoredNpub();
|
|
78
78
|
const sessionNpub = hasSessionNsec() ? sessionNsecNpub() : null;
|
|
79
79
|
if (sessionNpub && sessionNpub === npub)
|
|
80
|
-
return signInlineProof(toolName(tool));
|
|
80
|
+
return { token: signInlineProof(toolName(tool)), inline: true };
|
|
81
81
|
if (sessionNpub)
|
|
82
82
|
clearSessionNsec();
|
|
83
83
|
}
|
|
84
84
|
catch {
|
|
85
85
|
/* fall through to the cached token */
|
|
86
86
|
}
|
|
87
|
-
return getStoredProof();
|
|
87
|
+
return { token: getStoredProof(), inline: false };
|
|
88
88
|
}
|
|
89
89
|
export { isNetworkError, NetworkError } from "./networkError.js";
|
|
90
90
|
/** A paid call bounced for a lapsed or missing proof. The gate re-arms sign-in. */
|
|
@@ -131,9 +131,10 @@ export async function callToolWithContent(tool, args = {}, opts = {}) {
|
|
|
131
131
|
if (!quiet)
|
|
132
132
|
debugPush("call", `${name}(${redact(JSON.stringify(args)).slice(0, 140)})`);
|
|
133
133
|
const bootstrap = STANDARD_BOOTSTRAP.includes(tool) || cfg.extraBootstrapTools.includes(tool);
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
const proof = bootstrap ? null : proofFor(tool);
|
|
135
|
+
const merged = proof
|
|
136
|
+
? { npub: getStoredNpub(), dpop_token: proof.token, ...args }
|
|
137
|
+
: { ...args };
|
|
137
138
|
let result;
|
|
138
139
|
try {
|
|
139
140
|
const c = await getClient();
|
|
@@ -163,18 +164,20 @@ export async function callToolWithContent(tool, args = {}, opts = {}) {
|
|
|
163
164
|
const tail = images.length ? ` +${images.length} image` : "";
|
|
164
165
|
debugPush(looksFailed(payload) ? "error" : "result", `${name} → ${redact(String(preview)).slice(0, 220)}${tail}`);
|
|
165
166
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
167
|
+
// A stored grant the service refused for good signs the patron out even
|
|
168
|
+
// from a best-effort call: that is not a blip, and staying "signed in" on a
|
|
169
|
+
// dead grant leaves every page failing with nothing to tell them why.
|
|
170
|
+
const bounced = (opts.bestEffort ? null : proofBounceMessage(payload)) ??
|
|
171
|
+
(proof && !proof.inline && proof.token ? deadGrantMessage(payload) : null);
|
|
172
|
+
if (bounced !== null) {
|
|
173
|
+
// The token just refused is the same one the recent-login shortcut
|
|
174
|
+
// would replay, so it goes too, or a returning patron re-bounces.
|
|
175
|
+
const npub = getStoredNpub();
|
|
176
|
+
clearStoredProof();
|
|
177
|
+
if (npub)
|
|
178
|
+
forgetRecentLogin(npub);
|
|
179
|
+
emitProofExpired(bounced);
|
|
180
|
+
throw new ProofRequiredError(bounced);
|
|
178
181
|
}
|
|
179
182
|
return { data: payload, images };
|
|
180
183
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -29,11 +29,14 @@ export interface TollboothConfig {
|
|
|
29
29
|
quietTools?: string[];
|
|
30
30
|
/** Avatar glyphs in this site's voice. Defaults to a general set. */
|
|
31
31
|
avatarChoices?: string[];
|
|
32
|
+
/** How long a page sits untouched before its guided tour is offered, in ms. Defaults to 10 s. */
|
|
33
|
+
tourIdleMs?: number;
|
|
32
34
|
}
|
|
33
|
-
interface Resolved extends Required<Omit<TollboothConfig, "extraBootstrapTools" | "quietTools" | "avatarChoices">> {
|
|
35
|
+
interface Resolved extends Required<Omit<TollboothConfig, "extraBootstrapTools" | "quietTools" | "avatarChoices" | "tourIdleMs">> {
|
|
34
36
|
extraBootstrapTools: string[];
|
|
35
37
|
quietTools: string[];
|
|
36
38
|
avatarChoices?: string[];
|
|
39
|
+
tourIdleMs?: number;
|
|
37
40
|
}
|
|
38
41
|
export declare function configureTollbooth(config: TollboothConfig): void;
|
|
39
42
|
export declare function tollboothConfig(): Resolved;
|
package/dist/config.js
CHANGED
|
@@ -20,6 +20,7 @@ export function configureTollbooth(config) {
|
|
|
20
20
|
extraBootstrapTools: config.extraBootstrapTools ?? [],
|
|
21
21
|
quietTools: config.quietTools ?? [],
|
|
22
22
|
avatarChoices: config.avatarChoices,
|
|
23
|
+
tourIdleMs: config.tourIdleMs,
|
|
23
24
|
};
|
|
24
25
|
}
|
|
25
26
|
export function tollboothConfig() {
|
package/dist/index.d.ts
CHANGED
|
@@ -22,3 +22,9 @@ export * from "./funding.ts";
|
|
|
22
22
|
export { errorReport } from "./errorReport.ts";
|
|
23
23
|
export { loadQuotes, peekQuotes, shuffle, validQuotes, type Quote } from "./quotes.ts";
|
|
24
24
|
export { captureGlobalErrors, clearDebug, configureDebugLog, debugEntries, debugLogText, debugPush, debugSeverity, onDebug, redact, type DebugEntry, type DebugLogOptions, type DebugSeverity, } from "./debugLog.ts";
|
|
25
|
+
export type { Tour, TourActions, TourStep } from "./tour/types.ts";
|
|
26
|
+
export { fromStored as tourPrefsFromStored, markToured, replayAll, replayPage, shouldRun, stopAll, TOUR_DEFAULTS, type TourPrefs, } from "./tour/state.ts";
|
|
27
|
+
export { ACTIVITY_EVENTS, DEFAULT_IDLE_MS, idleThreshold, offerDue } from "./tour/idle.ts";
|
|
28
|
+
export { lintStep, lintTour, lintTours, MAX_SAYS, MAX_SENTENCES, MAX_TITLE } from "./tour/lint.ts";
|
|
29
|
+
export { accountPanelsSteps, accountTour, signInTour, TB_TARGETS } from "./tour/tours.ts";
|
|
30
|
+
export { onTourChange, readTourPrefs, settleTourPrefs, tourStorageKey, writeTourPrefs } from "./tourPrefs.ts";
|
package/dist/index.js
CHANGED
|
@@ -21,3 +21,8 @@ export * from "./funding.js";
|
|
|
21
21
|
export { errorReport } from "./errorReport.js";
|
|
22
22
|
export { loadQuotes, peekQuotes, shuffle, validQuotes } from "./quotes.js";
|
|
23
23
|
export { captureGlobalErrors, clearDebug, configureDebugLog, debugEntries, debugLogText, debugPush, debugSeverity, onDebug, redact, } from "./debugLog.js";
|
|
24
|
+
export { fromStored as tourPrefsFromStored, markToured, replayAll, replayPage, shouldRun, stopAll, TOUR_DEFAULTS, } from "./tour/state.js";
|
|
25
|
+
export { ACTIVITY_EVENTS, DEFAULT_IDLE_MS, idleThreshold, offerDue } from "./tour/idle.js";
|
|
26
|
+
export { lintStep, lintTour, lintTours, MAX_SAYS, MAX_SENTENCES, MAX_TITLE } from "./tour/lint.js";
|
|
27
|
+
export { accountPanelsSteps, accountTour, signInTour, TB_TARGETS } from "./tour/tours.js";
|
|
28
|
+
export { onTourChange, readTourPrefs, settleTourPrefs, tourStorageKey, writeTourPrefs } from "./tourPrefs.js";
|
|
@@ -84,7 +84,7 @@ export default function CouponsPanel({ heading = "My coupons", intro = DEFAULT_I
|
|
|
84
84
|
setMsg({ ok: false, text: e.message });
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
|
-
return (_jsxs("section", { className: c.root, children: [heading && _jsx("div", { className: c.heading, children: heading }), intro && _jsx("p", { className: c.intro, children: intro }), formHeading && _jsx("div", { className: c.subheading, children: formHeading }), _jsxs("div", { className: c.form, children: [_jsx("input", { type: "text", value: code, onChange: (e) => setCode(e.target.value.toUpperCase()), onKeyDown: (e) => {
|
|
87
|
+
return (_jsxs("section", { className: c.root, "data-tour": "tb.account.coupons", children: [heading && _jsx("div", { className: c.heading, children: heading }), intro && _jsx("p", { className: c.intro, children: intro }), formHeading && _jsx("div", { className: c.subheading, children: formHeading }), _jsxs("div", { className: c.form, children: [_jsx("input", { type: "text", value: code, onChange: (e) => setCode(e.target.value.toUpperCase()), onKeyDown: (e) => {
|
|
88
88
|
if (e.key === "Enter")
|
|
89
89
|
void redeem();
|
|
90
90
|
}, placeholder: placeholder, "aria-label": "Coupon code", autoCapitalize: "characters", autoCorrect: "off", spellCheck: false, disabled: redeeming, className: c.input }), _jsx("button", { type: "button", onClick: () => void redeem(), disabled: redeeming || !code.trim(), className: c.primary ?? c.chip, children: redeeming ? "Redeeming…" : redeemLabel })] }), msg && (_jsx("p", { role: "status", className: cx(c.message, msg.ok ? c.ok : c.error), children: msg.text })), loading ? (_jsx("p", { className: c.loading, children: "Loading\u2026" })) : loadError ? (_jsx("p", { className: cx(c.message, c.error), children: loadError })) : coupons.length === 0 ? (empty && _jsx("p", { className: c.empty, children: empty })) : (_jsxs(_Fragment, { children: [listHeading && _jsx("div", { className: c.subheading, children: listHeading }), _jsx("ul", { className: c.list, children: coupons.map((coupon) => renderCoupon ? (_jsx("li", { className: c.row, children: renderCoupon(coupon, () => void forget(coupon)) }, coupon.coupon_id)) : (_jsx(CouponRow, { coupon: coupon, c: c, forgetLabel: forgetLabel, onForget: () => void forget(coupon) }, coupon.coupon_id))) })] }))] }));
|
|
@@ -112,7 +112,7 @@ export default function NostrProfilePanel({ npub, onPublished, }) {
|
|
|
112
112
|
setPublishing(false);
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
|
-
return (_jsxs("div", { className: `${card} px-4 py-3`, children: [_jsxs("div", { className: "flex items-start gap-3", children: [_jsxs("div", { className: "relative flex-none", children: [_jsx(Avatar, { value: profile.picture || avatarFor(npub), size: 56 }), _jsx("button", { type: "button", onClick: () => {
|
|
115
|
+
return (_jsxs("div", { className: `${card} px-4 py-3`, "data-tour": "tb.account.profile", children: [_jsxs("div", { className: "flex items-start gap-3", children: [_jsxs("div", { className: "relative flex-none", children: [_jsx(Avatar, { value: profile.picture || avatarFor(npub), size: 56 }), _jsx("button", { type: "button", onClick: () => {
|
|
116
116
|
setExpanded(true);
|
|
117
117
|
setShowPicker((v) => !v);
|
|
118
118
|
}, "aria-label": showPicker ? "Done changing avatar" : "Change avatar", "aria-expanded": showPicker, className: "absolute -bottom-0.5 -right-0.5 inline-flex h-6 w-6 items-center justify-center rounded-full border border-[var(--tb-line)] bg-[var(--tb-surface)] shadow-sm", children: _jsx(Camera, { className: "h-3.5 w-3.5", "aria-hidden": "true" }) })] }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "flex items-center gap-2 flex-wrap", children: [_jsx("div", { className: "text-sm font-medium truncate", children: collapsedDisplayName(profile.display_name ?? "") }), mode === "how-to-set" && (_jsx("span", { className: `text-[11px] px-1.5 py-0.5 rounded-md bg-[var(--tb-surface-2)] ${muted}`, children: "Read-only" }))] }), _jsx("button", { type: "button", onClick: copyNpub, title: "Copy full npub", className: `mt-0.5 font-mono text-xs ${muted}`, children: copied ? "Copied" : shortNpub(npub) })] }), _jsxs("button", { type: "button", onClick: () => setExpanded((v) => !v), "aria-expanded": expanded, "aria-controls": fieldsId, className: "flex-none inline-flex items-center gap-1 rounded-full border border-[var(--tb-line)] px-2.5 py-1.5 text-xs", children: [expanded ? "Hide" : "Edit", _jsx(ChevronDown, { className: `h-3.5 w-3.5 transition-transform ${expanded ? "rotate-180" : ""}`, "aria-hidden": "true" })] })] }), showPicker && (_jsx("div", { className: "mt-3", children: _jsx(AvatarPicker, { value: profile.picture ?? "", onChange: pickAvatar }) })), loading ? (_jsxs("div", { className: `flex items-center gap-1.5 text-xs py-2 mt-3 ${muted}`, children: [_jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }), " Reading from relays\u2026"] })) : (_jsxs("div", { id: fieldsId, hidden: !expanded, className: expanded ? "mt-3 grid gap-x-3 gap-y-2 sm:grid-cols-2" : undefined, children: [FIELDS.map((f) => (_jsxs("label", { className: `block text-xs ${muted} ${f.key === "about" ? "sm:col-span-2" : ""}`, children: [f.label, f.key === "about" ? (_jsx("textarea", { value: profile[f.key] ?? "", onChange: (e) => set(f.key, e.target.value), rows: 2, className: `mt-1 ${input} font-sans resize-none`, placeholder: f.placeholder, readOnly: mode === "how-to-set" })) : (_jsx("input", { value: profile[f.key] ?? "", onChange: (e) => set(f.key, e.target.value), className: `mt-1 ${input} font-sans`, placeholder: f.placeholder, readOnly: mode === "how-to-set" }))] }, f.key))), msg && (_jsx("div", { className: `text-xs sm:col-span-2 ${msg.ok ? "text-[var(--tb-ok)]" : "text-[var(--tb-err-ink)]"}`, children: msg.text })), _jsxs("div", { className: "relative flex items-center gap-3 sm:col-span-2", ref: howToRef, children: [_jsx("button", { type: "button", onClick: () => (mode === "how-to-set" ? setShowHowTo((v) => !v) : void publish()), disabled: publishControlDisabled(mode, publishing), "aria-haspopup": mode === "how-to-set" ? "dialog" : undefined, "aria-expanded": mode === "how-to-set" ? showHowTo : undefined, className: mode === "publish"
|
package/dist/react/NpubGate.js
CHANGED
|
@@ -167,7 +167,7 @@ export default function NpubGate({ onLogin, startFresh = false, operatorHash, no
|
|
|
167
167
|
setPendingProof("");
|
|
168
168
|
setError("");
|
|
169
169
|
}
|
|
170
|
-
return (_jsxs("div", { className: cx("max-w-md mx-auto mt-12 px-4", c.root), children: [_jsxs("h1", { className: "text-xl font-semibold mb-1", children: ["Sign in to ", appName] }), welcome && _jsx("div", { className: cx("text-sm mb-3", c.welcome), children: welcome }), _jsxs("p", { className: cx(`text-sm mb-5 ${muted}`, c.register), children: [appName, " is an operator on the Tollbooth DPYC\u2122 network. DPYC\u2122 \u2014 Don't Pester Your Customer\u2122: no email, no password, no KYC. Your Nostr key is your identity."] }), notice && _jsx("div", { className: `mb-5 ${warnBox}`, children: notice }), stage === "begin" && recents.length > 0 && (_jsxs("div", { className: "mb-5", children: [_jsx("div", { className: `text-xs uppercase tracking-wider mb-2 ${muted}`, children: "Recent identities" }), _jsx("div", { className: "space-y-1.5", children: recents.map((e) => (_jsxs("div", { className: "flex gap-1.5", children: [_jsxs("button", { type: "button", onClick: () => reuseRecent(e), disabled: busy, title: `Re-enter as ${e.npub} on the cached proof`, className: `${card} flex-1 flex items-center justify-between px-3 py-2.5 text-left hover:border-[var(--tb-accent)]`, children: [_jsxs("span", { className: "font-mono text-xs truncate", children: [e.npub.slice(0, 12), "\u2026", e.npub.slice(-6)] }), _jsxs("span", { className: `text-xs shrink-0 ml-2 ${muted}`, children: [ttl(e.expiresAt), " left"] })] }), _jsx("button", { type: "button", onClick: () => forget(e.npub), disabled: busy, title: "Forget this identity", "aria-label": "Forget this identity", className: `w-9 rounded-lg border border-[var(--tb-line)] ${muted}`, children: "\u00D7" })] }, e.npub))) })] })), _jsxs("div", { className: `${card} p-4 space-y-3`, children: [stage === "begin" ? (_jsxs(_Fragment, { children: [_jsx("label", { className: `block text-xs uppercase tracking-wider ${muted}`, htmlFor: "tb-npub-field", children: "Enter npub (secure) or nsec (convenient)" }), _jsx("input", { id: "tb-npub-field", type: isNsec ? "password" : "text", value: value, onChange: (e) => {
|
|
170
|
+
return (_jsxs("div", { className: cx("max-w-md mx-auto mt-12 px-4", c.root), children: [_jsxs("h1", { className: "text-xl font-semibold mb-1", children: ["Sign in to ", appName] }), welcome && _jsx("div", { className: cx("text-sm mb-3", c.welcome), children: welcome }), _jsxs("p", { className: cx(`text-sm mb-5 ${muted}`, c.register), children: [appName, " is an operator on the Tollbooth DPYC\u2122 network. DPYC\u2122 \u2014 Don't Pester Your Customer\u2122: no email, no password, no KYC. Your Nostr key is your identity."] }), notice && _jsx("div", { className: `mb-5 ${warnBox}`, children: notice }), stage === "begin" && recents.length > 0 && (_jsxs("div", { className: "mb-5", children: [_jsx("div", { className: `text-xs uppercase tracking-wider mb-2 ${muted}`, children: "Recent identities" }), _jsx("div", { className: "space-y-1.5", children: recents.map((e) => (_jsxs("div", { className: "flex gap-1.5", children: [_jsxs("button", { type: "button", onClick: () => reuseRecent(e), disabled: busy, title: `Re-enter as ${e.npub} on the cached proof`, className: `${card} flex-1 flex items-center justify-between px-3 py-2.5 text-left hover:border-[var(--tb-accent)]`, children: [_jsxs("span", { className: "font-mono text-xs truncate", children: [e.npub.slice(0, 12), "\u2026", e.npub.slice(-6)] }), _jsxs("span", { className: `text-xs shrink-0 ml-2 ${muted}`, children: [ttl(e.expiresAt), " left"] })] }), _jsx("button", { type: "button", onClick: () => forget(e.npub), disabled: busy, title: "Forget this identity", "aria-label": "Forget this identity", className: `w-9 rounded-lg border border-[var(--tb-line)] ${muted}`, children: "\u00D7" })] }, e.npub))) })] })), _jsxs("div", { className: `${card} p-4 space-y-3`, children: [stage === "begin" ? (_jsxs(_Fragment, { children: [_jsx("label", { className: `block text-xs uppercase tracking-wider ${muted}`, htmlFor: "tb-npub-field", children: "Enter npub (secure) or nsec (convenient)" }), _jsx("input", { id: "tb-npub-field", "data-tour": "tb.gate.npub", type: isNsec ? "password" : "text", value: value, onChange: (e) => {
|
|
171
171
|
setValue(e.target.value);
|
|
172
172
|
setGeneratedHint(false);
|
|
173
173
|
}, onKeyDown: (e) => {
|
|
@@ -177,7 +177,7 @@ export default function NpubGate({ onLogin, startFresh = false, operatorHash, no
|
|
|
177
177
|
signInWithNsec();
|
|
178
178
|
else
|
|
179
179
|
void begin();
|
|
180
|
-
}, placeholder: "npub1\u2026", spellCheck: false, autoCapitalize: "off", autoCorrect: "off", className: input }), generatedHint && (_jsxs("div", { className: warnBox, children: ["New key generated. ", _jsx("b", { children: "Save this nsec" }), " somewhere safe \u2014 it's the only copy and it lives only in this browser."] })), _jsx("button", { type: "button", onClick: () => (isNsec ? signInWithNsec() : void begin()), disabled: !valid || busy, className: primary, children: busy ? "Sending…" : isNpub ? "Send message" : "Sign in" }), isNsec && _jsx("p", { className: `text-xs ${muted}`, children: "Your nsec stays in this browser and signs each call." }), isNpub && (_jsx("p", { className: `text-xs ${muted}`, children: "We send a message to your npub. Approve it from your Nostr client \u2014 your signature is the proof." })), _jsx("p", { className: `text-xs pt-1 ${muted}`, children: "Need a new identity?" }), _jsx("button", { type: "button", onClick: generateKey, className: ghost, children: "Generate a new key" })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: `${warnBox} space-y-1.5`, children: [_jsx("div", { className: "font-medium", children: "Message sent \u2014 open your Nostr client." }), _jsx("div", { children: "Reply with any text. Your signature on that reply is the proof." }), operatorHash && (_jsxs("div", { children: ["Sender fingerprint: ", _jsxs("span", { className: "font-mono", children: ["\uD83D\uDD12 ", operatorHash] })] }))] }), pendingProof && (_jsxs("div", { className: "rounded-lg p-3 text-xs border border-[var(--tb-accent)] space-y-2", children: [_jsx("div", { className: `uppercase tracking-wider text-[10px] ${muted}`, children: "Confirmation code" }), _jsx("div", { className: "font-mono text-base select-all", children: pendingProof }), _jsxs("div", { className: `leading-relaxed ${muted}`, children: ["The same code appears in the message. ", _jsx("b", { children: "Approve it only if the codes match." }), " If they differ \u2014 or the message points you somewhere other than this site \u2014 do not reply."] })] })), _jsx("button", { type: "button", onClick: () => void finish(), disabled: busy, className: primary, children: busy ? "Checking…" : "I've replied — verify" }), _jsx("button", { type: "button", onClick: () => void begin(), disabled: busy, className: ghost, children: "Resend" }), _jsx("button", { type: "button", onClick: reset, disabled: busy, className: `w-full text-xs py-1.5 ${muted}`, children: "Use a different npub" })] })), error && _jsx(Trouble, { raw: error, appName: appName }), note && _jsx("div", { className: `text-xs text-center italic ${muted}`, children: note })] }), steps && _jsx(SignInSteps, { stage: gateStage(stage, busy), classNames: { ...STEPS_LOOK, ...c.steps } }), links && _jsx(SignInLinks, { credit: linksCredit, classNames: { ...LINKS_LOOK, ...c.links } })] }));
|
|
180
|
+
}, placeholder: "npub1\u2026", spellCheck: false, autoCapitalize: "off", autoCorrect: "off", className: input }), generatedHint && (_jsxs("div", { className: warnBox, children: ["New key generated. ", _jsx("b", { children: "Save this nsec" }), " somewhere safe \u2014 it's the only copy and it lives only in this browser."] })), _jsx("button", { type: "button", onClick: () => (isNsec ? signInWithNsec() : void begin()), disabled: !valid || busy, className: primary, "data-tour": "tb.gate.send", children: busy ? "Sending…" : isNpub ? "Send message" : "Sign in" }), isNsec && _jsx("p", { className: `text-xs ${muted}`, children: "Your nsec stays in this browser and signs each call." }), isNpub && (_jsx("p", { className: `text-xs ${muted}`, children: "We send a message to your npub. Approve it from your Nostr client \u2014 your signature is the proof." })), _jsx("p", { className: `text-xs pt-1 ${muted}`, children: "Need a new identity?" }), _jsx("button", { type: "button", onClick: generateKey, className: ghost, children: "Generate a new key" })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: `${warnBox} space-y-1.5`, children: [_jsx("div", { className: "font-medium", children: "Message sent \u2014 open your Nostr client." }), _jsx("div", { children: "Reply with any text. Your signature on that reply is the proof." }), operatorHash && (_jsxs("div", { children: ["Sender fingerprint: ", _jsxs("span", { className: "font-mono", children: ["\uD83D\uDD12 ", operatorHash] })] }))] }), pendingProof && (_jsxs("div", { className: "rounded-lg p-3 text-xs border border-[var(--tb-accent)] space-y-2", children: [_jsx("div", { className: `uppercase tracking-wider text-[10px] ${muted}`, children: "Confirmation code" }), _jsx("div", { className: "font-mono text-base select-all", children: pendingProof }), _jsxs("div", { className: `leading-relaxed ${muted}`, children: ["The same code appears in the message. ", _jsx("b", { children: "Approve it only if the codes match." }), " If they differ \u2014 or the message points you somewhere other than this site \u2014 do not reply."] })] })), _jsx("button", { type: "button", onClick: () => void finish(), disabled: busy, className: primary, "data-tour": "tb.gate.verify", children: busy ? "Checking…" : "I've replied — verify" }), _jsx("button", { type: "button", onClick: () => void begin(), disabled: busy, className: ghost, children: "Resend" }), _jsx("button", { type: "button", onClick: reset, disabled: busy, className: `w-full text-xs py-1.5 ${muted}`, children: "Use a different npub" })] })), error && _jsx(Trouble, { raw: error, appName: appName }), note && _jsx("div", { className: `text-xs text-center italic ${muted}`, children: note })] }), steps && _jsx(SignInSteps, { stage: gateStage(stage, busy), classNames: { ...STEPS_LOOK, ...c.steps } }), links && _jsx(SignInLinks, { credit: linksCredit, classNames: { ...LINKS_LOOK, ...c.links } })] }));
|
|
181
181
|
}
|
|
182
182
|
/** A failure, read to the person: the lead first, the service's own words underneath. */
|
|
183
183
|
function Trouble({ raw, appName }) {
|
|
@@ -155,9 +155,9 @@ function SessionKeyClaimInner({ actionClass }) {
|
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
if (!revealed) {
|
|
158
|
-
return (_jsxs("div", { className: `${card} px-4 py-3`, children: [_jsx("div", { className: "eyebrow mb-1", children: "Session key" }), _jsxs("div", { className: "flex flex-wrap items-center gap-x-4 gap-y-2", children: [_jsx("p", { className: `min-w-[14rem] flex-1 text-[12.5px] leading-snug ${muted}`, children: "This browser is holding the secret key that signs you in. Take a copy before you clear site data or switch devices \u2014 without it, this identity is gone." }), _jsx("button", { type: "button", onClick: () => setRevealed(true), className: "min-h-11 shrink-0 rounded-full border border-[var(--tb-accent)] bg-[var(--tb-accent)] px-4 text-[13px] font-semibold text-[var(--tb-on-accent)]", children: "Claim your session key" })] })] }));
|
|
158
|
+
return (_jsxs("div", { className: `${card} px-4 py-3`, "data-tour": "tb.account.sessionKey", children: [_jsx("div", { className: "eyebrow mb-1", children: "Session key" }), _jsxs("div", { className: "flex flex-wrap items-center gap-x-4 gap-y-2", children: [_jsx("p", { className: `min-w-[14rem] flex-1 text-[12.5px] leading-snug ${muted}`, children: "This browser is holding the secret key that signs you in. Take a copy before you clear site data or switch devices \u2014 without it, this identity is gone." }), _jsx("button", { type: "button", onClick: () => setRevealed(true), className: "min-h-11 shrink-0 rounded-full border border-[var(--tb-accent)] bg-[var(--tb-accent)] px-4 text-[13px] font-semibold text-[var(--tb-on-accent)]", children: "Claim your session key" })] })] }));
|
|
159
159
|
}
|
|
160
|
-
return (_jsxs("div", { className: `relative ${card} px-4 py-3`, children: [_jsxs("div", { className: "flex flex-wrap items-baseline justify-between gap-2", children: [_jsx("div", { className: "eyebrow mb-1", children: "Session key" }), _jsx("button", { type: "button", onClick: () => {
|
|
160
|
+
return (_jsxs("div", { className: `relative ${card} px-4 py-3`, "data-tour": "tb.account.sessionKey", children: [_jsxs("div", { className: "flex flex-wrap items-baseline justify-between gap-2", children: [_jsx("div", { className: "eyebrow mb-1", children: "Session key" }), _jsx("button", { type: "button", onClick: () => {
|
|
161
161
|
setRevealed(false);
|
|
162
162
|
setShowKey(false);
|
|
163
163
|
setDmOpen(false);
|
|
@@ -2,5 +2,5 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { cx } from "./cx.js";
|
|
3
3
|
import { SIGN_IN_LINKS, SIGN_IN_LINKS_CREDIT } from "./signInTools.js";
|
|
4
4
|
export default function SignInLinks({ links = SIGN_IN_LINKS, lead = "New to Nostr? These two are all it takes.", credit = true, classNames: c = {}, }) {
|
|
5
|
-
return (_jsxs("nav", { "aria-label": "Tools for signing in", className: c.root, children: [lead && _jsx("p", { className: c.lead, children: lead }), _jsx("ul", { className: c.list, children: links.map((l) => (_jsx("li", { className: c.item, children: _jsxs("a", { href: l.href, target: "_blank", rel: "noopener noreferrer", className: cx(c.link), children: [l.logo && _jsx("img", { src: l.logo, alt: "", width: 40, height: 40, className: c.logo }), _jsxs("span", { className: c.text, children: [_jsx("span", { className: c.name, children: l.name }), _jsx("span", { className: c.line, children: l.line })] })] }) }, l.id))) }), credit && _jsx("p", { className: c.credit, children: SIGN_IN_LINKS_CREDIT })] }));
|
|
5
|
+
return (_jsxs("nav", { "aria-label": "Tools for signing in", className: c.root, "data-tour": "tb.gate.links", children: [lead && _jsx("p", { className: c.lead, children: lead }), _jsx("ul", { className: c.list, children: links.map((l) => (_jsx("li", { className: c.item, children: _jsxs("a", { href: l.href, target: "_blank", rel: "noopener noreferrer", className: cx(c.link), children: [l.logo && _jsx("img", { src: l.logo, alt: "", width: 40, height: 40, className: c.logo }), _jsxs("span", { className: c.text, children: [_jsx("span", { className: c.name, children: l.name }), _jsx("span", { className: c.line, children: l.line })] })] }) }, l.id))) }), credit && _jsx("p", { className: c.credit, children: SIGN_IN_LINKS_CREDIT })] }));
|
|
6
6
|
}
|
|
@@ -13,7 +13,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
13
13
|
import { cx } from "./cx.js";
|
|
14
14
|
import { SIGN_IN_ACTORS, SIGN_IN_STEPS, stepState, } from "./signInFlow.js";
|
|
15
15
|
export default function SignInSteps({ stage, steps = SIGN_IN_STEPS, actors = SIGN_IN_ACTORS, label = "How signing in works", classNames: c = {}, }) {
|
|
16
|
-
return (_jsx("ol", { "aria-label": label, className: c.root, children: steps.map((s, i) => {
|
|
16
|
+
return (_jsx("ol", { "aria-label": label, className: c.root, "data-tour": "tb.gate.steps", children: steps.map((s, i) => {
|
|
17
17
|
const state = stepState(s, stage);
|
|
18
18
|
return (_jsxs("li", { "data-state": state, "aria-current": state === "current" ? "step" : undefined, className: cx(c.step, c[state]), children: [_jsx("span", { "aria-hidden": true, className: c.glyph, children: s.glyph }), _jsx("span", { className: c.label, children: s.label }), _jsx("span", { className: c.actor, children: actors[s.actor] }), i < steps.length - 1 && (_jsx("span", { "aria-hidden": true, className: c.connector, children: "\u203A" }))] }, s.id));
|
|
19
19
|
}) }));
|
|
@@ -48,6 +48,6 @@ export default function UsageSummary({ days = 30, heading, figures = ALL, labels
|
|
|
48
48
|
void load();
|
|
49
49
|
}, [load]);
|
|
50
50
|
const shown = facts ? figures.filter((f) => f !== "expired" || facts.expired > 0) : [];
|
|
51
|
-
return (_jsxs("section", { className: c.root, children: [_jsxs("div", { className: c.header, children: [_jsx("div", { className: c.heading, children: heading ?? `Last ${days} days` }), refreshLabel !== null && (_jsx("button", { type: "button", onClick: () => void load(), disabled: loading, className: c.chip, children: loading ? "Loading…" : refreshLabel }))] }), error ? (_jsx("p", { className: c.error, children: error })) : !facts ? (loading && _jsx("p", { className: c.loading, children: "Loading\u2026" })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: c.figures, children: shown.map((f) => (_jsxs("div", { className: c.figure, children: [_jsx("div", { className: c.value, children: facts[f].toLocaleString() }), _jsx("div", { className: c.label, children: labels[f] ?? LABELS[f] })] }, f))) }), topTools > 0 &&
|
|
51
|
+
return (_jsxs("section", { className: c.root, "data-tour": "tb.account.usage", children: [_jsxs("div", { className: c.header, children: [_jsx("div", { className: c.heading, children: heading ?? `Last ${days} days` }), refreshLabel !== null && (_jsx("button", { type: "button", onClick: () => void load(), disabled: loading, className: c.chip, children: loading ? "Loading…" : refreshLabel }))] }), error ? (_jsx("p", { className: c.error, children: error })) : !facts ? (loading && _jsx("p", { className: c.loading, children: "Loading\u2026" })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: c.figures, children: shown.map((f) => (_jsxs("div", { className: c.figure, children: [_jsx("div", { className: c.value, children: facts[f].toLocaleString() }), _jsx("div", { className: c.label, children: labels[f] ?? LABELS[f] })] }, f))) }), topTools > 0 &&
|
|
52
52
|
(facts.tools.length === 0 ? (empty && _jsx("p", { className: c.empty, children: empty })) : (_jsxs(_Fragment, { children: [toolsHeading && _jsx("div", { className: c.subheading, children: toolsHeading }), _jsx("ul", { className: c.list, children: facts.tools.map((t) => (_jsx("li", { className: c.row, children: renderRow ? (renderRow(t)) : (_jsxs(_Fragment, { children: [_jsx("span", { className: c.tool, children: t.tool }), " ", _jsxs("span", { className: c.calls, children: [t.calls.toLocaleString(), " call", t.calls === 1 ? "" : "s"] }), " ", _jsxs("span", { className: c.sats, children: [t.sats.toLocaleString(), " sats"] })] })) }, t.tool))) })] })))] }))] }));
|
|
53
53
|
}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -33,3 +33,5 @@ export { useDebugLog } from "./useDebugLog.ts";
|
|
|
33
33
|
export { useTheme } from "./useTheme.ts";
|
|
34
34
|
export { useTimezone } from "./useTimezone.ts";
|
|
35
35
|
export { useTopUp, type TopUp, type UseTopUpOptions } from "./useTopUp.ts";
|
|
36
|
+
export { TourProvider, tourTarget, useTour, useTourReplay, useTourSwitch, type TourProviderProps, type UseTourOptions, } from "./useTour.tsx";
|
|
37
|
+
export { buildTour, offerTour, OFFER_WORDS, type Offer, type OfferHooks, type OfferWords, type TourHooks, type TourRun } from "../tour/run.ts";
|
package/dist/react/index.js
CHANGED
|
@@ -32,3 +32,5 @@ export { useDebugLog } from "./useDebugLog.js";
|
|
|
32
32
|
export { useTheme } from "./useTheme.js";
|
|
33
33
|
export { useTimezone } from "./useTimezone.js";
|
|
34
34
|
export { useTopUp } from "./useTopUp.js";
|
|
35
|
+
export { TourProvider, tourTarget, useTour, useTourReplay, useTourSwitch, } from "./useTour.js";
|
|
36
|
+
export { buildTour, offerTour, OFFER_WORDS } from "../tour/run.js";
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guided tour: who sees it, when it is offered, and how a page asks for
|
|
3
|
+
* its own.
|
|
4
|
+
*
|
|
5
|
+
* `TourProvider` holds the site's tours and the preference. A page calls
|
|
6
|
+
* `useTour(page, {actions})` once; when this device is due that page's tour,
|
|
7
|
+
* the page is watched for a quiet stretch and then asked, centred, whether it
|
|
8
|
+
* would like one. Yes starts the tour; No marks the page. A page asked to
|
|
9
|
+
* replay from its guide starts at once. `actions` are what a step may ask
|
|
10
|
+
* the page to do first — open the add form, pick a tag — by the names the
|
|
11
|
+
* copy uses.
|
|
12
|
+
*
|
|
13
|
+
* The site keeps its own switch and its own replay button: `useTourSwitch`
|
|
14
|
+
* and `useTourReplay` give them their mechanics.
|
|
15
|
+
*/
|
|
16
|
+
import { type ReactNode } from "react";
|
|
17
|
+
import { type OfferWords } from "../tour/run.ts";
|
|
18
|
+
import type { Tour, TourActions } from "../tour/types.ts";
|
|
19
|
+
export interface TourProviderProps {
|
|
20
|
+
/** The site's tours, by page key — its own and the package's two. */
|
|
21
|
+
tours: Record<string, Tour>;
|
|
22
|
+
/** The quiet stretch before a page offers its tour. Defaults to the site's `tourIdleMs`, else 10 s. */
|
|
23
|
+
idleMs?: number;
|
|
24
|
+
/** The offer's words, when a site wants its own. */
|
|
25
|
+
words?: OfferWords;
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
export declare function TourProvider({ tours, idleMs, words, children }: TourProviderProps): import("react").JSX.Element;
|
|
29
|
+
export interface UseTourOptions {
|
|
30
|
+
actions?: TourActions;
|
|
31
|
+
/** False while the page has yet to draw what the tour points at. */
|
|
32
|
+
ready?: boolean;
|
|
33
|
+
}
|
|
34
|
+
/** Ask for this page's tour, if this device is due it. */
|
|
35
|
+
export declare function useTour(page: string, { actions, ready }?: UseTourOptions): void;
|
|
36
|
+
/** The site's "Live tutorial" switch: on replays every page, off stops them all. */
|
|
37
|
+
export declare function useTourSwitch(): {
|
|
38
|
+
on: boolean;
|
|
39
|
+
setOn: (on: boolean) => void;
|
|
40
|
+
};
|
|
41
|
+
/** The site's "Show me on the page" button: that page's tour once more, at once. */
|
|
42
|
+
export declare function useTourReplay(): (page: string) => void;
|
|
43
|
+
/** The attribute to spread on the element a step points at. */
|
|
44
|
+
export declare function tourTarget(name: string): {
|
|
45
|
+
"data-tour": string;
|
|
46
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The guided tour: who sees it, when it is offered, and how a page asks for
|
|
4
|
+
* its own.
|
|
5
|
+
*
|
|
6
|
+
* `TourProvider` holds the site's tours and the preference. A page calls
|
|
7
|
+
* `useTour(page, {actions})` once; when this device is due that page's tour,
|
|
8
|
+
* the page is watched for a quiet stretch and then asked, centred, whether it
|
|
9
|
+
* would like one. Yes starts the tour; No marks the page. A page asked to
|
|
10
|
+
* replay from its guide starts at once. `actions` are what a step may ask
|
|
11
|
+
* the page to do first — open the add form, pick a tag — by the names the
|
|
12
|
+
* copy uses.
|
|
13
|
+
*
|
|
14
|
+
* The site keeps its own switch and its own replay button: `useTourSwitch`
|
|
15
|
+
* and `useTourReplay` give them their mechanics.
|
|
16
|
+
*/
|
|
17
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
|
18
|
+
import { tollboothConfig } from "../config.js";
|
|
19
|
+
import { ACTIVITY_EVENTS, idleThreshold, offerDue } from "../tour/idle.js";
|
|
20
|
+
import { buildTour, offerTour } from "../tour/run.js";
|
|
21
|
+
import { markToured, replayAll, replayPage, shouldRun, stopAll } from "../tour/state.js";
|
|
22
|
+
import { onTourChange, readTourPrefs, settleTourPrefs, tourStorageKey, writeTourPrefs } from "../tourPrefs.js";
|
|
23
|
+
const Ctx = createContext(null);
|
|
24
|
+
function subscribe(changed) {
|
|
25
|
+
const stopPage = onTourChange(changed);
|
|
26
|
+
const onStorage = (e) => {
|
|
27
|
+
try {
|
|
28
|
+
if (e.key === tourStorageKey())
|
|
29
|
+
changed();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* not configured yet: nothing of ours changed */
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
globalThis.addEventListener?.("storage", onStorage);
|
|
36
|
+
return () => {
|
|
37
|
+
stopPage();
|
|
38
|
+
globalThis.removeEventListener?.("storage", onStorage);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const onServer = () => ({ on: false, toured: {}, replay: null });
|
|
42
|
+
export function TourProvider({ tours, idleMs, words, children }) {
|
|
43
|
+
const prefs = useSyncExternalStore(subscribe, readTourPrefs, onServer);
|
|
44
|
+
const update = useCallback((change) => {
|
|
45
|
+
writeTourPrefs(change(readTourPrefs()));
|
|
46
|
+
}, []);
|
|
47
|
+
const threshold = idleThreshold(tollboothConfig().tourIdleMs, idleMs);
|
|
48
|
+
// A newcomer's first reading is written down, so signing in a minute later
|
|
49
|
+
// does not turn them into an old hand.
|
|
50
|
+
useEffect(() => { settleTourPrefs(); }, []);
|
|
51
|
+
const value = useMemo(() => ({ prefs, update, tours, idleMs: threshold, words }), [prefs, update, tours, threshold, words]);
|
|
52
|
+
return _jsx(Ctx.Provider, { value: value, children: children });
|
|
53
|
+
}
|
|
54
|
+
/** Ask for this page's tour, if this device is due it. */
|
|
55
|
+
export function useTour(page, { actions, ready = true } = {}) {
|
|
56
|
+
const ctx = useContext(Ctx);
|
|
57
|
+
const acts = useRef(actions);
|
|
58
|
+
acts.current = actions;
|
|
59
|
+
const due = ctx ? shouldRun(ctx.prefs, page) : false;
|
|
60
|
+
const atOnce = ctx?.prefs.replay === page;
|
|
61
|
+
const update = ctx?.update;
|
|
62
|
+
const tour = ctx?.tours[page];
|
|
63
|
+
const idleMs = ctx?.idleMs ?? 0;
|
|
64
|
+
const words = ctx?.words;
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!due || !ready || !update || !tour)
|
|
67
|
+
return;
|
|
68
|
+
let gone = false;
|
|
69
|
+
let run = null;
|
|
70
|
+
let offer = null;
|
|
71
|
+
const start = () => {
|
|
72
|
+
if (gone)
|
|
73
|
+
return;
|
|
74
|
+
run = buildTour(tour, {
|
|
75
|
+
run: (name) => acts.current?.[name]?.(),
|
|
76
|
+
onEnd: () => update((p) => markToured(p, page)),
|
|
77
|
+
onStop: () => update(stopAll),
|
|
78
|
+
});
|
|
79
|
+
run.start();
|
|
80
|
+
};
|
|
81
|
+
// From a guide, at once; otherwise after a quiet while, and only asked.
|
|
82
|
+
if (atOnce) {
|
|
83
|
+
const timer = window.setTimeout(start, 600);
|
|
84
|
+
return () => { gone = true; window.clearTimeout(timer); run?.stop(true); };
|
|
85
|
+
}
|
|
86
|
+
let last = Date.now();
|
|
87
|
+
const touched = () => { last = Date.now(); };
|
|
88
|
+
for (const ev of ACTIVITY_EVENTS)
|
|
89
|
+
window.addEventListener(ev, touched, { passive: true, capture: true });
|
|
90
|
+
const tick = window.setInterval(() => {
|
|
91
|
+
if (!offerDue(last, Date.now(), document.visibilityState === "visible", idleMs))
|
|
92
|
+
return;
|
|
93
|
+
window.clearInterval(tick);
|
|
94
|
+
for (const ev of ACTIVITY_EVENTS)
|
|
95
|
+
window.removeEventListener(ev, touched, { capture: true });
|
|
96
|
+
offer = offerTour({
|
|
97
|
+
onYes: start,
|
|
98
|
+
onNo: () => update((p) => markToured(p, page)),
|
|
99
|
+
}, words);
|
|
100
|
+
offer.show();
|
|
101
|
+
}, 1000);
|
|
102
|
+
return () => {
|
|
103
|
+
gone = true;
|
|
104
|
+
window.clearInterval(tick);
|
|
105
|
+
for (const ev of ACTIVITY_EVENTS)
|
|
106
|
+
window.removeEventListener(ev, touched, { capture: true });
|
|
107
|
+
offer?.dismiss();
|
|
108
|
+
run?.stop(true);
|
|
109
|
+
};
|
|
110
|
+
}, [page, due, atOnce, ready, update, tour, idleMs, words]);
|
|
111
|
+
}
|
|
112
|
+
/** The site's "Live tutorial" switch: on replays every page, off stops them all. */
|
|
113
|
+
export function useTourSwitch() {
|
|
114
|
+
const ctx = useContext(Ctx);
|
|
115
|
+
const update = ctx?.update;
|
|
116
|
+
const setOn = useCallback((on) => { update?.(on ? replayAll : stopAll); }, [update]);
|
|
117
|
+
return { on: ctx?.prefs.on ?? false, setOn };
|
|
118
|
+
}
|
|
119
|
+
/** The site's "Show me on the page" button: that page's tour once more, at once. */
|
|
120
|
+
export function useTourReplay() {
|
|
121
|
+
const ctx = useContext(Ctx);
|
|
122
|
+
const update = ctx?.update;
|
|
123
|
+
return useCallback((page) => { update?.((p) => replayPage(p, page)); }, [update]);
|
|
124
|
+
}
|
|
125
|
+
/** The attribute to spread on the element a step points at. */
|
|
126
|
+
export function tourTarget(name) {
|
|
127
|
+
return { "data-tour": name };
|
|
128
|
+
}
|
package/dist/toolResult.d.ts
CHANGED
|
@@ -33,5 +33,16 @@ export declare function readToolResult(result: RawToolResult): ReadResult;
|
|
|
33
33
|
* silently never fired. Normalised here, once.
|
|
34
34
|
*/
|
|
35
35
|
export declare function proofBounceMessage(payload: unknown): string | null;
|
|
36
|
+
/**
|
|
37
|
+
* A stored proof grant the service refused for good: `{success: false,
|
|
38
|
+
* error_code: proof_invalid, reason}` where the reason is about the grant —
|
|
39
|
+
* expired, revoked, issued for another npub or operator, malformed. Retrying
|
|
40
|
+
* with it can never succeed, so the patron is signed out.
|
|
41
|
+
*
|
|
42
|
+
* Only meaningful when the call carried the stored grant. A session key's
|
|
43
|
+
* inline proof shares the code and even the word `expired` (outside its
|
|
44
|
+
* window), but it is signed afresh for the next call and loses nothing.
|
|
45
|
+
*/
|
|
46
|
+
export declare function deadGrantMessage(payload: unknown): string | null;
|
|
36
47
|
/** Whether a payload reports failure, for the debug log's colouring. */
|
|
37
48
|
export declare function looksFailed(payload: unknown): boolean;
|
package/dist/toolResult.js
CHANGED
|
@@ -51,6 +51,33 @@ export function proofBounceMessage(payload) {
|
|
|
51
51
|
}
|
|
52
52
|
return null;
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Grant refusals that say nothing about the grant: the service could not
|
|
56
|
+
* establish its own identity, or could not read its revocation list. Both are
|
|
57
|
+
* the service's fault and retryable, so the patron stays signed in.
|
|
58
|
+
*/
|
|
59
|
+
const GRANT_STILL_GOOD = new Set(["operator_unknown", "revocations_unavailable"]);
|
|
60
|
+
/**
|
|
61
|
+
* A stored proof grant the service refused for good: `{success: false,
|
|
62
|
+
* error_code: proof_invalid, reason}` where the reason is about the grant —
|
|
63
|
+
* expired, revoked, issued for another npub or operator, malformed. Retrying
|
|
64
|
+
* with it can never succeed, so the patron is signed out.
|
|
65
|
+
*
|
|
66
|
+
* Only meaningful when the call carried the stored grant. A session key's
|
|
67
|
+
* inline proof shares the code and even the word `expired` (outside its
|
|
68
|
+
* window), but it is signed afresh for the next call and loses nothing.
|
|
69
|
+
*/
|
|
70
|
+
export function deadGrantMessage(payload) {
|
|
71
|
+
if (!payload || typeof payload !== "object")
|
|
72
|
+
return null;
|
|
73
|
+
const p = payload;
|
|
74
|
+
const code = String(p.error_code ?? "").toLowerCase();
|
|
75
|
+
if (p.success !== false || code !== "proof_invalid")
|
|
76
|
+
return null;
|
|
77
|
+
if (GRANT_STILL_GOOD.has(String(p.reason ?? "").toLowerCase()))
|
|
78
|
+
return null;
|
|
79
|
+
return String(p.error ?? "Your sign-in has expired.");
|
|
80
|
+
}
|
|
54
81
|
/** Whether a payload reports failure, for the debug log's colouring. */
|
|
55
82
|
export function looksFailed(payload) {
|
|
56
83
|
if (!payload || typeof payload !== "object")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When to offer a tour: after a stretch with nothing touched.
|
|
3
|
+
*
|
|
4
|
+
* A tour that starts the moment a page opens gets in the way of someone who
|
|
5
|
+
* knows the page. One offered after a pause finds the person who is looking
|
|
6
|
+
* for where to start. The policy is data here, so it is tested; the hook
|
|
7
|
+
* that listens for the events is in `react/useTour.ts`.
|
|
8
|
+
*/
|
|
9
|
+
/** How long a page may sit untouched before a tour is offered. */
|
|
10
|
+
export declare const DEFAULT_IDLE_MS = 10000;
|
|
11
|
+
/** Anything a person does to a page. */
|
|
12
|
+
export declare const ACTIVITY_EVENTS: readonly ["pointerdown", "pointermove", "keydown", "wheel", "touchstart", "scroll"];
|
|
13
|
+
/** The idle stretch a site asked for, or the default; never under a second. */
|
|
14
|
+
export declare function idleThreshold(configured: number | undefined, override?: number): number;
|
|
15
|
+
/**
|
|
16
|
+
* Whether a page has sat untouched long enough. A hidden tab is never idle:
|
|
17
|
+
* someone reading another tab has not gone quiet on this one.
|
|
18
|
+
*/
|
|
19
|
+
export declare function offerDue(lastActivityAt: number, now: number, visible: boolean, idleMs: number): boolean;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When to offer a tour: after a stretch with nothing touched.
|
|
3
|
+
*
|
|
4
|
+
* A tour that starts the moment a page opens gets in the way of someone who
|
|
5
|
+
* knows the page. One offered after a pause finds the person who is looking
|
|
6
|
+
* for where to start. The policy is data here, so it is tested; the hook
|
|
7
|
+
* that listens for the events is in `react/useTour.ts`.
|
|
8
|
+
*/
|
|
9
|
+
/** How long a page may sit untouched before a tour is offered. */
|
|
10
|
+
export const DEFAULT_IDLE_MS = 10_000;
|
|
11
|
+
/** Anything a person does to a page. */
|
|
12
|
+
export const ACTIVITY_EVENTS = ["pointerdown", "pointermove", "keydown", "wheel", "touchstart", "scroll"];
|
|
13
|
+
/** The idle stretch a site asked for, or the default; never under a second. */
|
|
14
|
+
export function idleThreshold(configured, override) {
|
|
15
|
+
const ms = override ?? configured ?? DEFAULT_IDLE_MS;
|
|
16
|
+
return Number.isFinite(ms) && ms >= 1_000 ? ms : DEFAULT_IDLE_MS;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Whether a page has sat untouched long enough. A hidden tab is never idle:
|
|
20
|
+
* someone reading another tab has not gone quiet on this one.
|
|
21
|
+
*/
|
|
22
|
+
export function offerDue(lastActivityAt, now, visible, idleMs) {
|
|
23
|
+
return visible && now - lastActivityAt >= idleMs;
|
|
24
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The voice a tour speaks in, checked.
|
|
3
|
+
*
|
|
4
|
+
* A step says what a control is and what to do with it, in one or two short
|
|
5
|
+
* sentences. It never says what something is not, and never contrasts: "your
|
|
6
|
+
* npub is your key" and never "your key, not a password". A site's test runs
|
|
7
|
+
* its tours through this so every site holds the same voice.
|
|
8
|
+
*/
|
|
9
|
+
import type { Tour, TourStep } from "./types.ts";
|
|
10
|
+
export declare const MAX_SAYS = 180;
|
|
11
|
+
export declare const MAX_TITLE = 28;
|
|
12
|
+
export declare const MAX_SENTENCES = 2;
|
|
13
|
+
/** The problems with one step's words, each a sentence; none when it is well said. */
|
|
14
|
+
export declare function lintStep(s: TourStep, page?: string): string[];
|
|
15
|
+
/** Every problem in a tour's words. */
|
|
16
|
+
export declare function lintTour(t: Tour): string[];
|
|
17
|
+
/** Every problem across a site's tours. */
|
|
18
|
+
export declare function lintTours(tours: Record<string, Tour>): string[];
|