@hermesihq/react 0.1.0 → 0.2.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/dist/index.js CHANGED
@@ -1,429 +1,17 @@
1
- // src/core/subscriberToken.ts
2
- function base64UrlDecodeToString(segment) {
3
- const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
4
- const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
5
- const binary = atob(padded);
6
- const bytes = new Uint8Array(binary.length);
7
- for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
8
- return new TextDecoder().decode(bytes);
9
- }
10
- function decodeSubscriberTokenExp(token) {
11
- const [payloadSegment] = token.split(".");
12
- if (!payloadSegment) return null;
13
- try {
14
- const parsed = JSON.parse(base64UrlDecodeToString(payloadSegment));
15
- if (typeof parsed === "object" && parsed !== null && "exp" in parsed && typeof parsed.exp === "number") {
16
- return parsed.exp;
17
- }
18
- return null;
19
- } catch {
20
- return null;
21
- }
22
- }
23
-
24
- // src/core/types.ts
25
- var HERMS_CHANNELS = [
26
- "in_app",
27
- "email",
28
- "push",
29
- "sms",
30
- "whatsapp",
31
- "telegram",
32
- "slack",
33
- "teams",
34
- "discord"
35
- ];
36
- var HermsApiError = class extends Error {
37
- status;
38
- code;
39
- type;
40
- requestId;
41
- /** Per-problem specifics. Possibly empty, never absent: the API's own words. */
42
- detail;
43
- /** Documentation for this `code`, or `''` when the server sent none. */
44
- docUrl;
45
- constructor(status, body) {
46
- const envelope = typeof body === "object" && body !== null && "error" in body ? body.error : void 0;
47
- const message = typeof envelope?.message === "string" ? envelope.message : `Hermesi client API request failed with status ${status}.`;
48
- super(message);
49
- this.name = "HermsApiError";
50
- this.status = status;
51
- this.code = typeof envelope?.code === "string" ? envelope.code : "unknown_error";
52
- this.type = typeof envelope?.type === "string" ? envelope.type : "unknown_error";
53
- this.requestId = typeof envelope?.request_id === "string" ? envelope.request_id : "";
54
- this.detail = Array.isArray(envelope?.detail) ? envelope.detail.filter(
55
- (entry) => typeof entry === "object" && entry !== null
56
- ) : [];
57
- this.docUrl = typeof envelope?.doc_url === "string" ? envelope.doc_url : "";
58
- }
59
- };
60
-
61
- // src/core/HermsClient.ts
62
- var POLL_INTERVAL_MS = 6e4;
63
- var MAX_FAST_RECONNECT_ATTEMPTS = 3;
64
- var BASE_RECONNECT_DELAY_MS = 1e3;
65
- var MAX_RECONNECT_DELAY_MS = 3e4;
66
- var TOKEN_EXPIRY_BUFFER_SECONDS = 60;
67
- function backoffDelayMs(attempt) {
68
- const exponential = BASE_RECONNECT_DELAY_MS * 2 ** Math.max(0, attempt - 1);
69
- return Math.min(MAX_RECONNECT_DELAY_MS, exponential);
70
- }
71
- function buildQueryString(params) {
72
- const search = new URLSearchParams();
73
- for (const [key, value] of Object.entries(params)) {
74
- if (value === void 0) continue;
75
- if (Array.isArray(value)) {
76
- for (const item of value) search.append(key, item);
77
- } else {
78
- search.append(key, String(value));
79
- }
80
- }
81
- const qs = search.toString();
82
- return qs ? `?${qs}` : "";
83
- }
84
- function fromWireItem(wire) {
85
- return {
86
- id: wire.id,
87
- title: wire.title,
88
- body: wire.body,
89
- actionUrl: wire.action_url,
90
- category: wire.category ? { key: wire.category.key, name: wire.category.name } : null,
91
- seenAt: wire.seen_at,
92
- readAt: wire.read_at,
93
- createdAt: wire.created_at
94
- };
95
- }
96
- function fromWirePreferences(wire) {
97
- return {
98
- firstName: wire.first_name,
99
- globalChannels: wire.global_channels.map((c) => ({ channel: c.channel, enabled: c.enabled })),
100
- globalEmailEnabled: wire.global_email_enabled,
101
- globalInAppEnabled: wire.global_in_app_enabled,
102
- categories: wire.categories.map((category) => ({
103
- categoryId: category.category_id,
104
- key: category.key,
105
- name: category.name,
106
- isCritical: category.is_critical,
107
- channels: category.channels.map((c) => ({ channel: c.channel, enabled: c.enabled })),
108
- emailEnabled: category.email_enabled,
109
- inAppEnabled: category.in_app_enabled
110
- }))
111
- };
112
- }
113
- var HermsClient = class {
114
- options;
115
- baseUrl;
116
- expiryTimer = null;
117
- lastScheduledExp = null;
118
- constructor(options) {
119
- this.options = options;
120
- this.baseUrl = options.apiBaseUrl.replace(/\/+$/, "");
121
- }
122
- // --- Token handling ----------------------------------------------------
123
- /** Every request goes through here: always a *fresh* call to
124
- * `getSubscriberToken()` (never cached past what the host itself returns,
125
- * per this class's own contract), which also re-arms `onTokenExpiring`
126
- * against whatever token comes back. */
127
- async getFreshToken() {
128
- const token = await this.options.getSubscriberToken();
129
- this.scheduleExpiryHook(token);
130
- return token;
131
- }
132
- scheduleExpiryHook(token) {
133
- const { onTokenExpiring } = this.options;
134
- if (!onTokenExpiring) return;
135
- const exp = decodeSubscriberTokenExp(token);
136
- if (exp === null || exp === this.lastScheduledExp) return;
137
- if (this.expiryTimer !== null) clearTimeout(this.expiryTimer);
138
- this.lastScheduledExp = exp;
139
- const delayMs = Math.max(0, exp * 1e3 - TOKEN_EXPIRY_BUFFER_SECONDS * 1e3 - Date.now());
140
- this.expiryTimer = setTimeout(() => {
141
- onTokenExpiring();
142
- }, delayMs);
143
- }
144
- /** Stops the scheduled `onTokenExpiring` timer. Called from
145
- * `HermsProvider`'s unmount cleanup so a torn-down client never fires the
146
- * hook after the app has stopped caring. */
147
- destroy() {
148
- if (this.expiryTimer !== null) clearTimeout(this.expiryTimer);
149
- this.expiryTimer = null;
150
- }
151
- // --- Transport -----------------------------------------------------------
152
- /**
153
- * `expectsBody` is how this knows a missing response body is a failure rather than a
154
- * normal outcome. It defaults to `true` because most calls here read a payload; the
155
- * methods that resolve to `void` pass `false`.
156
- *
157
- * Without it the success path did no checking at all. A `200` carrying a proxy's HTML
158
- * page, or a `204` where a body was expected, left `parsed` null and returned it as
159
- * `T`, so `listInbox()` rejected with `TypeError: Cannot read properties of null
160
- * (reading 'data')` and the stack pointed at this SDK instead of at whatever was
161
- * actually in the way. The *error* path already handled exactly that case carefully;
162
- * only the success path did not.
163
- */
164
- async request(path, init = {}) {
165
- const token = await this.getFreshToken();
166
- const qs = init.query ? buildQueryString(init.query) : "";
167
- const headers = {
168
- Authorization: `Bearer ${this.options.publicKey}`,
169
- "X-Hermesi-Subscriber-Token": token,
170
- Accept: "application/json"
171
- };
172
- if (init.body !== void 0) headers["Content-Type"] = "application/json";
173
- const response = await fetch(`${this.baseUrl}${path}${qs}`, {
174
- method: init.method ?? "GET",
175
- headers,
176
- body: init.body !== void 0 ? JSON.stringify(init.body) : void 0
177
- });
178
- const expectsBody = init.expectsBody ?? true;
179
- if (response.status === 204) {
180
- if (expectsBody) throw new HermsApiError(response.status, null);
181
- return void 0;
182
- }
183
- const rawText = await response.text();
184
- let parsed = null;
185
- if (rawText) {
186
- try {
187
- parsed = JSON.parse(rawText);
188
- } catch {
189
- parsed = null;
190
- }
191
- }
192
- if (!response.ok) {
193
- throw new HermsApiError(response.status, parsed);
194
- }
195
- if (expectsBody && parsed === null) {
196
- throw new HermsApiError(response.status, null);
197
- }
198
- return parsed;
199
- }
200
- // --- Inbox reads -----------------------------------------------------------
201
- async listInbox(params = {}) {
202
- const wire = await this.request("/inbox", {
203
- query: {
204
- limit: params.limit,
205
- cursor: params.cursor,
206
- read: params.status === void 0 ? void 0 : params.status === "read" ? "true" : "false",
207
- category: params.category
208
- }
209
- });
210
- return {
211
- items: wire.data.map(fromWireItem),
212
- hasMore: wire.has_more,
213
- nextCursor: wire.next_cursor
214
- };
215
- }
216
- async getCounts() {
217
- const wire = await this.request("/inbox/counts");
218
- return { unread: wire.unread, unseen: wire.unseen };
219
- }
220
- // --- Inbox writes ------------------------------------------------------
221
- async markRead(id) {
222
- const wire = await this.request(`/inbox/${encodeURIComponent(id)}/read`, { method: "POST" });
223
- return fromWireItem(wire);
224
- }
225
- async markAllRead() {
226
- const wire = await this.request("/inbox/read-all", { method: "POST" });
227
- return { updated: wire.updated };
228
- }
229
- async markSeen(ids) {
230
- if (ids.length === 0) return { updated: 0 };
231
- const wire = await this.request("/inbox/seen", { method: "POST", body: { ids } });
232
- return { updated: wire.updated };
233
- }
234
- async archive(id) {
235
- const wire = await this.request(`/inbox/${encodeURIComponent(id)}/archive`, { method: "POST" });
236
- return fromWireItem(wire);
237
- }
238
- async delete(id) {
239
- await this.request(`/inbox/${encodeURIComponent(id)}`, { method: "DELETE", expectsBody: false });
240
- }
241
- // --- Channels (device-token self-registration) --------------------------
242
- //
243
- // `POST/DELETE /v1/client/channels`: landing alongside this SDK, not yet
244
- // live in every deployment at the time this client was written. Implemented
245
- // against the documented shape (§8.5/§6.10) so the SDK's public surface is
246
- // complete; a backend that hasn't shipped the route yet will 404, surfaced
247
- // to the caller as an ordinary `HermsApiError`, same as any other endpoint.
248
- async registerChannel(params) {
249
- await this.request("/channels", {
250
- method: "POST",
251
- expectsBody: false,
252
- body: { channel: params.channel, identifier: params.identifier, metadata: params.metadata }
253
- });
254
- }
255
- // --- Preferences -----------------------------------------------------------
256
- /**
257
- * The subscriber's own notification settings, for rendering a preference centre.
258
- *
259
- * There is deliberately no `unsubscribe()` beside this, although the API exposes
260
- * `POST /v1/client/unsubscribe`. That route is the target of a `List-Unsubscribe`
261
- * one-click link (RFC 8058): its token is minted by Hermesi and embedded in an
262
- * outgoing email's headers, and the API's own description says it is invoked by a
263
- * mail client rather than by application code. Wrapping it here would invite a host
264
- * to build an unsubscribe button on a token it cannot obtain. The equivalent here
265
- * is `updatePreference({ channel: 'email', enabled: false })`.
266
- */
267
- async getPreferences() {
268
- return fromWirePreferences(await this.request("/preferences"));
269
- }
270
- /**
271
- * Change one setting, and receive the whole updated state back.
272
- *
273
- * The server answers `PATCH` with the full preferences rather than an
274
- * acknowledgement, so a caller never has to refetch, and never renders a view
275
- * assembled from its own optimistic guess plus whatever else has changed since.
276
- * Omit `categoryId` for the global setting; supply it to change one category.
277
- */
278
- async updatePreference(update) {
279
- const wire = await this.request("/preferences", {
280
- method: "PATCH",
281
- body: {
282
- channel: update.channel,
283
- enabled: update.enabled,
284
- // Sent as `null` rather than omitted: the API reads its absence and its null
285
- // the same way, and being explicit keeps "global" visible in a request log.
286
- category_id: update.categoryId ?? null
287
- }
288
- });
289
- return fromWirePreferences(wire);
290
- }
291
- /**
292
- * **Path parameters, not a query string.** This sent
293
- * `DELETE /channels?channel=...&identifier=...` for as long as the method has existed,
294
- * and there is no `DELETE` on `/v1/client/channels` at all: the route is
295
- * `/v1/client/channels/{channel}/{identifier}`, so every call 404'd. Nothing caught
296
- * it: the reference integration never deregisters, and the package had no tests.
297
- *
298
- * `identifier` is encoded because it is somebody's address (an email, a phone
299
- * number, a chat conversation id), and those carry `@`, `+` and `:`.
300
- */
301
- async deregisterChannel(channel, identifier) {
302
- await this.request(`/channels/${encodeURIComponent(channel)}/${encodeURIComponent(identifier)}`, {
303
- method: "DELETE",
304
- expectsBody: false
305
- });
306
- }
307
- // --- Real-time -----------------------------------------------------------
308
- /**
309
- * Opens a live connection for real-time inbox events and returns an
310
- * unsubscribe function. SSR-safe: `window`/`EventSource` are only read here,
311
- * at call time, never at module or constructor time.
312
- *
313
- * - Prefers SSE (`GET /inbox/stream`) when `EventSource` exists.
314
- * - Falls back to polling `getCounts()` every 60s (§6.6) when `EventSource`
315
- * doesn't exist at all, or after `MAX_FAST_RECONNECT_ATTEMPTS` consecutive
316
- * immediate reconnect failures (an invalid/expired token, or a network
317
- * that blocks SSE outright). It still retries SSE in the background with
318
- * exponential backoff rather than abandoning it permanently, so a fixed
319
- * token/network problem self-heals without a page reload.
320
- */
321
- subscribe(onEvent) {
322
- let stopped = false;
323
- let eventSource = null;
324
- let pollTimer = null;
325
- let reconnectTimer = null;
326
- let consecutiveFailures = 0;
327
- const hasEventSource = typeof window !== "undefined" && typeof EventSource !== "undefined";
328
- const startPolling = () => {
329
- if (pollTimer !== null) return;
330
- pollTimer = setInterval(() => {
331
- this.getCounts().then((counts) => onEvent({ type: "counts.changed", data: counts })).catch(() => {
332
- });
333
- }, POLL_INTERVAL_MS);
334
- };
335
- const stopPolling = () => {
336
- if (pollTimer !== null) {
337
- clearInterval(pollTimer);
338
- pollTimer = null;
339
- }
340
- };
341
- const scheduleReconnect = () => {
342
- if (stopped) return;
343
- consecutiveFailures += 1;
344
- if (consecutiveFailures >= MAX_FAST_RECONNECT_ATTEMPTS) startPolling();
345
- const delay = backoffDelayMs(consecutiveFailures);
346
- reconnectTimer = setTimeout(() => {
347
- void connect();
348
- }, delay);
349
- };
350
- const connect = async () => {
351
- if (stopped || !hasEventSource) return;
352
- let token;
353
- try {
354
- token = await this.getFreshToken();
355
- } catch {
356
- scheduleReconnect();
357
- return;
358
- }
359
- if (stopped) return;
360
- const qs = buildQueryString({ public_key: this.options.publicKey, subscriber_token: token });
361
- const source = new EventSource(`${this.baseUrl}/inbox/stream${qs}`);
362
- eventSource = source;
363
- source.addEventListener("item.created", (evt) => {
364
- consecutiveFailures = 0;
365
- try {
366
- const data = JSON.parse(evt.data);
367
- onEvent({ type: "item.created", data });
368
- } catch {
369
- }
370
- });
371
- source.addEventListener("counts.changed", (evt) => {
372
- consecutiveFailures = 0;
373
- try {
374
- const data = JSON.parse(evt.data);
375
- onEvent({ type: "counts.changed", data });
376
- } catch {
377
- }
378
- });
379
- source.onopen = () => {
380
- consecutiveFailures = 0;
381
- stopPolling();
382
- };
383
- source.onerror = () => {
384
- source.close();
385
- if (eventSource === source) eventSource = null;
386
- if (!stopped) scheduleReconnect();
387
- };
388
- };
389
- if (hasEventSource) {
390
- void connect();
391
- } else {
392
- startPolling();
393
- }
394
- return () => {
395
- stopped = true;
396
- stopPolling();
397
- if (reconnectTimer !== null) clearTimeout(reconnectTimer);
398
- eventSource?.close();
399
- };
400
- }
401
- };
1
+ // src/index.ts
2
+ import { HermsClient, HermsApiError, HERMS_CHANNELS, decodeSubscriberTokenExp } from "@hermesihq/js";
402
3
 
