@flamingo-stack/openframe-frontend-core 0.0.854 → 0.0.855

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 (133) hide show
  1. package/dist/build/export-map.json +1 -1
  2. package/dist/{case-study-card-WP5Z234Q.cjs → case-study-card-6VAMNHLU.cjs} +8 -8
  3. package/dist/{chunk-FBANHIAG.cjs → chunk-2JCA7BRK.cjs} +19 -19
  4. package/dist/{chunk-7MOBD44H.cjs → chunk-2MCPK6AX.cjs} +28 -28
  5. package/dist/{chunk-GVHCQRUE.cjs → chunk-2P72KAG5.cjs} +23 -23
  6. package/dist/{chunk-YY3WP2YQ.cjs → chunk-3GJ75OUL.cjs} +183 -183
  7. package/dist/{chunk-FJXSKMQJ.cjs → chunk-4H3J57AN.cjs} +25 -25
  8. package/dist/{chunk-6GWRPW6B.cjs → chunk-5NK3FUHY.cjs} +4 -4
  9. package/dist/{chunk-DLTPSP5F.cjs → chunk-5TYG5JNB.cjs} +2 -2
  10. package/dist/{chunk-63ZV3UGG.cjs → chunk-65NMKAPQ.cjs} +12 -12
  11. package/dist/{chunk-IGVAPIEW.cjs → chunk-6C4OR4MZ.cjs} +2 -2
  12. package/dist/{chunk-GG466XFQ.cjs → chunk-6DMH4GXN.cjs} +30 -5
  13. package/dist/{chunk-S5P6CF5R.cjs → chunk-6VVQJU76.cjs} +54 -54
  14. package/dist/{chunk-2IJMHBOK.cjs → chunk-77GX573I.cjs} +9 -9
  15. package/dist/{chunk-D2XGRWPW.cjs → chunk-AOOHJLXS.cjs} +90 -90
  16. package/dist/{chunk-O3EKOXFZ.cjs → chunk-AYMY2VNB.cjs} +37 -43
  17. package/dist/{chunk-RMI6O3AE.cjs → chunk-BSPIGZCC.cjs} +36 -36
  18. package/dist/{chunk-JKBNK5U6.cjs → chunk-D6GEMVHD.cjs} +3 -3
  19. package/dist/{chunk-7J5NSWJG.cjs → chunk-DPNFI5XM.cjs} +100 -100
  20. package/dist/{chunk-TMPLHUUU.cjs → chunk-DTZ6KASI.cjs} +6 -6
  21. package/dist/{chunk-SSKMGH5O.cjs → chunk-DXVGGWLG.cjs} +10 -10
  22. package/dist/{chunk-5TXE7BXT.cjs → chunk-EOUEUYCQ.cjs} +54 -54
  23. package/dist/{chunk-EIHMPQRQ.cjs → chunk-ESGE7CUX.cjs} +14 -14
  24. package/dist/{chunk-ETZFBB7O.cjs → chunk-EXQSIX2I.cjs} +4 -4
  25. package/dist/{chunk-3WXHPD6N.cjs → chunk-F33EAPD2.cjs} +124 -124
  26. package/dist/{chunk-4SSJ3T3N.cjs → chunk-GK577ZLJ.cjs} +3 -13
  27. package/dist/{chunk-BAA3YCLT.cjs → chunk-GZYRDLJW.cjs} +103 -103
  28. package/dist/{chunk-VMAOZA2V.cjs → chunk-HML3JUXF.cjs} +324 -322
  29. package/dist/{chunk-4O5N5CGK.cjs → chunk-J3CFRGZ2.cjs} +17 -17
  30. package/dist/{chunk-LQDAB5V5.cjs → chunk-KZAQPRTA.cjs} +9 -11
  31. package/dist/{chunk-6DCUGIGU.cjs → chunk-LSLRJJFP.cjs} +2 -2
  32. package/dist/{chunk-ETQNMR2K.cjs → chunk-MINFN3IG.cjs} +8 -8
  33. package/dist/{chunk-ONYANAMA.cjs → chunk-MX52LZ6M.cjs} +6 -6
  34. package/dist/{chunk-OH5TXFRR.cjs → chunk-O4CPT3RK.cjs} +7 -7
  35. package/dist/{chunk-ZDPPNHA2.cjs → chunk-OTHQ2KQW.cjs} +3 -3
  36. package/dist/{chunk-5HMENXSR.cjs → chunk-PCQ2HKU3.cjs} +5 -5
  37. package/dist/{chunk-6XU3RCK3.cjs → chunk-QPHXYE5P.cjs} +27 -27
  38. package/dist/{chunk-UPQ4MAR7.cjs → chunk-QZXCUY36.cjs} +53 -53
  39. package/dist/{chunk-5OENPMRU.cjs → chunk-RIJU4HZ2.cjs} +51 -55
  40. package/dist/{chunk-KKFDRCQF.cjs → chunk-RY7INTYU.cjs} +14 -1
  41. package/dist/{chunk-GI3MMBP3.cjs → chunk-SQ7YHWEP.cjs} +228 -15
  42. package/dist/{chunk-ACPOJE32.cjs → chunk-STV6VJ3U.cjs} +21 -21
  43. package/dist/{chunk-QD727QFI.cjs → chunk-T54I5ZZY.cjs} +7 -7
  44. package/dist/{chunk-4LTLEUY5.cjs → chunk-TW5Y4D5I.cjs} +7 -7
  45. package/dist/{chunk-FX35L4Y6.cjs → chunk-XCQ5I27Z.cjs} +3 -3
  46. package/dist/{chunk-AZEO7HBB.cjs → chunk-Y44EF4JW.cjs} +2 -2
  47. package/dist/{chunk-KA3L6D5N.cjs → chunk-YSXVRTGT.cjs} +9 -12
  48. package/dist/{chunk-PRJZMZ3X.cjs → chunk-ZUGJLCYP.cjs} +678 -678
  49. package/dist/{chunk-H3GBPYPS.cjs → chunk-ZYAVIRQV.cjs} +25 -25
  50. package/dist/{cloud-tenants-5RNK4ZAT.cjs → cloud-tenants-2XFIDDGU.cjs} +28 -29
  51. package/dist/components/case-studies/index.cjs +44 -45
  52. package/dist/components/chat/chat-message-row.d.ts +1 -1
  53. package/dist/components/chat/chat-message-row.d.ts.map +1 -1
  54. package/dist/components/chat/chat-message-row.js +4 -4
  55. package/dist/components/chat/entity-cards/use-cover-image-fallback.d.ts +7 -13
  56. package/dist/components/chat/entity-cards/use-cover-image-fallback.d.ts.map +1 -1
  57. package/dist/components/chat/entity-cards/use-cover-image-fallback.js +2 -8
  58. package/dist/components/chat/hooks/use-chat-attachment-image-gallery.d.ts.map +1 -1
  59. package/dist/components/chat/hooks/use-chat-attachment-image-gallery.js +8 -4
  60. package/dist/components/chat/index.cjs +22 -23
  61. package/dist/components/contact/index.cjs +29 -30
  62. package/dist/components/docs/index.cjs +32 -33
  63. package/dist/components/embeds/index.cjs +29 -30
  64. package/dist/components/faq/index.cjs +30 -31
  65. package/dist/components/features/index.cjs +28 -29
  66. package/dist/components/help-center-pages/index.cjs +134 -135
  67. package/dist/components/index.cjs +112 -113
  68. package/dist/components/meeting-scheduler/index.cjs +102 -103
  69. package/dist/components/navigation/index.cjs +31 -32
  70. package/dist/components/onboarding-guides/index.cjs +31 -32
  71. package/dist/components/product-demo/index.cjs +39 -40
  72. package/dist/components/related-content/index.cjs +16 -17
  73. package/dist/components/tickets/index.cjs +35 -36
  74. package/dist/components/ui/entity-image.d.ts +1 -2
  75. package/dist/components/ui/entity-image.d.ts.map +1 -1
  76. package/dist/components/ui/entity-image.js +4 -8
  77. package/dist/components/ui/index.cjs +28 -29
  78. package/dist/components/ui/markdown/base-components.d.ts +0 -13
  79. package/dist/components/ui/markdown/base-components.d.ts.map +1 -1
  80. package/dist/components/ui/markdown/base-components.js +7 -7
  81. package/dist/components/ui/square-avatar.d.ts.map +1 -1
  82. package/dist/components/ui/square-avatar.js +3 -8
  83. package/dist/{devices-44O5PZOW.cjs → devices-O3DYGIZB.cjs} +29 -30
  84. package/dist/embed-shims/index.cjs +3 -2
  85. package/dist/embed-shims/next-image.cjs +3 -2
  86. package/dist/embed-shims/next-image.d.ts +2 -0
  87. package/dist/embed-shims/next-image.d.ts.map +1 -1
  88. package/dist/embed-shims/next-image.js +14 -4
  89. package/dist/{embeddable-chat-W3CLMGC3.cjs → embeddable-chat-QD7NFD2H.cjs} +100 -101
  90. package/dist/hooks/index.cjs +23 -16
  91. package/dist/hooks/index.d.ts +1 -0
  92. package/dist/hooks/index.d.ts.map +1 -1
  93. package/dist/hooks/index.js +1 -0
  94. package/dist/hooks/use-authed-asset-src.d.ts +77 -2
  95. package/dist/hooks/use-authed-asset-src.d.ts.map +1 -1
  96. package/dist/hooks/use-authed-asset-src.js +185 -73
  97. package/dist/hooks/use-authed-image-src.d.ts +11 -4
  98. package/dist/hooks/use-authed-image-src.d.ts.map +1 -1
  99. package/dist/hooks/use-authed-image-src.js +6 -2
  100. package/dist/hooks/use-image-fallback.d.ts +16 -0
  101. package/dist/hooks/use-image-fallback.d.ts.map +1 -0
  102. package/dist/hooks/use-image-fallback.js +14 -0
  103. package/dist/index.cjs +40 -36
  104. package/dist/{knowledge-UUU3WVO3.cjs → knowledge-K7AFNZNO.cjs} +29 -30
  105. package/dist/{logs-DFHNSODA.cjs → logs-MZ3BR2FR.cjs} +35 -36
  106. package/dist/{policies-RGFUGDBP.cjs → policies-XVKQ2W7S.cjs} +32 -33
  107. package/dist/{remote-session-U3H77HKR.cjs → remote-session-VOZJXDM4.cjs} +13 -14
  108. package/dist/{scripts-KOVO5DHW.cjs → scripts-IBPMBS6G.cjs} +35 -36
  109. package/dist/{software-update-4EKHVV5W.cjs → software-update-PXTANMAO.cjs} +32 -33
  110. package/dist/{tickets-board-YIFOVMDX.cjs → tickets-board-POYZS7BW.cjs} +16 -17
  111. package/dist/utils/app-origins.d.ts +3 -6
  112. package/dist/utils/app-origins.d.ts.map +1 -1
  113. package/dist/utils/app-origins.js +8 -1
  114. package/dist/utils/embed-authed-fetch.d.ts +46 -26
  115. package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
  116. package/dist/utils/embed-authed-fetch.js +24 -8
  117. package/dist/utils/index.cjs +4 -1
  118. package/dist/{video-file-player-XMOB234V.cjs → video-file-player-RIOT24NT.cjs} +3 -4
  119. package/package.json +1 -1
  120. package/src/components/chat/chat-message-row.tsx +8 -7
  121. package/src/components/chat/entity-cards/use-cover-image-fallback.ts +8 -28
  122. package/src/components/chat/hooks/use-chat-attachment-image-gallery.tsx +19 -8
  123. package/src/components/ui/entity-image.tsx +4 -12
  124. package/src/components/ui/markdown/base-components.tsx +15 -20
  125. package/src/components/ui/square-avatar.tsx +5 -8
  126. package/src/embed-shims/next-image.tsx +34 -3
  127. package/src/hooks/index.ts +1 -0
  128. package/src/hooks/use-authed-asset-src.ts +323 -107
  129. package/src/hooks/use-authed-image-src.ts +15 -5
  130. package/src/hooks/use-image-fallback.ts +31 -0
  131. package/src/utils/app-origins.ts +12 -7
  132. package/src/utils/embed-authed-fetch.ts +71 -32
  133. package/dist/chunk-YSDB6HGQ.cjs +0 -102
