react-cheminfo 0.52.0 → 0.53.0

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.
Files changed (82) hide show
  1. package/README.md +65 -31
  2. package/lib/i18n/core/chromeCatalog.d.ts +1 -1
  3. package/lib/i18n/core/chromeCatalog.d.ts.map +1 -1
  4. package/lib/language/ui/siteLanguageContext.d.ts.map +1 -1
  5. package/lib/language/ui/siteLanguageContext.js +2 -1
  6. package/lib/language/ui/siteLanguageContext.js.map +1 -1
  7. package/lib/locales/de.json +5 -0
  8. package/lib/locales/en.json +5 -0
  9. package/lib/locales/es.json +5 -0
  10. package/lib/locales/fr.json +5 -0
  11. package/lib/locales/it.json +5 -0
  12. package/lib/router/core/history.d.ts +4 -3
  13. package/lib/router/core/history.d.ts.map +1 -1
  14. package/lib/router/core/history.js +9 -13
  15. package/lib/router/core/history.js.map +1 -1
  16. package/lib/router/core/index.d.ts +2 -0
  17. package/lib/router/core/index.d.ts.map +1 -1
  18. package/lib/router/core/index.js +1 -0
  19. package/lib/router/core/index.js.map +1 -1
  20. package/lib/router/core/pageAddress.d.ts +61 -0
  21. package/lib/router/core/pageAddress.d.ts.map +1 -0
  22. package/lib/router/core/pageAddress.js +69 -0
  23. package/lib/router/core/pageAddress.js.map +1 -0
  24. package/lib/share/core/index.d.ts +4 -0
  25. package/lib/share/core/index.d.ts.map +1 -1
  26. package/lib/share/core/index.js +2 -0
  27. package/lib/share/core/index.js.map +1 -1
  28. package/lib/share/core/preferences.d.ts +2 -2
  29. package/lib/share/core/preferences.d.ts.map +1 -1
  30. package/lib/share/core/preferences.js +8 -3
  31. package/lib/share/core/preferences.js.map +1 -1
  32. package/lib/share/core/seal.d.ts +42 -0
  33. package/lib/share/core/seal.d.ts.map +1 -0
  34. package/lib/share/core/seal.js +185 -0
  35. package/lib/share/core/seal.js.map +1 -0
  36. package/lib/share/core/sealedAddress.d.ts +31 -0
  37. package/lib/share/core/sealedAddress.d.ts.map +1 -0
  38. package/lib/share/core/sealedAddress.js +38 -0
  39. package/lib/share/core/sealedAddress.js.map +1 -0
  40. package/lib/share/ui/SealedLinkRefused.d.ts +17 -0
  41. package/lib/share/ui/SealedLinkRefused.d.ts.map +1 -0
  42. package/lib/share/ui/SealedLinkRefused.js +23 -0
  43. package/lib/share/ui/SealedLinkRefused.js.map +1 -0
  44. package/lib/share/ui/ShareConfigOptions.d.ts +12 -1
  45. package/lib/share/ui/ShareConfigOptions.d.ts.map +1 -1
  46. package/lib/share/ui/ShareConfigOptions.js +4 -3
  47. package/lib/share/ui/ShareConfigOptions.js.map +1 -1
  48. package/lib/share/ui/ShareDialogContent.d.ts.map +1 -1
  49. package/lib/share/ui/ShareDialogContent.js +20 -6
  50. package/lib/share/ui/ShareDialogContent.js.map +1 -1
  51. package/lib/share/ui/ShareLinkBar.d.ts +5 -0
  52. package/lib/share/ui/ShareLinkBar.d.ts.map +1 -1
  53. package/lib/share/ui/ShareLinkBar.js +4 -4
  54. package/lib/share/ui/ShareLinkBar.js.map +1 -1
  55. package/lib/share/ui/index.d.ts +2 -0
  56. package/lib/share/ui/index.d.ts.map +1 -1
  57. package/lib/share/ui/index.js +1 -0
  58. package/lib/share/ui/index.js.map +1 -1
  59. package/lib/share/ui/useSealedUrl.d.ts +17 -0
  60. package/lib/share/ui/useSealedUrl.d.ts.map +1 -0
  61. package/lib/share/ui/useSealedUrl.js +31 -0
  62. package/lib/share/ui/useSealedUrl.js.map +1 -0
  63. package/package.json +1 -1
  64. package/src/language/ui/siteLanguageContext.ts +2 -1
  65. package/src/locales/de.json +5 -0
  66. package/src/locales/en.json +5 -0
  67. package/src/locales/es.json +5 -0
  68. package/src/locales/fr.json +5 -0
  69. package/src/locales/it.json +5 -0
  70. package/src/router/core/history.ts +9 -15
  71. package/src/router/core/index.ts +8 -0
  72. package/src/router/core/pageAddress.ts +110 -0
  73. package/src/share/core/index.ts +10 -0
  74. package/src/share/core/preferences.ts +10 -10
  75. package/src/share/core/seal.ts +233 -0
  76. package/src/share/core/sealedAddress.ts +48 -0
  77. package/src/share/ui/SealedLinkRefused.tsx +43 -0
  78. package/src/share/ui/ShareConfigOptions.tsx +23 -1
  79. package/src/share/ui/ShareDialogContent.tsx +37 -6
  80. package/src/share/ui/ShareLinkBar.tsx +9 -1
  81. package/src/share/ui/index.ts +2 -0
  82. package/src/share/ui/useSealedUrl.ts +40 -0