403
- // src/react/HermsProvider.tsx
404
- import { createContext, useContext, useEffect, useMemo, useRef } from "react";
4
+ // src/HermsProvider.tsx
5
+ import { createContext, useContext, useEffect, useMemo } from "react";
6
+ import { HermsSession } from "@hermesihq/js";
405
7
  import { jsx } from "react/jsx-runtime";
406
8
  var HermsContext = createContext(null);
407
9
  function HermsProvider({ client, children }) {
408
- const listenersRef = useRef(/* @__PURE__ */ new Set());
409
- useEffect(() => {
410
- const unsubscribe = client.subscribe((event) => {
411
- for (const listener of listenersRef.current) listener(event);
412
- });
413
- return () => {
414
- unsubscribe();
415
- client.destroy();
416
- };
417
- }, [client]);
10
+ const session = useMemo(() => new HermsSession(client), [client]);
11
+ useEffect(() => session.connect(), [session]);
418
12
  const value = useMemo(
419
- () => ({
420
- client,
421
- addEventListener: (listener) => {
422
- listenersRef.current.add(listener);
423
- return () => listenersRef.current.delete(listener);
424
- }
425
- }),
426
- [client]
13
+ () => ({ client, addEventListener: session.addEventListener }),
14
+ [client, session]
427
15
  );
428
16
  return /* @__PURE__ */ jsx(HermsContext.Provider, { value, children });
429
17
  }