@@ -1,115 +1,326 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useReducer } from 'react';
4
- import { embedAuthedFetch, needsBearerAssetFetch } from '../utils/embed-authed-fetch';
3
+ import { useEffect, useSyncExternalStore } from 'react';
4
+ import { subscribeRegisteredAdapter } from '../utils/embed-auth-adapter-registry';
5
+ import {
6
+ answeredAfterRefresh,
7
+ assetLoadMode,
8
+ embedAuthedFetch,
9
+ resolveAssetUrl,
10
+ subscribeEmbedCredentialChange,
11
+ } from '../utils/embed-authed-fetch';
5
12
  import { useIsomorphicLayoutEffect } from './ui/use-isomorphic-layout-effect';
6
13
 
7
14
  /**
8
15
  * Resolve ANY asset URL the browser would load natively — `<img src>`,
9
- * `<track src>`, a font, a poster — for hosts whose auth rides in request
10
- * HEADERS instead of cookies: native shells (`capacitor://`, `tauri://`) and
11
- * dev-ticket web, both in bearer mode. A native subresource load can't carry
12
- * an `Authorization` header, so a gateway URL that works on the cookie-auth
13
- * web 401s there.
16
+ * `<track src>`, a font, a poster — on every host:
14
17
  *
15
- * When `needsBearerAssetFetch` says the URL is bearer-authed, this hook
16
- * fetches it through `embedAuthedFetch` (bearer + deduped 401-refresh-retry +
17
- * the adapter's cross-origin guard — the SAME single auth knob every embedded
18
- * `fetch` in the lib rides) and returns a blob object-URL. Every other URL is
19
- * returned unchanged, so on the cookie-auth web the hook is a pass-through.
18
+ * 1. The host's `resolveAssetUrl` says where the URL lives, so a backend path
19
+ * the page's origin does not serve is drawn from the gateway.
20
+ * 2. Where auth rides in request HEADERS instead of cookies (native shells,
21
+ * `capacitor://` / `tauri://`, and dev-ticket web, both in bearer mode), a
22
+ * native subresource load can't carry the `Authorization` header and gets
23
+ * a 401. Such a URL is fetched through `embedAuthedFetch` (bearer + deduped
24
+ * 401-refresh-retry + the adapter's cross-origin guard) and drawn from a
25
+ * blob object-URL. Every other URL is drawn as it stands, so on the
26
+ * cookie-auth web this is a pass-through.
20
27
  *
21
- * Returns `undefined` while the fetch is in flight OR after it fails, so
22
- * callers show their existing placeholder / "no captions" branch instead of a
23
- * broken asset; a failed fetch retries on the next mount.
28
+ * The state lives in ONE module store read through `useSyncExternalStore`, so
29
+ * every mounted consumer follows it, memoized or not:
24
30
  *
25
- * Cache: module-level, keyed by full URL — so the two `<track>` URLs of one
26
- * player, or the same avatar in twenty rows, cost one request. Gateway URLs
27
- * carry a `?v=<content-hash>` cache-buster, so changed content is a new key.
28
- * Entries are reference-counted by the mounted hooks using them and revoked
29
- * RELEASE_DELAY_MS after the last one unmounts (a remount inside that window
30
- * keeps the blob). They used to live for the whole session, and chat images,
31
- * org logos and avatars are not "a few KB": in a desktop shell open for days
32
- * every distinct image ever shown stayed in memory. `clearAuthedAssetCache`
33
- * still drops everything at session end.
31
+ * - A shell renders before its tokens arrive. A gateway URL is then `pending`
32
+ * (nothing is drawn, nothing is requested) and is fetched as soon as the
33
+ * adapter reports a credential (`subscribeEmbedCredentialChange`), a new
34
+ * adapter registers, or a consumer renders and finds one. It used to be
35
+ * handed to a native `<img>` at that first render, which got a 401 and was
36
+ * never asked again.
37
+ * - `failed` is its own state, so a caller can show its fallback at once, and
38
+ * it stays `failed` while a retry runs. What is retried, and when:
39
+ * - no answer at all (offline, a refused redirect): on the next credential
40
+ * and when the browser comes back online;
41
+ * - a 401 that `embedAuthedFetch` could not refresh its way past (no new
42
+ * credential came of it): on the next credential;
43
+ * - anything else, including a 401 to the retry `embedAuthedFetch` sent
44
+ * with a credential it had just refreshed (`answeredAfterRefresh`): only
45
+ * when a consumer mounts again after every one let go. A new credential
46
+ * cannot lift that refusal, and retrying it on each rotation would rotate
47
+ * again: two such pictures would keep rotating the session between them.
48
+ * - `clearAuthedAssetCache` (session end) drops every blob; consumers still
49
+ * mounted go back to `pending` and fetch again with whatever credential the
50
+ * host holds when one next asks (see there).
51
+ * - A 2xx `text/html` answer is a failure, not a picture: a native shell's
52
+ * asset server answers an unknown path with its SPA page.
53
+ *
54
+ * Reading the state through the store also keeps it correct under the React
55
+ * Compiler: a module map read during render is memoized on its key, so a hook
56
+ * that read `cache.get(src)` there would keep the `undefined` it saw before the
57
+ * fetch finished.
58
+ *
59
+ * Cache: keyed by the resolved URL, so the same avatar in twenty rows costs one
60
+ * request. Gateway URLs carry a `?v=<content-hash>` cache-buster, so changed
61
+ * content is a new key. Entries are reference-counted by the mounted consumers
62
+ * and revoked RELEASE_DELAY_MS after the last one unmounts (a remount inside
63
+ * that window keeps the blob): in a desktop shell open for days, every distinct
64
+ * picture ever shown would otherwise stay in memory.
34
65
  */
