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