@@ -435,203 +23,63 @@ function useHermsContext() {
435
23
  return ctx;
436
24
  }
437
25
 
438
- // src/react/useUnreadCount.ts
439
- import { useEffect as useEffect2, useState } from "react";
26
+ // src/useUnreadCount.ts
27
+ import { useEffect as useEffect2, useMemo as useMemo2, useSyncExternalStore } from "react";
28
+ import { CountsStore } from "@hermesihq/js";
440
29
  function useUnreadCount() {
441
- const { client, addEventListener } = useHermsContext();
442
- const [counts, setCounts] = useState({ unread: 0, unseen: 0 });
443
- const [isLoading, setIsLoading] = useState(true);
444
- useEffect2(() => {
445
- let cancelled = false;
446
- setIsLoading(true);
447
- client.getCounts().then((fresh) => {
448
- if (!cancelled) setCounts(fresh);
449
- }).catch(() => {
450
- }).finally(() => {
451
- if (!cancelled) setIsLoading(false);
452
- });
453
- return () => {
454
- cancelled = true;
455
- };
456
- }, [client]);
457
- useEffect2(
458
- () => addEventListener((event) => {
459
- if (event.type === "counts.changed") setCounts(event.data);
460
- }),
461
- [addEventListener]
462
- );
463
- return { ...counts, isLoading };
30
+ const host = useHermsContext();
31
+ const store = useMemo2(() => new CountsStore(host), [host]);
32
+ useEffect2(() => store.connect(), [store]);
33
+ return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
464
34
  }