35
66
 
36
- const resolvedCache = new Map<string, string>();
37
- const inFlight = new Map<string, Promise<void>>();
38
- let cacheGeneration = 0;
67
+ /** What a consumer draws, and why. */
68
+ export type AuthedAssetStatus =
69
+ /** Draw `src` as it stands: the browser loads it itself. */
70
+ | 'native'
71
+ /** Nothing to draw yet: waiting for a credential, or the first authed fetch is in flight. */
72
+ | 'pending'
73
+ /** `src` is the fetched blob. */
74
+ | 'ready'
75
+ /** The authed fetch failed (a retry may be running); `url` is what was asked for. */
76
+ | 'failed';
77
+
78
+ export interface AuthedAsset {
79
+ /** What to draw: the URL (`native`), its blob (`ready`), or nothing (`pending`, `failed`, or no URL given). */
80
+ readonly src: string | undefined;
81
+ /** The URL after the host's `resolveAssetUrl`; `undefined` when none was given. */
82
+ readonly url: string | undefined;
83
+ readonly status: AuthedAssetStatus;
84
+ }
85
+
86
+ /** What makes a failed entry worth asking again (see the header). */
87
+ type RetryOn = 'credential-or-online' | 'credential' | 'remount';
88
+
89
+ type EntryState =
90
+ | { readonly status: 'idle' }
91
+ | { readonly status: 'loading'; readonly afterFailure: boolean }
92
+ | { readonly status: 'ready'; readonly blobUrl: string }
93
+ | { readonly status: 'failed'; readonly retryOn: RetryOn };
94
+
95
+ interface Entry {
96
+ state: EntryState;
97
+ accept: string;
98
+ refs: number;
99
+ releaseTimer: ReturnType<typeof setTimeout> | undefined;
100
+ }
101
+
102
+ /**
103
+ * Why an entry is being looked at:
104
+ * - `join`: another consumer joining, or a consumer rendering while it waits. Starts only an idle entry.
105
+ * - `mount`: the first consumer back after every one let go. Also retries any failure.
106
+ * - `credential`: the adapter's credential changed, or a new adapter registered.
107
+ * - `online`: the browser came back online.
108
+ */
109
+ type Trigger = 'join' | 'mount' | 'credential' | 'online';
39
110
 