@@ -52,6 +52,16 @@ export {
52
52
  } from './preferences.ts';
53
53
  export type { SharePreset } from './presets.ts';
54
54
  export { applySharePreset, findSharePreset } from './presets.ts';
55
+ export type { SealProblem, UnsealResult } from './seal.ts';
56
+ export {
57
+ SEAL_PARAM,
58
+ sealQuery,
59
+ sealShareUrl,
60
+ sealedToken,
61
+ unsealQuery,
62
+ } from './seal.ts';
63
+ export type { SealedAddress } from './sealedAddress.ts';
64
+ export { canOpenSealedAddresses, openSealedAddress } from './sealedAddress.ts';
55
65
  export type { EmbedCodeOptions, ShareUrlOptions } from './url.ts';
56
66
  export { buildEmbedCode, buildShareUrl } from './url.ts';
57
67
  export type {
@@ -1,3 +1,5 @@
1
+ import { pageHistory, pageLocation } from '../../router/core/pageAddress.ts';
2
+
1
3
  import { EMBED_PARAM, HIDE_PARAM } from './config.ts';
2
4
  import type { ShareParamCodec } from './params.ts';
3
5
  import type { QueryEntry } from './query.ts';
@@ -56,12 +58,12 @@ export interface SyncPreferencesOptions {
56
58
  subscribe: (listener: () => void) => () => void;
57
59
  /**
58
60
  * The address to read and keep up to date.
59
- * @default globalThis.location
61
+ * @default pageLocation()
60
62
  */
61
63
  location?: PreferenceLocation;
62
64
  /**
63
65
  * The history to rewrite.
64
- * @default globalThis.history
66
+ * @default pageHistory()
65
67
  */
66
68
  history?: PreferenceHistory;
67
69
  }
@@ -157,16 +159,14 @@ export function writePreferencesToSearch(
157
159
  export function syncPreferencesWithUrl(
158
160
  options: SyncPreferencesOptions,
159
161
  ): () => void {
160
- const {
161
- preferences,
162
- subscribe,
163
- location = globalThis.location,
164
- history = globalThis.history,
165
- } = options;
162
+ const { preferences, subscribe } = options;
163
+ const location = options.location ?? pageLocation();
164
+ const history = options.history ?? pageHistory();
166
165
  assertPreferenceNames(preferences);
166
+ if (location === undefined || history === undefined) return () => undefined;
167
167
  applyPreferencesFromSearch(location.search, preferences);
168
168
  let active = true;
169
- function write(): void {
169
+ const write = (): void => {
170
170
  if (!active) return;
171
171
  const current = location.search;
172
172
  const next = writePreferencesToSearch(current, preferences);
@@ -177,7 +177,7 @@ export function syncPreferencesWithUrl(
177
177
  '',
178
178
  `${location.pathname}${query}${location.hash}`,
179
179
  );
180
- }
180
+ };
181
181
  const unsubscribe = subscribe(write);
182
182
  write();
183
183
  return () => {
@@ -0,0 +1,233 @@
1
+ import { EMBED_PARAM } from './config.ts';
2
+ import { firstValues, parseQuery, serializeQuery } from './query.ts';
3
+
4
+ /** The query parameter a sealed link carries, and the only one it carries. */
5
+ export const SEAL_PARAM = 's';
6
+
7
+ /** Why a sealed link could not be opened. */
8
+ export type SealProblem = 'malformed' | 'tampered' | 'unsupported';
9
+
10
+ /** What opening a sealed link gave. */
11
+ export type UnsealResult =
12
+ { ok: true; query: string } | { ok: false; problem: SealProblem };
13
+
14
+ // The key is in every page that opens a sealed link, so it is not a secret: the
15
+ // seal keeps a class from reading or editing a link, not someone who digs the
16
+ // key out of the bundle. The version prefix of a token picks its key, so a new
17
+ // key ships under a new version and every older link keeps opening.
18
+ const KEYRING: Readonly<Record<string, string>> = {
19
+ e1: 'MOCyEX25cEtWf2QJ6Ux_2lFxN_4oCwjtqyOkOC4tCXA',
20
+ };
21
+ const CURRENT_VERSION = 'e1';
22
+
23
+ // The tag is an HMAC of the message, and also the counter the message is
24
+ // encrypted under: the synthetic-IV construction. One 8-byte value both refuses
25
+ // an edited link and makes sealing deterministic, so the same query always
26
+ // gives the same link.
27
+ const TAG_LENGTH = 8;
28
+ const COUNTER_BITS = 64;
29
+ const COMPRESSED = 1;
30
+
31
+ interface SealKeys {
32
+ cipher: CryptoKey;
33
+ mac: CryptoKey;
34
+ }
35
+
36
+ const keys = new Map<string, Promise<SealKeys>>();
37
+
38
+ /**
39
+ * Seal a query string: compressed when that makes it shorter, then encrypted
40
+ * under a tag computed from it, so the token can be neither read nor edited
41
+ * without the key, and the same query always gives the same token.
42
+ * @param query - The query string the page is to read, without its leading `?`.
43
+ * @returns The token, `e1.` followed by base64url.
44
+ */
45
+ export async function sealQuery(query: string): Promise<string> {
46
+ const plain = new TextEncoder().encode(query);
47
+ const packed = await transform(plain, new CompressionStream('deflate-raw'));
48
+ const compressed = packed.length < plain.length;
49
+ const body = compressed ? packed : plain;
50
+ const message = new Uint8Array(1 + body.length);
51
+ message[0] = compressed ? COMPRESSED : 0;
52
+ message.set(body, 1);
53
+
54
+ const { cipher, mac } = await keysOf(CURRENT_VERSION);
55
+ const tag = await tagOf(mac, CURRENT_VERSION, message);
56
+ const encrypted = await crypto.subtle.encrypt(ctr(tag), cipher, message);
57
+ const sealed = new Uint8Array(TAG_LENGTH + encrypted.byteLength);
58
+ sealed.set(tag);
59
+ sealed.set(new Uint8Array(encrypted), TAG_LENGTH);
60
+ return `${CURRENT_VERSION}.${toBase64Url(sealed)}`;
61
+ }
62
+
63
+ /**
64
+ * Open a token written by {@link sealQuery}. Never throws: a token that was
65
+ * edited, cut, or minted by a newer key is answered with the reason.
66
+ * @param token - The value of the `s` parameter.
67
+ * @returns The query string it carries, or why it cannot be opened.
68
+ */
69
+ export async function unsealQuery(token: string): Promise<UnsealResult> {
70
+ const dot = token.indexOf('.');
71
+ if (dot === -1) return { ok: false, problem: 'malformed' };
72
+ const version = token.slice(0, dot);
73
+ if (!Object.hasOwn(KEYRING, version)) {
74
+ return { ok: false, problem: 'unsupported' };
75
+ }
76
+ const sealed = fromBase64Url(token.slice(dot + 1));
77
+ if (sealed === null || sealed.length <= TAG_LENGTH) {
78
+ return { ok: false, problem: 'malformed' };
79
+ }
80
+
81
+ const { cipher, mac } = await keysOf(version);
82
+ const tag = sealed.subarray(0, TAG_LENGTH);
83
+ const message = new Uint8Array(
84
+ await crypto.subtle.decrypt(ctr(tag), cipher, sealed.subarray(TAG_LENGTH)),
85
+ );
86
+ if (!sameBytes(tag, await tagOf(mac, version, message))) {
87
+ return { ok: false, problem: 'tampered' };
88
+ }
89
+
90
+ const flags = message[0] ?? 0;
91
+ if (flags > COMPRESSED) return { ok: false, problem: 'unsupported' };
92
+ const body = message.subarray(1);
93
+ try {
94
+ const plain =
95
+ flags === COMPRESSED
96
+ ? await transform(body, new DecompressionStream('deflate-raw'))
97
+ : body;
98
+ const query = new TextDecoder('utf-8', { fatal: true }).decode(plain);
99
+ return { ok: true, query };
100
+ } catch {
101
+ return { ok: false, problem: 'malformed' };
102
+ }
103
+ }
104
+
105
+ /**
106
+ * The sealed token a query string carries.
107
+ * @param search - The query string, with or without its leading `?`.
108
+ * @returns The value of `s`, or `null` when the address is not sealed.
109
+ */
110
+ export function sealedToken(search: string): string | null {
111
+ return firstValues(parseQuery(search)).get(SEAL_PARAM) ?? null;
112
+ }
113
+
114
+ /**
115
+ * Turn a share link into its sealed form: the same page, with everything its
116
+ * query string said moved into one `s` token. A sealed page is always
117
+ * embedded, so the token says `embed=1` whatever the link said.
118
+ * @param url - The absolute share link.
119
+ * @returns The sealed link.
120
+ */
121
+ export async function sealShareUrl(url: string): Promise<string> {
122
+ const address = new URL(url);
123
+ const entries = parseQuery(address.search).filter(
124
+ ([key]) => key !== EMBED_PARAM && key !== SEAL_PARAM,
125
+ );
126
+ entries.push([EMBED_PARAM, '1']);
127
+ const token = await sealQuery(serializeQuery(entries));
128
+ address.search = `?${SEAL_PARAM}=${token}`;
129
+ return address.href;
130
+ }
131
+
132
+ function keysOf(version: string): Promise<SealKeys> {
133
+ let pair = keys.get(version);
134
+ if (pair === undefined) {
135
+ pair = deriveKeys(version);
136
+ keys.set(version, pair);
137
+ }
138
+ return pair;
139
+ }
140
+
141
+ // One key per purpose, both derived from the version's key, since a key is
142
+ // never used for two algorithms.
143
+ async function deriveKeys(version: string): Promise<SealKeys> {
144
+ const raw = fromBase64Url(KEYRING[version] ?? '') ?? new Uint8Array();
145
+ const base = await crypto.subtle.importKey('raw', raw, 'HKDF', false, [
146
+ 'deriveKey',
147
+ ]);
148
+ function derive(
149
+ purpose: string,
150
+ algorithm: AesKeyGenParams | HmacImportParams,
151
+ usages: KeyUsage[],
152
+ ): Promise<CryptoKey> {
153
+ const info = new TextEncoder().encode(
154
+ `cheminfo seal ${version} ${purpose}`,
155
+ );
156
+ return crypto.subtle.deriveKey(
157
+ { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(), info },
158
+ base,
159
+ algorithm,
160
+ false,
161
+ usages,
162
+ );
163
+ }
164
+ const [cipher, mac] = await Promise.all([
165
+ derive('cipher', { name: 'AES-CTR', length: 256 }, ['encrypt', 'decrypt']),
166
+ derive('mac', { name: 'HMAC', hash: 'SHA-256', length: 256 }, ['sign']),
167
+ ]);
168
+ return { cipher, mac };
169
+ }
170
+
171
+ // The version is signed with the message, so a token cannot be moved under
172
+ // another version's key.
173
+ async function tagOf(
174
+ mac: CryptoKey,
175
+ version: string,
176
+ message: Uint8Array<ArrayBuffer>,
177
+ ): Promise<Uint8Array<ArrayBuffer>> {
178
+ const prefix = new TextEncoder().encode(`${version}.`);
179
+ const signed = new Uint8Array(prefix.length + message.length);
180
+ signed.set(prefix);
181
+ signed.set(message, prefix.length);
182
+ const digest = await crypto.subtle.sign('HMAC', mac, signed);
183
+ return new Uint8Array(digest, 0, TAG_LENGTH);
184
+ }
185
+
186
+ function ctr(tag: Uint8Array<ArrayBuffer>): AesCtrParams {
187
+ const counter = new Uint8Array(16);
188
+ counter.set(tag);
189
+ return { name: 'AES-CTR', counter, length: COUNTER_BITS };
190
+ }
191
+
192
+ function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
193
+ if (left.length !== right.length) return false;
194
+ let difference = 0;
195
+ for (let index = 0; index < left.length; index++) {
196
+ difference |= (left[index] ?? 0) ^ (right[index] ?? 0);
197
+ }
198
+ return difference === 0;
199
+ }
200
+
201
+ async function transform(
202
+ bytes: Uint8Array<ArrayBuffer>,
203
+ stream: CompressionStream | DecompressionStream,
204
+ ): Promise<Uint8Array<ArrayBuffer>> {
205
+ const source = new ReadableStream<Uint8Array<ArrayBuffer>>({
206
+ start(controller) {
207
+ controller.enqueue(bytes);
208
+ controller.close();
209
+ },
210
+ });
211
+ const output = await new Response(source.pipeThrough(stream)).arrayBuffer();
212
+ return new Uint8Array(output);
213
+ }
214
+
215
+ function toBase64Url(bytes: Uint8Array): string {
216
+ let binary = '';
217
+ for (const byte of bytes) binary += String.fromCodePoint(byte);
218
+ return btoa(binary)
219
+ .replaceAll('+', '-')
220
+ .replaceAll('/', '_')
221
+ .replace(/[=]+$/, '');
222
+ }
223
+
224
+ function fromBase64Url(text: string): Uint8Array<ArrayBuffer> | null {
225
+ if (!/^[\w-]*$/.test(text) || text.length % 4 === 1) return null;
226
+ const base64 = text.replaceAll('-', '+').replaceAll('_', '/');
227
+ const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '='));
228
+ const bytes = new Uint8Array(binary.length);
229
+ for (let index = 0; index < binary.length; index++) {
230
+ bytes[index] = binary.codePointAt(index) ?? 0;
231
+ }
232
+ return bytes;
233
+ }
@@ -0,0 +1,48 @@
1
+ import type { PageLocation } from '../../router/core/pageAddress.ts';
2
+ import { holdPageAddress } from '../../router/core/pageAddress.ts';
3
+
4
+ import type { SealProblem } from './seal.ts';
5
+ import { sealedToken, unsealQuery } from './seal.ts';
6
+
7
+ /** How the page was opened. */
8
+ export type SealedAddress =
9
+ | { status: 'plain' }
10
+ | { status: 'sealed' }
11
+ | { status: 'refused'; problem: SealProblem };
12
+
13
+ let opensSealedAddresses = false;
14
+
15
+ /**
16
+ * Open the page's address before anything reads it. A plain address is left
17
+ * as it is. A sealed one is unsealed and held in memory, so the page reads
18
+ * what the token carries through `pageLocation` while the address bar keeps
19
+ * showing the link that was handed out, whatever the page does next.
20
+ *
21
+ * Call it once at startup, and import every module that reads the address
22
+ * only once it has resolved. Calling it is also what lets the share dialog
23
+ * offer to protect a link: a site that never opens a sealed address must not
24
+ * hand one out.
25
+ * @param location - The address the page was opened at.
26
+ * @returns How the page was opened; on `refused` nothing is held.
27
+ */
28
+ export async function openSealedAddress(
29
+ location: PageLocation | undefined = globalThis.location,
30
+ ): Promise<SealedAddress> {
31
+ opensSealedAddresses = true;
32
+ if (location === undefined) return { status: 'plain' };
33
+ const token = sealedToken(location.search);
34
+ if (token === null) return { status: 'plain' };
35
+ const result = await unsealQuery(token);
36
+ if (!result.ok) return { status: 'refused', problem: result.problem };
37
+ const query = result.query === '' ? '' : `?${result.query}`;
38
+ holdPageAddress(`${location.pathname}${query}${location.hash}`);
39
+ return { status: 'sealed' };
40
+ }
41
+
42
+ /**
43
+ * Whether this page opens sealed links, so may hand them out.
44
+ * @returns True once {@link openSealedAddress} has been called.
45
+ */
46
+ export function canOpenSealedAddresses(): boolean {
47
+ return opensSealedAddresses;
48
+ }
@@ -0,0 +1,43 @@
1
+ import { AnchorButton, NonIdealState } from '@blueprintjs/core';
2
+ import type { CSSProperties, ReactElement } from 'react';
3
+
4
+ import { useChromeT } from '../../i18n/ui/useT.ts';
5
+
6
+ // A grid, because the card's own `height: 100%` keeps a flex row from
7
+ // stretching it, and it would sit at the top of the page.
8
+ const PAGE_STYLE: CSSProperties = {
9
+ display: 'grid',
10
+ minHeight: '100vh',
11
+ placeItems: 'center',
12
+ };
13
+
14
+ export interface SealedLinkRefusedProps {
15
+ /**
16
+ * Where the tool opens without the link.
17
+ * @default the path the link was opened at
18
+ */
19
+ href?: string;
20
+ }
21
+
22
+ /**
23
+ * What a page shows instead of itself when its sealed link cannot be opened —
24
+ * edited, cut, or minted by a newer key — so it never opens something the link
25
+ * did not say, and never a blank page.
26
+ * @param props - Where the tool opens without the link.
27
+ * @returns The whole page.
28
+ */
29
+ export function SealedLinkRefused(props: SealedLinkRefusedProps): ReactElement {
30
+ const { href = globalThis.location?.pathname ?? '/' } = props;
31
+ const t = useChromeT();
32
+
33
+ return (
34
+ <div style={PAGE_STYLE}>
35
+ <NonIdealState
36
+ icon="lock"
37
+ title={t('seal.refusedTitle')}
38
+ description={t('seal.refusedBody')}
39
+ action={<AnchorButton href={href} text={t('seal.openWithout')} />}
40
+ />
41
+ </div>
42
+ );
43
+ }
@@ -21,6 +21,16 @@ export interface ShareConfigOptionsProps {
21
21
  descriptions: SharePartDescriptions;
22
22
  /** Called when the layout is switched. */
23
23
  onEmbedChange: (embed: boolean) => void;
24
+ /**
25
+ * Whether the link is handed out sealed.
26
+ * @default false
27
+ */
28
+ sealed?: boolean;
29
+ /**
30
+ * Called when sealing is switched. Without it the switch is not offered.
31
+ * @default undefined
32
+ */
33
+ onSealedChange?: (sealed: boolean) => void;
24
34
  /** Called with the part and whether the link should switch it off. */
25
35
  onPartChange: (part: string, hidden: boolean) => void;
26
36
  /** Called with the part the pointer is on, or `null` when it leaves. */
@@ -28,7 +38,8 @@ export interface ShareConfigOptionsProps {
28
38
  }
29
39
 
30
40
  /**
31
- * The switches of the share dialog: the layout, then one per part of the page.
41
+ * The switches of the share dialog: the layout — embedded, and sealed where the
42
+ * site opens sealed links — then one per part of the page.
32
43
  * @param props - The draft's layout and parts, and how to change them.
33
44
  * @returns The two sections.
34
45
  */
@@ -41,6 +52,8 @@ export function ShareConfigOptions(
41
52
  hidden,
42
53
  descriptions,
43
54
  onEmbedChange,
55
+ sealed = false,
56
+ onSealedChange,
44
57
  onPartChange,
45
58
  onPartPointed,
46
59
  } = props;
@@ -57,6 +70,15 @@ export function ShareConfigOptions(
57
70
  descriptions={descriptions}
58
71
  onChange={onEmbedChange}
59
72
  />
73
+ {onSealedChange === undefined ? null : (
74
+ <SharePartRow
75
+ label={t('share.protect')}
76
+ description={t('share.protectHint')}
77
+ on={sealed}
78
+ descriptions={descriptions}
79
+ onChange={onSealedChange}
80
+ />
81
+ )}
60
82
  </section>
61
83
 
62
84
  {parts.length > 0 ? (
@@ -15,6 +15,7 @@ import {
15
15
  applySharePreset,
16
16
  buildEmbedCode,
17
17
  buildShareUrl,
18
+ canOpenSealedAddresses,
18
19
  findSharePreset,
19
20
  isShareConfigured,
20
21
  parseShareConfig,
@@ -29,6 +30,7 @@ import { SharePanes } from './SharePanes.tsx';
29
30
  import { SharePresetTabs } from './SharePresetTabs.tsx';
30
31
  import { SharePreview } from './SharePreview.tsx';
31
32
  import { withPart } from './draft.ts';
33
+ import { useSealedUrl } from './useSealedUrl.ts';
32
34
 
33
35
  const NO_PRESETS: readonly never[] = [];
34
36
 
@@ -73,6 +75,7 @@ export function ShareDialogContent<
73
75
  const [config, setConfig] = useState<ShareConfig<Codecs>>(() =>
74
76
  initialDraft(query, vocabulary, findPreset(presets, defaultPreset ?? null)),
75
77
  );
78
+ const [sealed, setSealed] = useState(false);
76
79
  const [pointed, setPointed] = useState<string | null>(null);
77
80
  const [presetKey, setPresetKey] = useState<string | null>(
78
81
  () => findSharePreset(config, presets, vocabulary)?.key ?? null,
@@ -86,6 +89,8 @@ export function ShareDialogContent<
86
89
 
87
90
  function setEmbed(embed: boolean): void {
88
91
  setConfig((previous) => ({ ...previous, embed }));
92
+ // A sealed page is always embedded, so leaving the frame drops the seal.
93
+ setSealed((previous) => previous && embed);
89
94
  }
90
95
 
91
96
  function setPartHidden(part: string, hidden: boolean): void {
@@ -118,19 +123,23 @@ export function ShareDialogContent<
118
123
  setPartHidden,
119
124
  setParam,
120
125
  };
121
- const url = buildShareUrl({ base, search: query, config, vocabulary });
126
+ const shown = embeddedWhenSealed(config, sealed);
127
+ const url = buildShareUrl({ base, search: query, config: shown, vocabulary });
128
+ const { link, ready } = useSealedUrl(url, sealed);
122
129
  const frame = buildEmbedCode({
123
- url,
130
+ url: link,
124
131
  title: frameTitle ?? title,
125
132
  height: frameHeight,
126
133
  });
127
134
  const options = (
128
135
  <ShareConfigOptions
129
- embed={config.embed}
130
- parts={visibleShareParts(vocabulary.parts, config.embed)}
136
+ embed={shown.embed}
137
+ parts={visibleShareParts(vocabulary.parts, shown.embed)}
131
138
  hidden={config.hidden}
132
139
  descriptions={partDescriptions}
133
140
  onEmbedChange={setEmbed}
141
+ sealed={sealed}
142
+ onSealedChange={offeredWhenOpened(setSealed)}
134
143
  onPartChange={setPartHidden}
135
144
  onPartPointed={setPointed}
136
145
  />
@@ -155,7 +164,7 @@ export function ShareDialogContent<
155
164
 
156
165
  const previewPane = preview ? (
157
166
  <SharePreview
158
- url={url}
167
+ url={link}
159
168
  title={frameTitle ?? title}
160
169
  highlight={highlightOf(vocabulary.parts, pointed)}
161
170
  />
@@ -181,12 +190,34 @@ export function ShareDialogContent<
181
190
  />
182
191
  </div>
183
192
  <DialogFooter
184
- actions={<ShareLinkBar url={url} frame={frame} onCopied={onClose} />}
193
+ actions={
194
+ <ShareLinkBar
195
+ url={link}
196
+ frame={frame}
197
+ disabled={!ready}
198
+ onCopied={onClose}
199
+ />
200
+ }
185
201
  />
186
202
  </>
187
203
  );
188
204
  }
189
205
 
206
+ // A sealed page is always embedded, whatever a preset said.
207
+ function embeddedWhenSealed<Codecs extends ShareParamCodecs>(
208
+ config: ShareConfig<Codecs>,
209
+ sealed: boolean,
210
+ ): ShareConfig<Codecs> {
211
+ return sealed ? { ...config, embed: true } : config;
212
+ }
213
+
214
+ // A site that never opens a sealed address must not hand one out.
215
+ function offeredWhenOpened(
216
+ setSealed: (sealed: boolean) => void,
217
+ ): ((sealed: boolean) => void) | undefined {
218
+ return canOpenSealedAddresses() ? setSealed : undefined;
219
+ }
220
+
190
221
  function initialDraft<Codecs extends ShareParamCodecs>(
191
222
  search: string,
192
223
  vocabulary: ShareVocabulary<Codecs>,
@@ -15,6 +15,11 @@ export interface ShareLinkBarProps {
15
15
  * @default undefined
16
16
  */
17
17
  onCopied?: () => void;
18
+ /**
19
+ * Whether the link is not ready to hand out yet, as while it is being sealed.
20
+ * @default false
21
+ */
22
+ disabled?: boolean;
18
23
  }
19
24
 
20
25
  /**
@@ -31,7 +36,7 @@ export interface ShareLinkBarProps {
31
36
  * @returns The bar.
32
37
  */
33
38
  export function ShareLinkBar(props: ShareLinkBarProps): ReactElement {
34
- const { url, frame, onCopied } = props;
39
+ const { url, frame, onCopied, disabled = false } = props;
35
40
  const t = useChromeT();
36
41
 
37
42
  return (
@@ -46,6 +51,7 @@ export function ShareLinkBar(props: ShareLinkBarProps): ReactElement {
46
51
  content={url}
47
52
  label={t('share.copyLink')}
48
53
  title=""
54
+ disabled={disabled}
49
55
  onCopied={onCopied}
50
56
  />
51
57
  </HelpTooltip>
@@ -59,6 +65,7 @@ export function ShareLinkBar(props: ShareLinkBarProps): ReactElement {
59
65
  icon="share"
60
66
  text={t('share.openInNewTab')}
61
67
  href={url}
68
+ disabled={disabled}
62
69
  target="_blank"
63
70
  rel="noopener noreferrer"
64
71
  />
@@ -74,6 +81,7 @@ export function ShareLinkBar(props: ShareLinkBarProps): ReactElement {
74
81
  icon="code"
75
82
  label={t('share.copyIframe')}
76
83
  title=""
84
+ disabled={disabled}
77
85
  onCopied={onCopied}
78
86
  />
79
87
  </HelpTooltip>
@@ -3,6 +3,8 @@ export { HiddenPartsProvider } from './HiddenPartsProvider.tsx';
3
3
  export { useIsHidden } from './hiddenParts.ts';
4
4
  export type { PagePartProps } from './PagePart.tsx';
5
5
  export { PagePart } from './PagePart.tsx';
6
+ export type { SealedLinkRefusedProps } from './SealedLinkRefused.tsx';
7
+ export { SealedLinkRefused } from './SealedLinkRefused.tsx';
6
8
  export type { ShareButtonProps, ShareButtonVariant } from './ShareButton.tsx';
7
9
  export { ShareButton } from './ShareButton.tsx';
8
10
  export type { ShareDialogProps } from './ShareDialog.tsx';
@@ -0,0 +1,40 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ import { sealShareUrl } from '../core/index.ts';
4
+
5
+ /** The link the dialog hands out, and whether it may be handed out yet. */
6
+ export interface SealedUrl {
7
+ /** The link: sealed when asked and ready, else the share link itself. */
8
+ link: string;
9
+ /** False while the link is being sealed, when only the plain one exists. */
10
+ ready: boolean;
11
+ }
12
+
13
+ /**
14
+ * The link the dialog hands out: the share link itself, or its sealed form.
15
+ * Sealing is asynchronous, so a sealed link is not ready at once, and a link
16
+ * sealed for an older draft is never answered for the current one.
17
+ * @param url - The share link.
18
+ * @param sealed - Whether to hand out its sealed form.
19
+ * @returns The link, and whether it is ready.
20
+ */
21
+ export function useSealedUrl(url: string, sealed: boolean): SealedUrl {
22
+ const [result, setResult] = useState<{ from: string; to: string } | null>(
23
+ null,
24
+ );
25
+
26
+ useEffect(() => {
27
+ if (!sealed) return;
28
+ let current = true;
29
+ void sealShareUrl(url).then((to) => {
30
+ if (current) setResult({ from: url, to });
31
+ });
32
+ return () => {
33
+ current = false;
34
+ };
35
+ }, [url, sealed]);
36
+
37
+ if (!sealed) return { link: url, ready: true };
38
+ if (result?.from === url) return { link: result.to, ready: true };
39
+ return { link: url, ready: false };
40
+ }