@quran-lab/tajweedformer 2.0.0 → 2.0.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/CHANGELOG.md +27 -0
- package/dist/tajweedformer-opus.mjs +1 -1
- package/dist/tajweedformer-opus.wasm +0 -0
- package/docs/api.md +2 -2
- package/package.json +1 -1
- package/web/consent/copy.js +17 -9
- package/web/consent/decks.js +144 -56
- package/web/consent/element.js +21 -15
- package/web/consent/index.d.ts +76 -13
- package/web/consent/index.js +103 -18
- package/web/consent/state.js +91 -29
- package/web/consent/theme.js +31 -0
- package/web/consent/wording.js +126 -0
- package/web/index.d.ts +1 -1
- package/web/platform/client.js +484 -366
- package/web/platform/index.d.ts +39 -5
- package/web/platform/index.js +3 -2
- package/web/platform/telemetry.js +128 -13
- package/web/platform/update-notice.js +60 -0
- package/web/sharing/bitrate.js +69 -0
- package/web/sharing/encoder.js +26 -5
- package/web/sharing/index.d.ts +53 -6
- package/web/sharing/index.js +287 -257
- package/web/sharing/oggopus.js +325 -294
- package/web/sharing/outbox.js +670 -606
- package/web/sharing/uploader.js +10 -3
- package/web/tajweed/index.d.ts +2 -2
- package/web/tajweed/manifest.js +1 -1
package/web/consent/element.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
// <quranlab-consent> and <quranlab-sharing-settings>: the SDK's own sharing sheet and settings for
|
|
2
|
-
// the browser (SHARING_EXPERIENCE.md, PLATFORM_CLIENT.md section 9). Themable with
|
|
3
|
-
// properties (--ql-bg, --ql-fg, --ql-muted, --ql-accent, --ql-accent-fg, --ql-radius,
|
|
4
|
-
// ::part(sheet|title|body|primary|secondary|guardian), RTL from the deck's
|
|
5
|
-
// screen reader friendly (a modal <dialog>), two buttons of equal weight, nothing
|
|
2
|
+
// the browser (SHARING_EXPERIENCE.md, PLATFORM_CLIENT.md section 9). Themable with a `theme` (theme.js) or
|
|
3
|
+
// the CSS custom properties it sets (--ql-bg, --ql-fg, --ql-muted, --ql-accent, --ql-accent-fg, --ql-radius,
|
|
4
|
+
// --ql-font, --ql-title-font) and ::part(sheet|title|body|primary|secondary|guardian), RTL from the deck's
|
|
5
|
+
// direction, keyboard and screen reader friendly (a modal <dialog>), two buttons of equal weight, nothing
|
|
6
|
+
// pre-ticked. An app's own wording (wording.js) is shown in place of the deck's strings it replaces.
|
|
6
7
|
//
|
|
7
8
|
// import '@quran-lab/tajweedformer/consent'; // defines the elements
|
|
8
|
-
// const yes = await QuranLabConsent.ask({ lang: 'ar', variant: 'first', guardian: false });
|
|
9
|
+
// const yes = await QuranLabConsent.ask({ lang: 'ar', variant: 'first', guardian: false, theme: { accent: '#0b6e4f' } });
|
|
9
10
|
|
|
10
11
|
import { copyFor } from './copy.js';
|
|
12
|
+
import { applyTheme } from './theme.js';
|
|
11
13
|
|
|
12
14
|
const STYLE = `
|
|
13
15
|
:host { all: initial; }
|
|
@@ -18,7 +20,7 @@ dialog { box-sizing: border-box; max-inline-size: min(34rem, calc(100vw - 2rem))
|
|
|
18
20
|
box-shadow: 0 20px 60px rgb(0 0 0 / .25); }
|
|
19
21
|
dialog::backdrop { background: rgb(0 0 0 / .45); }
|
|
20
22
|
|
|
21
|
-
h2 { margin: 0 0 .75rem; font-size: 1.25rem; line-height: 1.3; font-weight: 650; }
|
|
23
|
+
h2 { margin: 0 0 .75rem; font-size: 1.25rem; line-height: 1.3; font-weight: 650; font-family: var(--ql-title-font, inherit); }
|
|
22
24
|
p { margin: 0 0 .75rem; }
|
|
23
25
|
.muted { color: var(--ql-muted, inherit); opacity: .85; font-size: .95rem; }
|
|
24
26
|
ul { margin: 0 0 1rem; padding-inline-start: 1.1rem; }
|
|
@@ -51,7 +53,8 @@ const Base = globalThis.HTMLElement || class {};
|
|
|
51
53
|
export class QuranLabConsent extends Base {
|
|
52
54
|
/**
|
|
53
55
|
* Show the sheet and resolve with the learner's answer (true = share). Dismissing (Escape) is "Not now".
|
|
54
|
-
* @param {{ lang?: string, variant?: 'first'|'reask'|'version', guardian?: boolean, parent?: Element
|
|
56
|
+
* @param {{ lang?: string, variant?: 'first'|'reask'|'version', guardian?: boolean, parent?: Element,
|
|
57
|
+
* wording?: Record<string, string>|null, theme?: object|null }} o
|
|
55
58
|
* @returns {Promise<{ yes: boolean, guardianConfirmed: boolean }>}
|
|
56
59
|
*/
|
|
57
60
|
static ask(o = {}) {
|
|
@@ -60,8 +63,9 @@ export class QuranLabConsent extends Base {
|
|
|
60
63
|
return el.show(o).finally(() => el.remove());
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
show({ lang = (globalThis.navigator && navigator.language) || 'en', variant = 'first', guardian = false } = {}) {
|
|
64
|
-
const c = copyFor(lang);
|
|
66
|
+
show({ lang = (globalThis.navigator && navigator.language) || 'en', variant = 'first', guardian = false, wording = null, theme = null } = {}) {
|
|
67
|
+
const c = copyFor(lang, wording);
|
|
68
|
+
applyTheme(this, theme);
|
|
65
69
|
const t = c.t;
|
|
66
70
|
const root = this.shadowRoot || this.attachShadow({ mode: 'open' });
|
|
67
71
|
const lines = (guardian ? ['sheet.what', 'sheet.who', 'sheet.control'] : ['sheet.mistakes', 'sheet.what', 'sheet.who', 'sheet.control'])
|
|
@@ -101,13 +105,14 @@ export class QuranLabConsent extends Base {
|
|
|
101
105
|
}
|
|
102
106
|
|
|
103
107
|
/** The one-time thank-you, as a small dismissable note. */
|
|
104
|
-
static thank({ lang, parent, ms = 6000 } = {}) {
|
|
105
|
-
const c = copyFor(lang || (globalThis.navigator && navigator.language) || 'en');
|
|
108
|
+
static thank({ lang, parent, ms = 6000, wording = null, theme = null } = {}) {
|
|
109
|
+
const c = copyFor(lang || (globalThis.navigator && navigator.language) || 'en', wording);
|
|
106
110
|
const el = document.createElement('div');
|
|
107
111
|
el.setAttribute('role', 'status');
|
|
108
112
|
el.lang = c.lang; el.dir = c.dir;
|
|
109
113
|
el.textContent = c.t('thanks');
|
|
110
|
-
el.style.cssText = 'position:fixed;inset-inline:1rem;inset-block-end:1rem;max-inline-size:32rem;margin-inline:auto;padding:1rem 1.25rem;border-radius:14px;background:var(--ql-accent,#2a3b8f);color:var(--ql-accent-fg,#fff);font:500 1rem/1.5 var(--ql-font,system-ui,sans-serif);z-index:2147483647;box-shadow:0 10px 30px rgb(0 0 0 / .25)';
|
|
114
|
+
el.style.cssText = 'position:fixed;inset-inline:1rem;inset-block-end:1rem;max-inline-size:32rem;margin-inline:auto;padding:1rem 1.25rem;border-radius:var(--ql-radius,14px);background:var(--ql-accent,#2a3b8f);color:var(--ql-accent-fg,#fff);font:500 1rem/1.5 var(--ql-font,system-ui,sans-serif);z-index:2147483647;box-shadow:0 10px 30px rgb(0 0 0 / .25)';
|
|
115
|
+
applyTheme(el, theme);
|
|
111
116
|
(parent || document.body).appendChild(el);
|
|
112
117
|
setTimeout(() => el.remove(), ms);
|
|
113
118
|
return el;
|
|
@@ -124,7 +129,8 @@ export class QuranLabSharingSettings extends Base {
|
|
|
124
129
|
connectedCallback() { this.render(); }
|
|
125
130
|
render() {
|
|
126
131
|
if (!this._c) return;
|
|
127
|
-
const c = copyFor(this.getAttribute('lang') || this._c.lang);
|
|
132
|
+
const c = copyFor(this.getAttribute('lang') || this._c.lang, this._c.wording || null);
|
|
133
|
+
applyTheme(this, this._c.theme || null);
|
|
128
134
|
const t = c.t;
|
|
129
135
|
const root = this.shadowRoot || this.attachShadow({ mode: 'open' });
|
|
130
136
|
const on = this._c.state.sharing;
|
|
@@ -135,7 +141,7 @@ export class QuranLabSharingSettings extends Base {
|
|
|
135
141
|
<input type="checkbox" role="switch" id="s" ${on ? 'checked' : ''} aria-checked="${on}"></div>
|
|
136
142
|
${this._c.state.guardianVariant ? `<label class="guardian"><input type="checkbox" id="g"> <span>${esc(t('sheet.guardian.confirm'))}</span></label>` : ''}
|
|
137
143
|
<details><summary>${esc(t('settings.what'))}</summary><p>${esc(t('what.detail'))}</p></details>
|
|
138
|
-
${code ? `<details id="rc"><summary
|
|
144
|
+
${code ? `<details id="rc"><summary>${esc(t('recovery.reveal'))}</summary><p class="code" id="code"></p><p class="muted">${esc(t('recovery.keep'))}</p><p class="muted">${esc(t('recovery.hint'))}</p></details>` : ''}
|
|
139
145
|
<div class="row"><button class="secondary" id="del">${esc(t('settings.delete'))}</button></div>
|
|
140
146
|
<p class="muted" id="msg" role="status"></p>
|
|
141
147
|
</section>`;
|
|
@@ -151,7 +157,7 @@ export class QuranLabSharingSettings extends Base {
|
|
|
151
157
|
root.querySelector('#del').addEventListener('click', async () => {
|
|
152
158
|
if (!globalThis.confirm || globalThis.confirm(t('delete.confirm'))) {
|
|
153
159
|
const ok = await this._c.deleteShared().then(() => true, () => false);
|
|
154
|
-
msg.textContent = ok ? t('delete.done') : t('delete.
|
|
160
|
+
msg.textContent = ok ? t('delete.done') : t('delete.failed.network');
|
|
155
161
|
if (ok) this.render();
|
|
156
162
|
}
|
|
157
163
|
});
|
package/web/consent/index.d.ts
CHANGED
|
@@ -1,45 +1,107 @@
|
|
|
1
1
|
// Types for @quran-lab/tajweedformer/consent: the recitation sharing experience (SHARING_EXPERIENCE.md).
|
|
2
2
|
import type { QuranLab, StateStore } from '../platform/index.js';
|
|
3
3
|
|
|
4
|
+
/** 'first', or 'reask' after "Not now". 'version' is deprecated and never produced since 2.0.1: a grant is kept
|
|
5
|
+
* across decks and wordings and is never asked about again. */
|
|
4
6
|
export type AskVariant = 'first' | 'reask' | 'version';
|
|
5
7
|
export interface Ask { variant: AskVariant; guardian: boolean }
|
|
6
8
|
export interface Answer { yes: boolean; guardianConfirmed?: boolean }
|
|
7
9
|
|
|
10
|
+
/** sheets shown in all per install, ever (the first ask and at most two re-asks after "Not now") */
|
|
8
11
|
export declare const MAX_ASKS: 3;
|
|
12
|
+
/** sessions between asks */
|
|
9
13
|
export declare const REASK_GAP: 7;
|
|
14
|
+
/** the consent version a 2.0.0 grant stored without a version was given to (the deck 2.0.0 bundled) */
|
|
15
|
+
export declare const LEGACY_CONSENT_VERSION: 'share-2026-11-07';
|
|
16
|
+
/** the StateStore key of the sharing state (the same since 2.0.0) */
|
|
17
|
+
export declare const SHARING_STATE_KEY: 'sharing.v1';
|
|
10
18
|
|
|
11
19
|
export declare class SharingState {
|
|
12
|
-
constructor(state: StateStore, o?: { enabled?: () => boolean; consentVersion?: string; audience?: string; clock?: () => number });
|
|
13
|
-
/** uploads may run: allowed by the
|
|
20
|
+
constructor(state: StateStore, o?: { enabled?: () => boolean; consentVersion?: string; consentTextSha256?: string | null; audience?: string; clock?: () => number });
|
|
21
|
+
/** uploads may run: allowed by the signed config, and the learner said yes (to whichever version) */
|
|
14
22
|
readonly sharing: boolean;
|
|
15
23
|
readonly guardianVariant: boolean;
|
|
16
|
-
/**
|
|
24
|
+
/** asking has not ended: no yes, not turned off, fewer than MAX_ASKS sheets shown */
|
|
25
|
+
readonly asking: boolean;
|
|
26
|
+
/** the version a new grant records */
|
|
27
|
+
readonly consentVersion: string | null;
|
|
28
|
+
readonly consentTextSha256: string | null;
|
|
29
|
+
/** the version the current grant was given to, as recorded then (null when not granted) */
|
|
30
|
+
readonly grantVersion: string | null;
|
|
31
|
+
/** the hash of the own wording the current grant was given to (null for a deck's wording or when not sharing) */
|
|
32
|
+
readonly grantTextSha256: string | null;
|
|
33
|
+
/** the version a new grant records; an existing grant keeps its own and is not asked again */
|
|
34
|
+
setConsent(version: string, textSha256?: string | null): void;
|
|
35
|
+
/** a session ended; the ask due now (counted as shown), or null */
|
|
17
36
|
sessionCompleted(o?: { feedback?: boolean }): Ask | null;
|
|
18
|
-
|
|
37
|
+
/** the app's own timing: an ask counted as shown, or null once asking has ended */
|
|
38
|
+
askNow(): Ask | null;
|
|
19
39
|
answer(yes: boolean, o?: { guardianConfirmed?: boolean }): 'granted' | 'declined' | 'final';
|
|
20
40
|
setSharing(on: boolean, o?: { guardianConfirmed?: boolean }): void;
|
|
21
41
|
takeThanks(): boolean;
|
|
22
42
|
onChange(f: (s: object) => void): () => void;
|
|
23
43
|
}
|
|
24
44
|
|
|
25
|
-
/** A copy deck (quranlab.sharing-copy/1) for one language. */
|
|
45
|
+
/** A copy deck (quranlab.sharing-copy/1) for one language, with any own wording applied. */
|
|
26
46
|
export interface Deck {
|
|
27
47
|
lang: string; dir: 'ltr' | 'rtl'; version: string; consentVersion: string; deleteDays: number;
|
|
48
|
+
/** 'immediate' since deck 2026-11-09.1 */
|
|
49
|
+
deleteTiming: string;
|
|
50
|
+
/** the keys the app's own wording replaces */
|
|
51
|
+
overridden: string[];
|
|
28
52
|
hadithSource: { collection: string; number: number; url: string; translation_source: string | null; translated: boolean } | null;
|
|
29
|
-
/** a string by key
|
|
53
|
+
/** a string by key: the app's own wording, else this deck's, else English */
|
|
30
54
|
t(key: string): string;
|
|
31
55
|
/** the plural impact line for real shared minutes */
|
|
32
56
|
minutes(n: number): string;
|
|
33
57
|
}
|
|
34
58
|
export declare const EN: Readonly<{ format: string; version: string; lang: string; dir: string; consent_version: string; strings: Record<string, unknown> }>;
|
|
35
|
-
export declare function copyFor(lang?: string): Deck;
|
|
59
|
+
export declare function copyFor(lang?: string, overrides?: Record<string, string> | null): Deck;
|
|
60
|
+
|
|
61
|
+
/** An app's own wording ({deck key: text}); plural lines as `impact.minutes.<category>`. */
|
|
62
|
+
export type Wording = Record<string, string>;
|
|
63
|
+
/** The consent a grant is to: the deck's version, or custom-<24 hex> with the text's SHA-256 and canonical text. */
|
|
64
|
+
export interface Consent { version: string; textSha256: string | null; canonicalText: string | null }
|
|
65
|
+
export declare const CONSENT_TEXT_FORMAT: 'quranlab.consent-text/1';
|
|
66
|
+
export declare const PLURAL_CATEGORIES: readonly string[];
|
|
67
|
+
/** checks overrides; throws a TypeError for an unknown key or an empty or blank text */
|
|
68
|
+
export declare function validateWording(overrides: Wording | null | undefined): Readonly<Wording>;
|
|
69
|
+
export declare function canonicalConsentText(base: string, overrides: Wording | null): string | null;
|
|
70
|
+
export declare function deriveConsent(base: string, overrides: Wording | null): Consent;
|
|
71
|
+
export declare function wordingKeys(): Set<string>;
|
|
72
|
+
export declare function sha256HexSync(bytes: Uint8Array): string;
|
|
73
|
+
|
|
74
|
+
/** The look of the SDK's sheets (consent, guardian, thanks, settings): CSS values; a number cornerRadius is px. */
|
|
75
|
+
export interface SharingTheme {
|
|
76
|
+
accent?: string; accentText?: string; background?: string; text?: string; secondaryText?: string;
|
|
77
|
+
cornerRadius?: string | number; titleFont?: string; bodyFont?: string;
|
|
78
|
+
}
|
|
79
|
+
export declare const THEME_PROPERTIES: Readonly<Record<keyof SharingTheme, string>>;
|
|
80
|
+
export declare function themeProperties(theme: SharingTheme | null | undefined): Record<string, string>;
|
|
81
|
+
export declare function applyTheme<E extends { style: CSSStyleDeclaration }>(el: E, theme: SharingTheme | null | undefined): E;
|
|
82
|
+
|
|
83
|
+
/** What an app's own UI receives: show its own sheet with `copy`, then answer from the learner's tap. */
|
|
84
|
+
export interface OwnAsk extends Ask { lang: string; copy: Deck; consentVersion: string }
|
|
36
85
|
export declare function registerDeck(lang: string, deck: { format: 'quranlab.sharing-copy/1'; [k: string]: unknown }): void;
|
|
37
86
|
export declare function languages(): string[];
|
|
38
87
|
|
|
39
88
|
export interface SharingController {
|
|
40
89
|
lang: string;
|
|
41
90
|
state: SharingState;
|
|
42
|
-
|
|
91
|
+
/** the copy with the app's own wording applied (the same as copy) */
|
|
92
|
+
readonly deck: Deck;
|
|
93
|
+
readonly copy: Deck;
|
|
94
|
+
/** the consent a new grant records; send textSha256 or canonicalText to Quran Lab for a review of your wording */
|
|
95
|
+
readonly consent: Consent;
|
|
96
|
+
readonly wording: Readonly<Wording>;
|
|
97
|
+
readonly theme: SharingTheme | null;
|
|
98
|
+
/** replace deck strings (null or {} restores the deck); a TypeError for a bad key or text, nothing changes then */
|
|
99
|
+
setWording(wording: Wording | null): Consent;
|
|
100
|
+
setTheme(theme: SharingTheme | null): void;
|
|
101
|
+
/** the ask an app's own UI has not answered yet */
|
|
102
|
+
readonly pendingAsk: OwnAsk | null;
|
|
103
|
+
/** the learner's answer, from their own tap (default sheet and own UI); after a yes or a switch turned off it changes nothing */
|
|
104
|
+
answer(yes: boolean, o?: { guardianConfirmed?: boolean }): 'granted' | 'declined' | 'final';
|
|
43
105
|
sessionCompleted(o?: { feedback?: boolean }): Promise<Answer | null>;
|
|
44
106
|
setSharing(on: boolean, o?: { guardianConfirmed?: boolean }): void;
|
|
45
107
|
/** "Delete what I shared": signed with this install's device key (no app credential can delete) */
|
|
@@ -50,15 +112,16 @@ export interface SharingController {
|
|
|
50
112
|
impactLine(): Promise<string | null>;
|
|
51
113
|
}
|
|
52
114
|
|
|
53
|
-
/**
|
|
54
|
-
export declare function enableSharing(ql: QuranLab, o?: { lang?: string; ask?: 'auto' | ((a:
|
|
115
|
+
/** Wire the sharing experience for a configured client (the SDK also does it when a model attaches in a page). */
|
|
116
|
+
export declare function enableSharing(ql: QuranLab, o?: { lang?: string; ask?: 'auto' | ((a: OwnAsk) => Promise<Answer | null | void>); idle?: () => boolean;
|
|
117
|
+
wording?: Wording | null; theme?: SharingTheme | null }): SharingController;
|
|
55
118
|
export declare function deleteWithKey(ql: QuranLab, key: import('../platform/index.js').ContributorKey): Promise<{ accepted: boolean; sessionsLeft: number | null }>;
|
|
56
119
|
|
|
57
120
|
/** The web components (import '@quran-lab/tajweedformer/consent/element' or let enableSharing load them). */
|
|
58
121
|
export declare class QuranLabConsent extends HTMLElement {
|
|
59
|
-
static ask(o?: { lang?: string; variant?: AskVariant; guardian?: boolean; parent?: Element }): Promise<{ yes: boolean; guardianConfirmed: boolean }>;
|
|
60
|
-
static thank(o?: { lang?: string; parent?: Element; ms?: number }): HTMLElement;
|
|
61
|
-
show(o?: { lang?: string; variant?: AskVariant; guardian?: boolean }): Promise<{ yes: boolean; guardianConfirmed: boolean }>;
|
|
122
|
+
static ask(o?: { lang?: string; variant?: AskVariant; guardian?: boolean; parent?: Element; wording?: Wording | null; theme?: SharingTheme | null }): Promise<{ yes: boolean; guardianConfirmed: boolean }>;
|
|
123
|
+
static thank(o?: { lang?: string; parent?: Element; ms?: number; wording?: Wording | null; theme?: SharingTheme | null }): HTMLElement;
|
|
124
|
+
show(o?: { lang?: string; variant?: AskVariant; guardian?: boolean; wording?: Wording | null; theme?: SharingTheme | null }): Promise<{ yes: boolean; guardianConfirmed: boolean }>;
|
|
62
125
|
}
|
|
63
126
|
export declare class QuranLabSharingSettings extends HTMLElement {
|
|
64
127
|
controller: SharingController;
|
package/web/consent/index.js
CHANGED
|
@@ -5,45 +5,114 @@
|
|
|
5
5
|
//
|
|
6
6
|
// import { QuranLab } from '@quran-lab/tajweedformer/platform';
|
|
7
7
|
// import { enableSharing } from '@quran-lab/tajweedformer/consent';
|
|
8
|
-
// const ql = await QuranLab.configure({ key
|
|
9
|
-
// const sharing = enableSharing(ql, { lang: 'ar' }); // asks by itself after a session with feedback
|
|
8
|
+
// const ql = await QuranLab.configure({ key });
|
|
9
|
+
// const sharing = enableSharing(ql, { lang: 'ar' }); // asks by itself after a session with feedback, where
|
|
10
|
+
// // the signed config offers sharing (flags.contributions)
|
|
10
11
|
// document.querySelector('quranlab-sharing-settings').controller = sharing;
|
|
12
|
+
//
|
|
13
|
+
// Since 2.0.1 the sheets take a theme (`theme`, or CSS custom properties), an app can show its own UI
|
|
14
|
+
// (`ask: fn`, then the learner's answer through the return value or `sharing.answer(yes)`), and an app can
|
|
15
|
+
// replace strings with its own wording (`wording`, `sharing.setWording`): a learner who agrees then agrees to that
|
|
16
|
+
// text, under the version `custom-<24 hex>` derived from it, and each session sends its hash.
|
|
17
|
+
//
|
|
18
|
+
// The sheet is shown at most three times per install; a yes is kept, with the version the learner agreed to, across
|
|
19
|
+
// copy decks, wordings and SDK upgrades (also from 2.0.0), and is never asked about again.
|
|
11
20
|
|
|
12
21
|
import { SharingState } from './state.js';
|
|
13
22
|
import { copyFor, registerDeck, languages, EN } from './copy.js';
|
|
23
|
+
import { validateWording, deriveConsent } from './wording.js';
|
|
24
|
+
import { themeProperties } from './theme.js';
|
|
14
25
|
import { contributorKey, forgetContributorKey, deletionMessage, b64url, newRecoveryCode, recoveryHash } from '../platform/contributor-key.js';
|
|
26
|
+
import { SDK_VERSION } from '../platform/client.js';
|
|
15
27
|
|
|
16
|
-
export { SharingState, MAX_ASKS, REASK_GAP } from './state.js';
|
|
28
|
+
export { SharingState, MAX_ASKS, REASK_GAP, LEGACY_CONSENT_VERSION, SHARING_STATE_KEY } from './state.js';
|
|
17
29
|
export { copyFor, registerDeck, languages, EN } from './copy.js';
|
|
30
|
+
export { validateWording, deriveConsent, canonicalConsentText, wordingKeys, sha256HexSync, CONSENT_TEXT_FORMAT, PLURAL_CATEGORIES } from './wording.js';
|
|
31
|
+
export { THEME_PROPERTIES, themeProperties, applyTheme } from './theme.js';
|
|
18
32
|
|
|
19
33
|
/**
|
|
20
34
|
* Turn on the sharing experience for a configured platform client.
|
|
21
|
-
* @param {import('../platform/client.js').QuranLab} ql configured
|
|
35
|
+
* @param {import('../platform/client.js').QuranLab} ql a configured client (the signed config decides whether sharing is offered)
|
|
22
36
|
* @param {object} [o]
|
|
23
37
|
* @param {string} [o.lang] the deck's language (default: the browser's)
|
|
24
|
-
* @param {'auto'|((ask: object) => Promise<{yes: boolean, guardianConfirmed?: boolean}>)} [o.ask='auto']
|
|
38
|
+
* @param {'auto'|((ask: object) => Promise<{yes: boolean, guardianConfirmed?: boolean}|null|undefined>)} [o.ask='auto']
|
|
25
39
|
* auto: the SDK shows <quranlab-consent> when the app is idle after a session with feedback; a
|
|
26
|
-
* function: the app shows
|
|
40
|
+
* function: the app shows its own UI with the SDK's strings (`ask.copy`, the copy with any own wording)
|
|
41
|
+
* and resolves with the learner's answer, or resolves with nothing and calls `sharing.answer(yes)` from
|
|
42
|
+
* the learner's tap later. Counted and re-asked exactly like the SDK's own sheet (three asks at most).
|
|
27
43
|
* @param {(() => boolean)} [o.idle] whether the app is idle now (default: always)
|
|
44
|
+
* @param {Record<string, string>|null} [o.wording] the app's own wording, {deck key: text} (see setWording)
|
|
45
|
+
* @param {object|null} [o.theme] colours, corner radius and fonts of the SDK's sheets (THEME_PROPERTIES)
|
|
28
46
|
*/
|
|
29
47
|
export function enableSharing(ql, o = {}) {
|
|
30
48
|
const lang = o.lang || (globalThis.navigator && navigator.language) || 'en';
|
|
31
|
-
|
|
32
|
-
|
|
49
|
+
let wording = validateWording(o.wording);
|
|
50
|
+
let theme = o.theme || null;
|
|
51
|
+
themeProperties(theme); // refuses a bad theme now, not when a sheet opens
|
|
52
|
+
let deck = copyFor(lang, wording);
|
|
53
|
+
let consent = deriveConsent(deck.consentVersion, wording);
|
|
54
|
+
const state = new SharingState(ql.state, { enabled: () => ql.sharingAllowed(), consentVersion: consent.version,
|
|
55
|
+
consentTextSha256: consent.textSha256, audience: ql.audience });
|
|
56
|
+
const count = (field) => { try { if (ql.telemetry && typeof ql.telemetry.count === 'function') ql.telemetry.count(field); } catch (_) { /* never in the way */ } };
|
|
57
|
+
let pending = null; // the ask an app's own UI has not answered yet
|
|
33
58
|
const ctl = {
|
|
34
|
-
lang, state,
|
|
59
|
+
lang, state,
|
|
60
|
+
/** The copy the sheets show and an app's own UI should show: the deck with the app's own wording applied. */
|
|
61
|
+
get deck() { return deck; },
|
|
62
|
+
get copy() { return deck; },
|
|
63
|
+
/** The consent a new grant records: { version, textSha256, canonicalText } (the last two null for the deck's own
|
|
64
|
+
* wording). Send textSha256 or canonicalText to Quran Lab for a review of your wording. */
|
|
65
|
+
get consent() { return consent; },
|
|
66
|
+
get wording() { return wording; },
|
|
67
|
+
get theme() { return theme; },
|
|
68
|
+
/**
|
|
69
|
+
* Replace deck strings with the app's own wording ({key: text}; plural lines as `impact.minutes.one`, ...;
|
|
70
|
+
* null or {} goes back to the deck). An unknown key or an empty or blank text throws a TypeError and nothing
|
|
71
|
+
* changes. A learner who agrees from now on agrees to this wording (its custom version); a learner who already
|
|
72
|
+
* agreed keeps the version recorded then, keeps sharing and is not asked again.
|
|
73
|
+
*/
|
|
74
|
+
setWording(next) {
|
|
75
|
+
const w = validateWording(next);
|
|
76
|
+
wording = w;
|
|
77
|
+
deck = copyFor(lang, wording);
|
|
78
|
+
consent = deriveConsent(deck.consentVersion, wording);
|
|
79
|
+
state.setConsent(consent.version, consent.textSha256);
|
|
80
|
+
return consent;
|
|
81
|
+
},
|
|
82
|
+
/** The look of the SDK's sheets: { accent, accentText, background, text, secondaryText, cornerRadius, titleFont,
|
|
83
|
+
* bodyFont } (CSS values; null for the default). The words and the consent version do not change. */
|
|
84
|
+
setTheme(next) { themeProperties(next); theme = next || null; },
|
|
85
|
+
/** The ask an app's own UI is showing and has not answered (null when none). */
|
|
86
|
+
get pendingAsk() { return pending; },
|
|
87
|
+
/**
|
|
88
|
+
* The learner's answer to a consent ask, from the learner's own tap (the SDK's sheet calls it, and so does an
|
|
89
|
+
* app's own UI). yes: share under the wording shown; no: not now. Counted as share_yes or share_no. After a yes
|
|
90
|
+
* or the switch turned off it changes nothing (and counts nothing): the settings switch decides then.
|
|
91
|
+
*/
|
|
92
|
+
answer(yes, { guardianConfirmed = false } = {}) {
|
|
93
|
+
const was = state.s.consent;
|
|
94
|
+
const r = state.answer(!!yes, { guardianConfirmed: !!guardianConfirmed });
|
|
95
|
+
pending = null;
|
|
96
|
+
if (was !== 'granted' && was !== 'final') count(yes ? 'share_yes' : 'share_no');
|
|
97
|
+
return r;
|
|
98
|
+
},
|
|
35
99
|
/** The ask to show after a session (null when none is due); auto mode shows it. */
|
|
36
100
|
async sessionCompleted({ feedback = true } = {}) {
|
|
37
101
|
const ask = state.sessionCompleted({ feedback });
|
|
38
102
|
if (!ask) return null;
|
|
39
|
-
const
|
|
103
|
+
const own = typeof o.ask === 'function';
|
|
104
|
+
const show = own ? o.ask : async (a) => {
|
|
40
105
|
const { QuranLabConsent } = await import('./element.js');
|
|
41
|
-
return QuranLabConsent.ask({ lang, variant: a.variant, guardian: a.guardian });
|
|
106
|
+
return QuranLabConsent.ask({ lang, variant: a.variant, guardian: a.guardian, wording, theme });
|
|
42
107
|
};
|
|
43
108
|
const wait = () => new Promise((r) => { const tick = () => (!o.idle || o.idle() ? r() : setTimeout(tick, 1000)); tick(); });
|
|
44
109
|
await wait();
|
|
45
|
-
const
|
|
46
|
-
|
|
110
|
+
const full = { ...ask, lang, copy: deck, consentVersion: consent.version };
|
|
111
|
+
pending = full;
|
|
112
|
+
count('share_asked');
|
|
113
|
+
const a = await show(full);
|
|
114
|
+
if (!a || typeof a.yes !== 'boolean') return null; // the app's own UI answers later with ctl.answer()
|
|
115
|
+
if (pending === full) ctl.answer(a.yes, { guardianConfirmed: !!a.guardianConfirmed });
|
|
47
116
|
return a;
|
|
48
117
|
},
|
|
49
118
|
/** The settings switch: off stops uploads at once and deletes the unsent queue. */
|
|
@@ -75,7 +144,7 @@ export function enableSharing(ql, o = {}) {
|
|
|
75
144
|
async impactLine() {
|
|
76
145
|
const s = await ql.contributionSummary();
|
|
77
146
|
if (!s || !s.you || !s.you.minutes) return null;
|
|
78
|
-
return
|
|
147
|
+
return ctl.copy.minutes(Math.floor(s.you.minutes));
|
|
79
148
|
},
|
|
80
149
|
};
|
|
81
150
|
ql.sharing = ctl;
|
|
@@ -87,11 +156,27 @@ export function enableSharing(ql, o = {}) {
|
|
|
87
156
|
const key = await contributorKey(ql.contributor());
|
|
88
157
|
let code = ql.state.get('sharing.recovery');
|
|
89
158
|
if (!code) { code = newRecoveryCode(); ql.state.set('sharing.recovery', code); }
|
|
159
|
+
// 2.0.0 passed ql.token() (a string; the outbox wants {token, attested}, so every pass stopped at
|
|
160
|
+
// "no token"), the telemetry device ({class, os_major}; the session JSON wants {deviceClass,
|
|
161
|
+
// osMajor}, so every start threw "os_major undefined" and was reported as sharing_start:error),
|
|
162
|
+
// snake_case decode fields (dropped), a flags snapshot and no consent check in the outbox
|
|
163
|
+
const tokenProvider = async () => {
|
|
164
|
+
const token = await ql.token();
|
|
165
|
+
return { token, attested: !!(ql._token && ql._token.attested) };
|
|
166
|
+
};
|
|
167
|
+
tokenProvider.invalidate = async () => { ql._token = null; ql.state.set('token', null); };
|
|
168
|
+
const dev = ql.telemetry.device || {};
|
|
90
169
|
session = await mod.createSharingSession({
|
|
91
|
-
api: ql.apiUrl, fetch: ql.fetch, tokenProvider
|
|
170
|
+
api: ql.apiUrl, fetch: ql.fetch, tokenProvider, headers: ql.origin ? { origin: ql.origin } : {},
|
|
92
171
|
contributor: ql.contributor(), contributorKeyRaw: key.publicKeyRaw, recoveryHash: await recoveryHash(code), inputSampleRate: tf.sampleRate, inputRoute: 'unknown',
|
|
93
|
-
decodeInfo: {
|
|
94
|
-
|
|
172
|
+
decodeInfo: { modelRevision: ql.modelRevision || null, backend: 'exact', weightsSha256: null, sdkVersion: SDK_VERSION },
|
|
173
|
+
// the version the learner agreed to, as recorded then (not the deck or wording in force now) and, for an
|
|
174
|
+
// app's own wording, the hash of that text
|
|
175
|
+
consentVersion: state.grantVersion, consentTextSha256: state.grantTextSha256,
|
|
176
|
+
device: { deviceClass: dev.class || 'browser', osMajor: dev.os_major || 1 },
|
|
177
|
+
// read at every pass: the signed config in force now, and the learner's answer now (a withdrawal
|
|
178
|
+
// or a config that turned sharing off stops the next PUT, not only the next session)
|
|
179
|
+
flags: () => ql.flags, enabled: () => state.sharing,
|
|
95
180
|
...(ql.sharingBitrate ? { bitrate: ql.sharingBitrate } : {}),
|
|
96
181
|
});
|
|
97
182
|
ctl.outbox = mod.outbox || ctl.outbox;
|
|
@@ -105,7 +190,7 @@ export function enableSharing(ql, o = {}) {
|
|
|
105
190
|
};
|
|
106
191
|
s.finish = () => {
|
|
107
192
|
const k = finish();
|
|
108
|
-
if (starting) begin().then((x) => x && x.finish()).then(() => { if (state.takeThanks() && typeof o.ask !== 'function') import('./element.js').then((m) => m.QuranLabConsent.thank({ lang })); });
|
|
193
|
+
if (starting) begin().then((x) => x && x.finish()).then(() => { if (state.takeThanks() && typeof o.ask !== 'function') import('./element.js').then((m) => m.QuranLabConsent.thank({ lang, wording, theme })); });
|
|
109
194
|
starting = null;
|
|
110
195
|
return k;
|
|
111
196
|
};
|
package/web/consent/state.js
CHANGED
|
@@ -1,88 +1,150 @@
|
|
|
1
1
|
// The sharing state machine: when to ask,
|
|
2
2
|
// how often to re-ask, the guardian variant, settings, the thank-you. Plain JS, no DOM.
|
|
3
|
+
//
|
|
4
|
+
// Asking, since 2.0.1:
|
|
5
|
+
// - the sheet is shown at most MAX_ASKS times per install, ever (the first ask and, after "Not now", re-asks
|
|
6
|
+
// REASK_GAP sessions apart); the count is of asks handed out (the SDK's sheet or an app's own UI), stored, and
|
|
7
|
+
// carried over from 2.0.0's `asks`;
|
|
8
|
+
// - a yes ends asking for good: the grant keeps the consent version (and, for an app's own wording, the text hash)
|
|
9
|
+
// recorded when the learner agreed, across copy decks, wordings and SDK upgrades. There is no version re-ask
|
|
10
|
+
// and nothing pauses sharing when the wording in force changes;
|
|
11
|
+
// - turning sharing off ends asking for good; only the settings switch (setSharing) turns it on or off then.
|
|
12
|
+
//
|
|
13
|
+
// Stored under SHARING_STATE_KEY, the same object 2.0.0 stored: { v: 1, consent: 'unknown' | 'granted' |
|
|
14
|
+
// 'declined' | 'final', asks, lastAskSession, sessions, thanked, decidedAt, consentVersion, guardian } plus
|
|
15
|
+
// consentTextSha256 (2.0.1). 2.0.0's `versionAsked` is left as it is and no longer read.
|
|
3
16
|
|
|
4
17
|
export const SHARING_STATE_KEY = 'sharing.v1';
|
|
5
18
|
const FRESH = { v: 1, consent: 'unknown', asks: 0, lastAskSession: 0, sessions: 0, thanked: false, decidedAt: null,
|
|
6
|
-
consentVersion: null, guardian: false };
|
|
7
|
-
/**
|
|
19
|
+
consentVersion: null, consentTextSha256: null, guardian: false };
|
|
20
|
+
/** Sheets shown in all, ever: the first ask and at most two re-asks after "Not now". */
|
|
8
21
|
export const MAX_ASKS = 3;
|
|
9
22
|
/** Sessions between asks. */
|
|
10
23
|
export const REASK_GAP = 7;
|
|
24
|
+
/** The consent version of the copy deck the web SDK 2.0.0 bundled: a 2.0.0 grant stored without a version was
|
|
25
|
+
* given to it. */
|
|
26
|
+
export const LEGACY_CONSENT_VERSION = 'share-2026-11-07';
|
|
11
27
|
|
|
12
28
|
export class SharingState {
|
|
13
29
|
/**
|
|
14
30
|
* @param {{ get(k): any, set(k, v): void }} state persistence (per origin in browsers)
|
|
15
|
-
* @param {{ enabled: () => boolean, consentVersion?: string,
|
|
31
|
+
* @param {{ enabled: () => boolean, consentVersion?: string, consentTextSha256?: string|null, audience?: string,
|
|
32
|
+
* clock?: () => number }} o consentVersion: the version a new grant records (the deck's, or custom-...
|
|
33
|
+
* for an app's own wording, with its text hash)
|
|
16
34
|
*/
|
|
17
35
|
constructor(state, o = {}) {
|
|
18
36
|
this.state = state;
|
|
19
37
|
this.enabledFn = o.enabled || (() => false);
|
|
20
38
|
this.consentVersion = o.consentVersion || null;
|
|
39
|
+
this.consentTextSha256 = o.consentTextSha256 || null;
|
|
21
40
|
this.audience = o.audience || 'general';
|
|
22
41
|
this.clock = o.clock || Date.now;
|
|
23
42
|
this.listeners = new Set();
|
|
24
43
|
}
|
|
25
|
-
get s() {
|
|
44
|
+
get s() {
|
|
45
|
+
const s = { ...FRESH, ...(this.state.get(SHARING_STATE_KEY) || {}) };
|
|
46
|
+
if (!Number.isFinite(s.asks) || s.asks < 0) s.asks = 0;
|
|
47
|
+
// a 2.0.0 grant without a version was given to the deck 2.0.0 bundled
|
|
48
|
+
if (s.consent === 'granted' && !s.consentVersion) { s.consentVersion = LEGACY_CONSENT_VERSION; s.consentTextSha256 = null; }
|
|
49
|
+
return s;
|
|
50
|
+
}
|
|
26
51
|
_put(s) { this.state.set(SHARING_STATE_KEY, s); for (const f of this.listeners) f(s); }
|
|
27
52
|
onChange(f) { this.listeners.add(f); return () => this.listeners.delete(f); }
|
|
53
|
+
_today() { return new Date(this.clock()).toISOString().slice(0, 10); }
|
|
28
54
|
|
|
29
|
-
/** Uploads may run: enabled by the app and the signed config, and the learner said yes (
|
|
30
|
-
*
|
|
55
|
+
/** Uploads may run: enabled by the app and the signed config, and the learner said yes (to whichever
|
|
56
|
+
* version: a grant is never paused by a newer deck or wording). */
|
|
31
57
|
get sharing() {
|
|
32
|
-
|
|
33
|
-
return this.enabledFn() && s.consent === 'granted';
|
|
58
|
+
return this.enabledFn() && this.s.consent === 'granted';
|
|
34
59
|
}
|
|
35
60
|
get guardianVariant() { return this.audience === 'children'; }
|
|
36
61
|
|
|
62
|
+
/** Asking has not ended: no yes, not turned off, and fewer than MAX_ASKS sheets shown. */
|
|
63
|
+
get asking() {
|
|
64
|
+
const s = this.s;
|
|
65
|
+
return (s.consent === 'unknown' || s.consent === 'declined') && s.asks < MAX_ASKS;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The version a NEW grant records (an app changed its wording, or a new deck). A learner who already agreed
|
|
69
|
+
* keeps the version recorded then, keeps sharing and is not asked again. */
|
|
70
|
+
setConsent(version, textSha256 = null) {
|
|
71
|
+
this.consentVersion = version || null;
|
|
72
|
+
this.consentTextSha256 = textSha256 || null;
|
|
73
|
+
for (const f of this.listeners) f(this.s);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The consent version the current grant was given to (null when not granted): what each session of this
|
|
77
|
+
* grant sends as consent_version. */
|
|
78
|
+
get grantVersion() { const s = this.s; return s.consent === 'granted' ? s.consentVersion : null; }
|
|
79
|
+
|
|
80
|
+
/** The hash of the custom wording the current grant was given to (null for a deck's own wording, or when
|
|
81
|
+
* not sharing): what each session of this grant sends as consent_text_sha256. */
|
|
82
|
+
get grantTextSha256() {
|
|
83
|
+
if (!this.sharing) return null;
|
|
84
|
+
const s = this.s;
|
|
85
|
+
return s.consentVersion && s.consentVersion.startsWith('custom-') ? s.consentTextSha256 || null : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_ask(s) {
|
|
89
|
+
if (!(s.consent === 'unknown' || s.consent === 'declined') || s.asks >= MAX_ASKS) return null;
|
|
90
|
+
if (s.asks > 0 && s.sessions - s.lastAskSession < REASK_GAP) return null;
|
|
91
|
+
return s.consent === 'declined' ? 'reask' : 'first';
|
|
92
|
+
}
|
|
93
|
+
|
|
37
94
|
/**
|
|
38
|
-
* A session ended. Returns null, or the ask to show: { variant: 'first' | 'reask'
|
|
39
|
-
*
|
|
95
|
+
* A session ended. Returns null, or the ask to show: { variant: 'first' | 'reask', guardian: boolean }
|
|
96
|
+
* (counted as shown). The caller shows the sheet and calls answer().
|
|
40
97
|
* @param {{ feedback: boolean }} o feedback: the learner was shown the model's verdicts
|
|
41
98
|
*/
|
|
42
99
|
sessionCompleted({ feedback = false } = {}) {
|
|
43
100
|
const s = this.s;
|
|
44
101
|
s.sessions += 1;
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
if (s.consent === 'unknown') ask = 'first';
|
|
48
|
-
else if (s.consent === 'declined' && s.asks < MAX_ASKS && s.sessions - s.lastAskSession >= REASK_GAP) ask = 'reask';
|
|
49
|
-
else if (s.consent === 'granted' && this.consentVersion && s.consentVersion && s.consentVersion !== this.consentVersion && !s.versionAsked) {
|
|
50
|
-
ask = 'version'; s.versionAsked = this.consentVersion;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
if (ask) { s.asks += ask === 'version' ? 0 : 1; s.lastAskSession = s.sessions; }
|
|
102
|
+
const ask = this.enabledFn() && feedback ? this._ask(s) : null;
|
|
103
|
+
if (ask) { s.asks += 1; s.lastAskSession = s.sessions; }
|
|
54
104
|
this._put(s);
|
|
55
105
|
return ask ? { variant: ask, guardian: this.guardianVariant } : null;
|
|
56
106
|
}
|
|
57
107
|
|
|
58
|
-
/** The app asks now (its own timing
|
|
59
|
-
|
|
108
|
+
/** The app asks now (its own timing; the disclosure lines stay the SDK's). Counted as shown; null once asking
|
|
109
|
+
* has ended (a yes, sharing turned off, or MAX_ASKS sheets shown). The REASK_GAP spacing is the app's to keep. */
|
|
110
|
+
askNow() {
|
|
111
|
+
const s = this.s;
|
|
112
|
+
if (!(s.consent === 'unknown' || s.consent === 'declined') || s.asks >= MAX_ASKS) return null;
|
|
113
|
+
const variant = s.consent === 'declined' ? 'reask' : 'first';
|
|
114
|
+
s.asks += 1; s.lastAskSession = s.sessions;
|
|
115
|
+
this._put(s);
|
|
116
|
+
return { variant, guardian: this.guardianVariant };
|
|
117
|
+
}
|
|
60
118
|
|
|
61
119
|
/**
|
|
62
|
-
* The learner answered the sheet. yes: share; no (or dismissed): not now
|
|
63
|
-
* guardianConfirmed must be true for a yes in the guardian variant.
|
|
120
|
+
* The learner answered the sheet. yes: share, recording the version in force now; no (or dismissed): not now
|
|
121
|
+
* (final after the last ask). guardianConfirmed must be true for a yes in the guardian variant. After a yes or
|
|
122
|
+
* a switch turned off this changes nothing (the recorded grant stays as it is): use setSharing.
|
|
64
123
|
*/
|
|
65
124
|
answer(yes, { guardianConfirmed = false } = {}) {
|
|
66
125
|
const s = this.s;
|
|
126
|
+
if (s.consent === 'granted' || s.consent === 'final') return s.consent;
|
|
67
127
|
if (yes) {
|
|
68
128
|
if (this.guardianVariant && !guardianConfirmed) throw new Error('the guardian variant needs the parent or guardian confirmation');
|
|
69
|
-
Object.assign(s, { consent: 'granted', decidedAt:
|
|
70
|
-
guardian: !!guardianConfirmed });
|
|
129
|
+
Object.assign(s, { consent: 'granted', decidedAt: this._today(), consentVersion: this.consentVersion,
|
|
130
|
+
consentTextSha256: this.consentTextSha256, guardian: !!guardianConfirmed });
|
|
71
131
|
} else {
|
|
72
132
|
s.consent = s.asks >= MAX_ASKS ? 'final' : 'declined';
|
|
73
|
-
s.decidedAt =
|
|
133
|
+
s.decidedAt = this._today();
|
|
74
134
|
}
|
|
75
135
|
this._put(s);
|
|
76
136
|
return s.consent;
|
|
77
137
|
}
|
|
78
138
|
|
|
79
|
-
/** The settings switch.
|
|
139
|
+
/** The settings switch. On records the version in force now (an existing grant keeps its own). Off is final
|
|
140
|
+
* until the learner turns it on again here, and is never asked about again. */
|
|
80
141
|
setSharing(on, { guardianConfirmed = false } = {}) {
|
|
81
142
|
const s = this.s;
|
|
82
143
|
if (on) {
|
|
144
|
+
if (s.consent === 'granted') return;
|
|
83
145
|
if (this.guardianVariant && !guardianConfirmed) throw new Error('the guardian variant needs the parent or guardian confirmation');
|
|
84
|
-
Object.assign(s, { consent: 'granted', consentVersion: this.consentVersion,
|
|
85
|
-
decidedAt:
|
|
146
|
+
Object.assign(s, { consent: 'granted', consentVersion: this.consentVersion, consentTextSha256: this.consentTextSha256,
|
|
147
|
+
guardian: !!guardianConfirmed, decidedAt: this._today() });
|
|
86
148
|
} else {
|
|
87
149
|
s.consent = 'final';
|
|
88
150
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The look of the SDK's sharing sheets (consent, guardian, thanks, settings): the same theme keys as the Swift
|
|
2
|
+
// SDK's SharingTheme, set as CSS custom properties on each element (an app can also set the properties in its
|
|
3
|
+
// own CSS). A theme changes colours, corner radius and fonts, never the words or the consent version.
|
|
4
|
+
|
|
5
|
+
/** Theme key -> the CSS custom property it sets. */
|
|
6
|
+
export const THEME_PROPERTIES = Object.freeze({
|
|
7
|
+
accent: '--ql-accent', accentText: '--ql-accent-fg', background: '--ql-bg', text: '--ql-fg', secondaryText: '--ql-muted',
|
|
8
|
+
cornerRadius: '--ql-radius', titleFont: '--ql-title-font', bodyFont: '--ql-font',
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
/** A theme as CSS custom properties ({ '--ql-accent': '#2a3b8f', ... }); a number cornerRadius is in px. An
|
|
12
|
+
* unknown key or a value that is not a plain CSS value throws a TypeError; null or {} is the default look. */
|
|
13
|
+
export function themeProperties(theme) {
|
|
14
|
+
if (theme === null || theme === undefined) return {};
|
|
15
|
+
if (typeof theme !== 'object' || Array.isArray(theme)) throw new TypeError('theme: an object');
|
|
16
|
+
const out = {};
|
|
17
|
+
for (const [k, v] of Object.entries(theme)) {
|
|
18
|
+
if (!(k in THEME_PROPERTIES)) throw new TypeError(`theme: unknown key ${k} (${Object.keys(THEME_PROPERTIES).join(', ')})`);
|
|
19
|
+
if (v === null || v === undefined || v === '') continue;
|
|
20
|
+
if (k === 'cornerRadius' && typeof v === 'number') { out[THEME_PROPERTIES[k]] = `${v}px`; continue; }
|
|
21
|
+
if (typeof v !== 'string' || /[;{}<>]/.test(v)) throw new TypeError(`theme: ${k} must be a CSS value`);
|
|
22
|
+
out[THEME_PROPERTIES[k]] = v;
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Sets a theme's properties on an element (its shadow tree inherits them). */
|
|
28
|
+
export function applyTheme(el, theme) {
|
|
29
|
+
for (const [k, v] of Object.entries(themeProperties(theme))) el.style.setProperty(k, v);
|
|
30
|
+
return el;
|
|
31
|
+
}
|