465
35
 
466
- // src/react/useInbox.ts
467
- import { useCallback, useEffect as useEffect3, useRef as useRef2, useState as useState2 } from "react";
36
+ // src/useInbox.ts
37
+ import { useEffect as useEffect3, useMemo as useMemo3, useSyncExternalStore as useSyncExternalStore2 } from "react";
38
+ import { InboxStore } from "@hermesihq/js";
468
39
  function useInbox(options = {}) {
469
- const { client, addEventListener } = useHermsContext();
40
+ const host = useHermsContext();
470
41
  const { status, category } = options;
471
- const [items, setItems] = useState2([]);
472
- const [isLoading, setIsLoading] = useState2(true);
473
- const [isLoadingMore, setIsLoadingMore] = useState2(false);
474
- const [error, setError] = useState2(null);
475
- const [hasMore, setHasMore] = useState2(false);
476
- const cursorRef = useRef2(null);
477
- const requestIdRef = useRef2(0);
478
- const fetchFirstPage = useCallback(() => {
479
- const requestId = ++requestIdRef.current;
480
- setIsLoading(true);
481
- setError(null);
482
- client.listInbox({ status, category }).then((page) => {
483
- if (requestIdRef.current !== requestId) return;
484
- setItems(page.items);
485
- setHasMore(page.hasMore);
486
- cursorRef.current = page.nextCursor;
487
- }).catch((err) => {
488
- if (requestIdRef.current !== requestId) return;
489
- setError(err instanceof Error ? err : new Error("Failed to load inbox."));
490
- }).finally(() => {
491
- if (requestIdRef.current === requestId) setIsLoading(false);
492
- });
493
- }, [client, status, category]);
42
+ const store = useMemo3(() => new InboxStore(host, { status, category }), [host]);
43
+ useEffect3(() => store.connect(), [store]);
494
44
  useEffect3(() => {
495
- fetchFirstPage();
496
- }, [fetchFirstPage]);
497
- const loadMore = useCallback(() => {
498
- if (isLoadingMore || !hasMore || cursorRef.current === null) return;
499
- setIsLoadingMore(true);
500
- client.listInbox({ status, category, cursor: cursorRef.current }).then((page) => {
501
- setItems((prev) => [...prev, ...page.items]);
502
- setHasMore(page.hasMore);
503
- cursorRef.current = page.nextCursor;
504
- }).catch((err) => {
505
- setError(err instanceof Error ? err : new Error("Failed to load more of the inbox."));
506
- }).finally(() => setIsLoadingMore(false));
507
- }, [client, status, category, isLoadingMore, hasMore]);
508
- const reconcileFirstPage = useCallback(() => {
509
- client.listInbox({ status, category }).then((page) => {
510
- setItems((prev) => {
511
- const freshById = new Map(page.items.map((item) => [item.id, item]));
512
- const merged = prev.map((item) => freshById.get(item.id) ?? item);
513
- const knownIds = new Set(merged.map((item) => item.id));
514
- const additions = page.items.filter((item) => !knownIds.has(item.id));
515
- return [...additions, ...merged];
516
- });
517
- }).catch(() => {
518
- });
519
- }, [client, status, category]);
520
- useEffect3(
521
- () => addEventListener((event) => {
522
- if (event.type !== "item.created") return;
523
- const partial = {
524
- id: event.data.id,
525
- title: event.data.title,
526
- body: "",
527
- actionUrl: null,
528
- category: null,
529
- seenAt: null,
530
- readAt: null,
531
- createdAt: event.data.created_at
532
- };
533
- setItems((prev) => prev.some((item) => item.id === partial.id) ? prev : [partial, ...prev]);
534
- reconcileFirstPage();
45
+ store.setFilter({ status, category });
46
+ }, [store, status, category]);
47
+ const state = useSyncExternalStore2(store.subscribe, store.getSnapshot, store.getSnapshot);
48
+ return useMemo3(
49
+ () => ({
50
+ ...state,
51
+ // Arrow-function fields on the store: the same references for its whole life, so a
52
+ // consumer's `useCallback`/`memo` dependencies do not churn as the state changes.
53
+ loadMore: store.loadMore,
54
+ markRead: store.markRead,
55
+ markAllRead: store.markAllRead,
56
+ archive: store.archive,
57
+ remove: store.remove,
58
+ refetch: store.refetch
535
59
  }),
536
- [addEventListener, reconcileFirstPage]
60
+ [state, store]
537
61
  );
538
- const markRead = useCallback(
539
- async (id) => {
540
- const updated = await client.markRead(id);
541
- setItems((prev) => prev.map((item) => item.id === id ? updated : item));
542
- },
543
- [client]
544
- );
545
- const markAllRead = useCallback(async () => {
546
- await client.markAllRead();
547
- const now = (/* @__PURE__ */ new Date()).toISOString();
548
- setItems((prev) => prev.map((item) => item.readAt ? item : { ...item, readAt: now }));
549
- }, [client]);
550
- const archive = useCallback(
551
- async (id) => {
552
- await client.archive(id);
553
- setItems((prev) => prev.filter((item) => item.id !== id));
554
- },
555
- [client]
556
- );
557
- const remove = useCallback(
558
- async (id) => {
559
- await client.delete(id);
560
- setItems((prev) => prev.filter((item) => item.id !== id));
561
- },
562
- [client]
563
- );
564
- return {
565
- items,
566
- isLoading,
567
- isLoadingMore,
568
- error,
569
- hasMore,
570
- loadMore,
571
- markRead,
572
- markAllRead,
573
- archive,
574
- remove,
575
- refetch: fetchFirstPage
576
- };
577
62
  }
578
63
 
579
- // src/react/usePreferences.ts
580
- import { useCallback as useCallback2, useEffect as useEffect4, useState as useState3 } from "react";
64
+ // src/usePreferences.ts
65
+ import { useEffect as useEffect4, useMemo as useMemo4, useSyncExternalStore as useSyncExternalStore3 } from "react";
66
+ import { PreferencesStore } from "@hermesihq/js";
581
67
  function usePreferences() {
582
- const { client } = useHermsContext();
583
- const [preferences, setPreferences] = useState3(null);
584
- const [isLoading, setIsLoading] = useState3(true);
585
- const [error, setError] = useState3(null);
586
- const load = useCallback2(async () => {
587
- setIsLoading(true);
588
- try {
589
- const fresh = await client.getPreferences();
590
- setPreferences(fresh);
591
- setError(null);
592
- } catch (caught) {
593
- setError(caught instanceof Error ? caught : new Error(String(caught)));
594
- } finally {
595
- setIsLoading(false);
596
- }
597
- }, [client]);
598
- useEffect4(() => {
599
- let cancelled = false;
600
- setIsLoading(true);
601
- client.getPreferences().then((fresh) => {
602
- if (cancelled) return;
603
- setPreferences(fresh);
604
- setError(null);
605
- }).catch((caught) => {
606
- if (cancelled) return;
607
- setError(caught instanceof Error ? caught : new Error(String(caught)));
608
- }).finally(() => {
609
- if (!cancelled) setIsLoading(false);
610
- });
611
- return () => {
612
- cancelled = true;
613
- };
614
- }, [client]);
615
- const setPreference = useCallback2(
616
- async (update) => {
617
- try {
618
- setPreferences(await client.updatePreference(update));
619
- setError(null);
620
- } catch (caught) {
621
- setError(caught instanceof Error ? caught : new Error(String(caught)));
622
- throw caught;
623
- }
624
- },
625
- [client]
68
+ const host = useHermsContext();
69
+ const store = useMemo4(() => new PreferencesStore(host), [host]);
70
+ useEffect4(() => store.connect(), [store]);
71
+ const state = useSyncExternalStore3(store.subscribe, store.getSnapshot, store.getSnapshot);
72
+ return useMemo4(
73
+ () => ({ ...state, setPreference: store.setPreference, reload: store.reload }),
74
+ [state, store]
626
75
  );
627
- return { preferences, isLoading, error, setPreference, reload: load };
628
76
  }
629
77
 
630
- // src/react/HermsInbox.tsx
631
- import { useCallback as useCallback3, useEffect as useEffect5, useMemo as useMemo2, useRef as useRef3, useState as useState4 } from "react";
78
+ // src/HermsInbox.tsx
79
+ import { useCallback, useEffect as useEffect5, useId, useMemo as useMemo5, useRef, useState } from "react";
632
80
  import * as Popover from "@radix-ui/react-popover";
633
81
 
634
- // src/react/locale.ts
82
+ // src/locale.ts
635
83
  var en = {
636
84
  bellLabel: "Notifications",
637
85
  bellLabelWithUnread: (count) => `Notifications, ${count} unread`,
@@ -667,7 +115,7 @@ function getHermsInboxStrings(locale) {
667
115
  return dictionaries[locale ?? "en"] ?? en;
668
116
  }
669
117
 
670
- // src/react/HermsInbox.tsx
118
+ // src/HermsInbox.tsx
671
119
  import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
672
120
  var placementToSideAlign = {
673
121
  "bottom-start": { side: "bottom", align: "start" },
@@ -714,11 +162,12 @@ function HermsInbox({ placement = "bottom-end", onItemClick, theme, colorScheme
714
162
  const { client } = useHermsContext();
715
163
  const { unread, unseen } = useUnreadCount();
716
164
  const { items, isLoading, isLoadingMore, error, hasMore, loadMore, markRead, markAllRead, archive, refetch } = useInbox();
717
- const [open, setOpen] = useState4(false);
718
- const [activeIndex, setActiveIndex] = useState4(0);
719
- const itemRefs = useRef3([]);
720
- const seenIdsRef = useRef3(/* @__PURE__ */ new Set());
165
+ const [open, setOpen] = useState(false);
166
+ const [activeIndex, setActiveIndex] = useState(0);
167
+ const itemRefs = useRef([]);
168
+ const seenIdsRef = useRef(/* @__PURE__ */ new Set());
721
169
  const strings = getHermsInboxStrings(locale);
170
+ const titleId = useId();
722
171
  useEffect5(() => {
723
172
  if (!open || items.length === 0) return;
724
173
  const toMark = items.filter((item) => !seenIdsRef.current.has(item.id)).map((item) => item.id);
@@ -729,11 +178,11 @@ function HermsInbox({ placement = "bottom-end", onItemClick, theme, colorScheme
729
178
  useEffect5(() => {
730
179
  if (open) setActiveIndex(0);
731
180
  }, [open]);
732
- const focusItem = useCallback3((index) => {
181
+ const focusItem = useCallback((index) => {
733
182
  setActiveIndex(index);
734
183
  itemRefs.current[index]?.focus();
735
184
  }, []);
736
- const handleListKeyDown = useCallback3(
185
+ const handleListKeyDown = useCallback(
737
186
  (event) => {
738
187
  if (items.length === 0) return;
739
188
  switch (event.key) {
@@ -759,7 +208,7 @@ function HermsInbox({ placement = "bottom-end", onItemClick, theme, colorScheme
759
208
  },
760
209
  [items.length, activeIndex, focusItem]
761
210
  );
762
- const handleActivate = useCallback3(
211
+ const handleActivate = useCallback(
763
212
  (item) => {
764
213
  if (!item.readAt) void markRead(item.id);
765
214
  if (onItemClick) {
@@ -770,7 +219,7 @@ function HermsInbox({ placement = "bottom-end", onItemClick, theme, colorScheme
770
219
  },
771
220
  [markRead, onItemClick]
772
221
  );
773
- const rootStyle = useMemo2(() => {
222
+ const rootStyle = useMemo5(() => {
774
223
  const style = {};
775
224
  if (theme?.accent) style["--herms-color-accent"] = theme.accent;
776
225
  if (theme?.radius) style["--herms-radius"] = theme.radius;
@@ -778,7 +227,9 @@ function HermsInbox({ placement = "bottom-end", onItemClick, theme, colorScheme
778
227
  }, [theme?.accent, theme?.radius]);
779
228
  const { side, align } = placementToSideAlign[placement];
780
229
  const rootClassName = ["herms-inbox", className].filter(Boolean).join(" ");
781
- return /* @__PURE__ */ jsxs("div", { className: rootClassName, style: rootStyle, "data-herms-color-scheme": colorScheme === "auto" ? void 0 : colorScheme, children: [
230
+ const panelClassName = ["herms-inbox__panel", className].filter(Boolean).join(" ");
231
+ const colorSchemeAttribute = colorScheme === "auto" ? void 0 : colorScheme;
232
+ return /* @__PURE__ */ jsxs("div", { className: rootClassName, style: rootStyle, "data-herms-color-scheme": colorSchemeAttribute, children: [
782
233
  /* @__PURE__ */ jsx2("span", { className: "herms-inbox__visually-hidden", role: "status", "aria-live": "polite", children: unread > 0 ? strings.bellLabelWithUnread(unread) : "" }),
783
234
  /* @__PURE__ */ jsxs(Popover.Root, { open, onOpenChange: setOpen, children: [
784
235
  /* @__PURE__ */ jsx2(Popover.Trigger, { asChild: true, children: /* @__PURE__ */ jsxs(
@@ -797,7 +248,10 @@ function HermsInbox({ placement = "bottom-end", onItemClick, theme, colorScheme
797
248
  /* @__PURE__ */ jsx2(Popover.Portal, { children: /* @__PURE__ */ jsxs(
798
249
  Popover.Content,
799
250
  {
800
- className: "herms-inbox__panel",
251
+ className: panelClassName,
252
+ style: rootStyle,
253
+ "data-herms-color-scheme": colorSchemeAttribute,
254
+ "aria-labelledby": titleId,
801
255
  side,
802
256
  align,
803
257
  sideOffset: 8,
@@ -809,7 +263,7 @@ function HermsInbox({ placement = "bottom-end", onItemClick, theme, colorScheme
809
263
  },
810
264
  children: [
811
265
  /* @__PURE__ */ jsxs("div", { className: "herms-inbox__header", children: [
812
- /* @__PURE__ */ jsx2("p", { className: "herms-inbox__title", children: strings.panelTitle }),
266
+ /* @__PURE__ */ jsx2("p", { className: "herms-inbox__title", id: titleId, children: strings.panelTitle }),
813
267
  /* @__PURE__ */ jsxs("div", { className: "herms-inbox__header-actions", children: [
814
268
  /* @__PURE__ */ jsx2("button", { type: "button", className: "herms-inbox__text-button", onClick: () => void markAllRead(), disabled: unread === 0, children: strings.markAllRead }),
815
269
  /* @__PURE__ */ jsx2(Popover.Close, { asChild: true, children: /* @__PURE__ */ jsx2("button", { type: "button", className: "herms-inbox__icon-button", "aria-label": strings.close, children: /* @__PURE__ */ jsx2(CloseIcon, {}) }) })