40
111
  /** How long an unused blob is kept before it is revoked, in ms. */
41
112
  const RELEASE_DELAY_MS = 60_000;
42
- const refCounts = new Map<string, number>();
43
- const releaseTimers = new Map<string, ReturnType<typeof setTimeout>>();
44
-
45
- function scheduleRelease(src: string): void {
46
- clearTimeout(releaseTimers.get(src));
47
- releaseTimers.set(
48
- src,
49
- setTimeout(() => {
50
- releaseTimers.delete(src);
51
- if (refCounts.has(src)) return;
52
- const blobUrl = resolvedCache.get(src);
53
- if (blobUrl) URL.revokeObjectURL(blobUrl);
54
- resolvedCache.delete(src);
55
- }, RELEASE_DELAY_MS),
56
- );
113
+
114
+ const IDLE: EntryState = { status: 'idle' };
115
+
116
+ /** A non-2xx answer, or a page where an asset was asked for. */
117
+ class AssetResponseError extends Error {
118
+ readonly status: number;
119
+ readonly afterRefresh: boolean;
120
+
121
+ constructor(response: Response) {
122
+ super(`asset fetch failed: ${response.status}`);
123
+ this.status = response.status;
124
+ this.afterRefresh = answeredAfterRefresh(response);
125
+ }
126
+ }
127
+
128
+ const entries = new Map<string, Entry>();
129
+ const listeners = new Set<() => void>();
130
+ /** Moves on `clearAuthedAssetCache`: fences fetches started under the previous session. */
131
+ let cacheGeneration = 0;
132
+ let notifyScheduled = false;
133
+ let detachSources: (() => void) | null = null;
134
+
135
+ function notify(): void {
136
+ if (notifyScheduled) return;
137
+ notifyScheduled = true;
138
+ // One pass per burst: a credential change can settle dozens of entries at once.
139
+ queueMicrotask(() => {
140
+ notifyScheduled = false;
141
+ for (const listener of [...listeners]) listener();
142
+ });
143
+ }
144
+
145
+ /** Settle `url`'s entry, unless it was dropped or the cache cleared since `entry` was read. */
146
+ function settle(url: string, entry: Entry, generation: number, state: EntryState): boolean {
147
+ const current = entries.get(url);
148
+ if (generation !== cacheGeneration || current !== entry) return false;
149
+ current.state = state;
150
+ notify();
151
+ return true;
152
+ }
153
+
154
+ function due(state: EntryState, trigger: Trigger): boolean {
155
+ if (state.status === 'idle') return true;
156
+ if (state.status !== 'failed') return false;
157
+ switch (trigger) {
158
+ case 'mount':
159
+ return true;
160
+ case 'credential':
161
+ return state.retryOn !== 'remount';
162
+ case 'online':
163
+ return state.retryOn === 'credential-or-online';
164
+ default:
165
+ return false;
166
+ }
167
+ }
168
+
169
+ function retryOnFor(error: unknown): RetryOn {
170
+ // `fetch` rejects with a TypeError when the request got no answer at all: offline, DNS, a refused redirect.
171
+ if (error instanceof TypeError) return 'credential-or-online';
172
+ if (error instanceof AssetResponseError && error.status === 401 && !error.afterRefresh) return 'credential';
173
+ return 'remount';
174
+ }
175
+
176
+ /** Start the authed fetch when `trigger` makes the entry due and a credential is there to send. */
177
+ function kick(url: string, trigger: Trigger): void {
178
+ const entry = entries.get(url);
179
+ if (!entry || !due(entry.state, trigger) || assetLoadMode(url) !== 'bearer') return;
180
+ const generation = cacheGeneration;
181
+ settle(url, entry, generation, { status: 'loading', afterFailure: entry.state.status === 'failed' });
182
+ // `embedAuthedFetch` throws synchronously on a URL its origin guard refuses: one failure path for both.
183
+ Promise.resolve()
184
+ .then(() => embedAuthedFetch(url, { headers: { Accept: entry.accept } }))
185
+ .then(async response => {
186
+ if (!response.ok || /^\s*text\/html\b/i.test(response.headers.get('Content-Type') ?? '')) {
187
+ throw new AssetResponseError(response);
188
+ }
189
+ return URL.createObjectURL(await response.blob());
190
+ })
191
+ .then(
192
+ blobUrl => {
193
+ if (!settle(url, entry, generation, { status: 'ready', blobUrl })) URL.revokeObjectURL(blobUrl);
194
+ },
195
+ (error: unknown) => {
196
+ settle(url, entry, generation, { status: 'failed', retryOn: retryOnFor(error) });
197
+ },
198
+ );
199
+ }
200
+
201
+ function sweep(trigger: Trigger): void {
202
+ for (const [url, entry] of entries) if (entry.refs > 0) kick(url, trigger);
203
+ // Even with nothing to fetch, a URL that was awaiting a credential may be classified differently now.
204
+ notify();
205
+ }
206
+
207
+ function onCredentialChange(): void {
208
+ sweep('credential');
209
+ }
210
+
211
+ function onOnline(): void {
212
+ sweep('online');
57
213
  }
58
214
 
59
- function acquire(src: string): void {
60
- clearTimeout(releaseTimers.get(src));
61
- releaseTimers.delete(src);
62
- refCounts.set(src, (refCounts.get(src) ?? 0) + 1);
215
+ /** Listen for what can make a waiting or failed entry loadable: a new adapter, a new credential, the network. */
216
+ function attachSources(): () => void {
217
+ let detachCredential = subscribeEmbedCredentialChange(onCredentialChange);
218
+ const detachAdapter = subscribeRegisteredAdapter(() => {
219
+ // `subscribeEmbedCredentialChange` binds to the adapter registered when it is called.
220
+ detachCredential();
221
+ detachCredential = subscribeEmbedCredentialChange(onCredentialChange);
222
+ onCredentialChange();
223
+ });
224
+ window.addEventListener('online', onOnline);
225
+ return () => {
226
+ detachCredential();
227
+ detachAdapter();
228
+ window.removeEventListener('online', onOnline);
229
+ };
63
230
  }
64
231
 
65
- function release(src: string): void {
66
- const remaining = (refCounts.get(src) ?? 1) - 1;
67
- if (remaining > 0) {
68
- refCounts.set(src, remaining);
69
- return;
232
+ /** No URL, nothing that can change: the many pictures drawn without one stay off the store. */
233
+ function subscribeNothing(): () => void {
234
+ return () => {};
235
+ }
236
+
237
+ function subscribe(listener: () => void): () => void {
238
+ listeners.add(listener);
239
+ detachSources ??= attachSources();
240
+ return () => {
241
+ listeners.delete(listener);
242
+ if (listeners.size === 0 && detachSources) {
243
+ detachSources();
244
+ detachSources = null;
245
+ }
246
+ };
247
+ }
248
+
249
+ function acquire(url: string, accept: string): void {
250
+ let entry = entries.get(url);
251
+ if (!entry) {
252
+ entry = { state: IDLE, accept, refs: 0, releaseTimer: undefined };
253
+ entries.set(url, entry);
70
254
  }
71
- refCounts.delete(src);
72
- scheduleRelease(src);
255
+ clearTimeout(entry.releaseTimer);
256
+ entry.releaseTimer = undefined;
257
+ entry.refs++;
258
+ kick(url, entry.refs === 1 ? 'mount' : 'join');
259
+ }
260
+
261
+ function release(url: string): void {
262
+ const entry = entries.get(url);
263
+ if (!entry) return;
264
+ entry.refs = Math.max(0, entry.refs - 1);
265
+ if (entry.refs > 0) return;
266
+ clearTimeout(entry.releaseTimer);
267
+ entry.releaseTimer = setTimeout(() => {
268
+ if (entries.get(url) !== entry || entry.refs > 0) return;
269
+ if (entry.state.status === 'ready') URL.revokeObjectURL(entry.state.blobUrl);
270
+ entries.delete(url);
271
+ }, RELEASE_DELAY_MS);
73
272
  }
74
273
 
75
274
  /**
76
275
  * Drop every cached blob and revoke its object URL. Hosts call this at
77
276
  * session end (logout / forced re-login) so a follow-on login as a
78
277
  * different identity cannot be served blobs fetched under the previous
79
- * one's bearer. Fetches still in flight when the clear happens are fenced
80
- * by a generation counter — their results are revoked instead of cached.
278
+ * one's bearer. Fetches still in flight are fenced by a generation counter
279
+ * (their results are revoked instead of cached). Consumers still mounted go
280
+ * back to `pending`; the clear itself starts no fetch, so a host may clear
281
+ * just before it drops its credential, in the same task. A consumer that
282
+ * looks again later (a credential change, a mount, a render) fetches with
283
+ * whatever credential the host holds then.
81
284
  */
82
285
  export function clearAuthedAssetCache(): void {
83
286
  cacheGeneration++;
84
- for (const url of resolvedCache.values()) URL.revokeObjectURL(url);
85
- resolvedCache.clear();
86
- inFlight.clear();
87
- for (const timer of releaseTimers.values()) clearTimeout(timer);
88
- releaseTimers.clear();
89
- }
90
-
91
- function fetchAsBlobUrl(src: string, accept: string): Promise<void> {
92
- let pending = inFlight.get(src);
93
- if (!pending) {
94
- const startedGeneration = cacheGeneration;
95
- pending = embedAuthedFetch(src, { headers: { Accept: accept } })
96
- .then(async response => {
97
- if (!response.ok) throw new Error(`asset fetch failed: ${response.status}`);
98
- const blobUrl = URL.createObjectURL(await response.blob());
99
- if (startedGeneration === cacheGeneration) {
100
- resolvedCache.set(src, blobUrl);
101
- // Everyone who asked for it unmounted while it was loading.
102
- if (!refCounts.has(src)) scheduleRelease(src);
103
- } else {
104
- URL.revokeObjectURL(blobUrl);
105
- }
106
- })
107
- .finally(() => {
108
- if (inFlight.get(src) === pending) inFlight.delete(src);
109
- });
110
- inFlight.set(src, pending);
287
+ for (const [url, entry] of entries) {
288
+ if (entry.state.status === 'ready') URL.revokeObjectURL(entry.state.blobUrl);
289
+ clearTimeout(entry.releaseTimer);
290
+ entry.releaseTimer = undefined;
291
+ if (entry.refs > 0) entry.state = IDLE;
292
+ else entries.delete(url);
111
293
  }
112
- return pending;
294
+ notify();
295
+ }
296
+
297
+ const NO_ASSET: AuthedAsset = Object.freeze({ src: undefined, url: undefined, status: 'native' });
298
+
299
+ function readAsset(src: string | null | undefined): AuthedAsset {
300
+ if (!src) return NO_ASSET;
301
+ const url = resolveAssetUrl(src);
302
+ const mode = assetLoadMode(url);
303
+ if (mode === 'native') return { src: url, url, status: 'native' };
304
+ const state = mode === 'bearer' ? entries.get(url)?.state : undefined;
305
+ if (state?.status === 'ready') return { src: state.blobUrl, url, status: 'ready' };
306
+ if (state?.status === 'failed' || (state?.status === 'loading' && state.afterFailure)) {
307
+ return { src: undefined, url, status: 'failed' };
308
+ }
309
+ return { src: undefined, url, status: 'pending' };
310
+ }
311
+
312
+ /**
313
+ * The store's answer as one string: `useSyncExternalStore` compares snapshots
314
+ * by identity, and a string is equal by value, with no cache of answer objects
315
+ * to grow and evict.
316
+ */
317
+ function encode(asset: AuthedAsset): string {
318
+ return JSON.stringify([asset.status, asset.url ?? null, asset.src ?? null]);
319
+ }
320
+
321
+ function decode(snapshot: string): AuthedAsset {
322
+ const [status, url, src] = JSON.parse(snapshot) as [AuthedAssetStatus, string | null, string | null];
323
+ return { status, url: url ?? undefined, src: src ?? undefined };
113
324
  }
114
325
 
115
326
  /**
@@ -118,33 +329,38 @@ function fetchAsBlobUrl(src: string, accept: string): Promise<void> {
118
329
  * consumer renders (`image/*`, `text/vtt`); it also replaces
119
330
  * `embedAuthedFetch`'s default JSON `Content-Type` on the GET.
120
331
  */
121
- export function useAuthedAssetSrc(src?: string | null, accept = '*/*'): string | undefined {
122
- const bearerSrc = src && needsBearerAssetFetch(src) ? src : null;
123
- const [, rerender] = useReducer((c: number) => c + 1, 0);
332
+ export function useAuthedAsset(src?: string | null, accept = '*/*'): AuthedAsset {
333
+ const snapshot = useSyncExternalStore(
334
+ src ? subscribe : subscribeNothing,
335
+ () => encode(readAsset(src)),
336
+ // The server has no adapter: it draws the URL as given, and the client takes over after hydration.
337
+ () => encode(src ? { src, url: src, status: 'native' } : NO_ASSET),
338
+ );
339
+ const asset = decode(snapshot);
340
+ const tracked = asset.status === 'native' ? undefined : asset.url;
124
341
 
125
- // Layout, not passive: this render hands out the cached blob URL, and a
342
+ // Layout, not passive: this render may hand out a cached blob URL, and a
126
343
  // release timer firing before a passive effect ran would revoke it under
127
- // the committed <img>.
344
+ // the committed element.
128
345
  useIsomorphicLayoutEffect(() => {
129
- if (!bearerSrc) return undefined;
130
- acquire(bearerSrc);
131
- return () => release(bearerSrc);
132
- }, [bearerSrc]);
346
+ if (!tracked) return undefined;
347
+ acquire(tracked, accept);
348
+ return () => release(tracked);
349
+ }, [tracked, accept]);
133
350
 
351
+ // A consumer that renders while it waits looks again, because a credential can be there with nobody having told
352
+ // the store: it arrived between this consumer's acquire (layout effect) and its subscribe (passive effect), or the
353
+ // adapter never announced it (another tab's sign-in reaches this one only through `storage`). Starts only an idle
354
+ // entry.
355
+ const waiting = tracked !== undefined && asset.status === 'pending';
134
356
  useEffect(() => {
135
- if (!bearerSrc || resolvedCache.has(bearerSrc)) return undefined;
136
- let cancelled = false;
137
- fetchAsBlobUrl(bearerSrc, accept)
138
- .catch(() => {})
139
- .finally(() => {
140
- if (!cancelled) rerender();
141
- });
142
- return () => {
143
- cancelled = true;
144
- };
145
- }, [bearerSrc, accept]);
146
-
147
- if (!src) return undefined;
148
- if (!bearerSrc) return src;
149
- return resolvedCache.get(bearerSrc);
357
+ if (waiting) kick(tracked, 'join');
358
+ });
359
+
360
+ return asset;
361
+ }
362
+
363
+ /** `useAuthedAsset`'s `src`: what to draw, `undefined` while there is nothing to draw. */
364
+ export function useAuthedAssetSrc(src?: string | null, accept = '*/*'): string | undefined {
365
+ return useAuthedAsset(src, accept).src;
150
366
  }
@@ -1,19 +1,29 @@
1
1
  'use client';
2
2
 
3
- import { clearAuthedAssetCache, useAuthedAssetSrc } from './use-authed-asset-src';
3
+ import { type AuthedAsset, clearAuthedAssetCache, useAuthedAsset } from './use-authed-asset-src';
4
4
 
5
5
  /**
6
- * Image flavor of `useAuthedAssetSrc` — see that hook for the full contract.
7
- * Kept as its own export because it is the lib's most-used consumer (avatars,
8
- * entity images, markdown `<img>`) and because hosts already import this name.
6
+ * Image flavor of `useAuthedAsset` — see that hook for the full contract. The
7
+ * one place a picture's `Accept` is stated: a store entry keeps the `Accept` of
8
+ * whichever consumer acquired it first, so the boxed `Image` and every picture
9
+ * component around it must ask the same.
9
10
  *
10
11
  * Distinct from the legacy `useAuthenticatedImage`, which ALWAYS blob-fetches
11
12
  * with `credentials: 'include'` and its own global config. This hook is
12
13
  * pass-through-first and keys off the single auth knob the lib already has:
13
14
  * the registered `EmbedAuthAdapter`.
14
15
  */
16
+ export function useAuthedImage(src?: string | null): AuthedAsset {
17
+ return useAuthedAsset(src, 'image/*');
18
+ }
19
+
20
+ /**
21
+ * `useAuthedImage`'s `src`. Kept as its own export because it is the lib's
22
+ * most-used consumer (avatars, entity images, markdown `<img>`) and because
23
+ * hosts already import this name.
24
+ */
15
25
  export function useAuthedImageSrc(src?: string | null): string | undefined {
16
- return useAuthedAssetSrc(src, 'image/*');
26
+ return useAuthedImage(src).src;
17
27
  }
18
28
 
19
29
  /**
@@ -0,0 +1,31 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useState } from 'react';
4
+
5
+ export interface ImageFallback {
6
+ /** The first candidate that has not failed, else null: draw your own fallback (initials, a placeholder, nothing). */
7
+ src: string | null;
8
+ /** Wire to the picture's `onError`. */
9
+ onError: () => void;
10
+ }
11
+
12
+ /**
13
+ * A picture's fallback chain, keyed by URL: how a component falls back from a picture that did not load
14
+ * (`SquareAvatar`, `EntityImage`, `ChatMessageRow`, the entity cards' covers). State is the SET of URLs that failed
15
+ * and `src` is derived, so a new URL is simply not one of them: the picture comes back with no reset effect, and a
16
+ * URL that failed once is not retried in a loop. Never hide the element or reveal a fallback by writing to the DOM
17
+ * in `onError` instead (the legacy `hideOnError`): React does not undo the write, so the fallback outlives the URL
18
+ * it was about.
19
+ */
20
+ export function useImageFallback(...candidates: (string | null | undefined)[]): ImageFallback {
21
+ const [failed, setFailed] = useState<ReadonlySet<string>>(() => new Set());
22
+
23
+ const src = candidates.find((candidate): candidate is string => !!candidate && !failed.has(candidate)) ?? null;
24
+
25
+ const onError = useCallback(() => {
26
+ if (!src) return;
27
+ setFailed(prev => new Set(prev).add(src));
28
+ }, [src]);
29
+
30
+ return { src, onError };
31
+ }
@@ -50,12 +50,17 @@ export function readAppOrigins(): AppOrigins {
50
50
  /** Called when a host registers, replaces or clears its auth adapter, which can change `allowedOrigins`. */
51
51
  export const subscribeAppOrigins: (listener: () => void) => () => void = subscribeRegisteredAdapter;
52
52
 
53
- /**
54
- * `url` resolved against the page when it belongs to the app, otherwise `null`.
55
- *
56
- * The page match compares scheme and host instead of `URL.origin`, which is
57
- * `"null"` for a `capacitor://` page.
58
- */
53
+ /** Scheme and host, not `URL.origin`, which is `"null"` for a `capacitor://` page. */
54
+ function isOnPage(target: URL, page: URL): boolean {
55
+ return target.protocol === page.protocol && target.host === page.host;
56
+ }
57
+
58
+ /** Whether `target` is on the page's own origin rather than on an origin the host sanctioned. */
59
+ export function isPageOriginUrl(target: URL, origins: AppOrigins = readAppOrigins()): boolean {
60
+ return isOnPage(target, new URL(origins.pageUrl ?? SERVER_PAGE_URL));
61
+ }
62
+
63
+ /** `url` resolved against the page when it belongs to the app, otherwise `null`. */
59
64
  export function resolveAppOriginUrl(url: string, origins: AppOrigins = readAppOrigins()): URL | null {
60
65
  let page: URL;
61
66
  let target: URL;
@@ -65,7 +70,7 @@ export function resolveAppOriginUrl(url: string, origins: AppOrigins = readAppOr
65
70
  } catch {
66
71
  return null;
67
72
  }
68
- if (target.protocol === page.protocol && target.host === page.host) return target;
73
+ if (isOnPage(target, page)) return target;
69
74
  if (target.protocol !== 'http:' && target.protocol !== 'https:') return null;
70
75
  return origins.allowedOrigins.includes(target.origin) ? target : null;
71
76
  }