@hermesihq/react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/dist/index.cjs +924 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +280 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.cts +444 -0
- package/dist/index.d.ts +444 -0
- package/dist/index.js +878 -0
- package/dist/index.js.map +1 -0
- package/package.json +71 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,878 @@
|
|
|
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
|
+
};
|
|
402
|
+
|
|
403
|
+
// src/react/HermsProvider.tsx
|
|
404
|
+
import { createContext, useContext, useEffect, useMemo, useRef } from "react";
|
|
405
|
+
import { jsx } from "react/jsx-runtime";
|
|
406
|
+
var HermsContext = createContext(null);
|
|
407
|
+
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]);
|
|
418
|
+
const value = useMemo(
|
|
419
|
+
() => ({
|
|
420
|
+
client,
|
|
421
|
+
addEventListener: (listener) => {
|
|
422
|
+
listenersRef.current.add(listener);
|
|
423
|
+
return () => listenersRef.current.delete(listener);
|
|
424
|
+
}
|
|
425
|
+
}),
|
|
426
|
+
[client]
|
|
427
|
+
);
|
|
428
|
+
return /* @__PURE__ */ jsx(HermsContext.Provider, { value, children });
|
|
429
|
+
}
|
|
430
|
+
function useHermsContext() {
|
|
431
|
+
const ctx = useContext(HermsContext);
|
|
432
|
+
if (!ctx) {
|
|
433
|
+
throw new Error("@hermesihq/react: this hook/component must be rendered inside <HermsProvider client={...}>.");
|
|
434
|
+
}
|
|
435
|
+
return ctx;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// src/react/useUnreadCount.ts
|
|
439
|
+
import { useEffect as useEffect2, useState } from "react";
|
|
440
|
+
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 };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// src/react/useInbox.ts
|
|
467
|
+
import { useCallback, useEffect as useEffect3, useRef as useRef2, useState as useState2 } from "react";
|
|
468
|
+
function useInbox(options = {}) {
|
|
469
|
+
const { client, addEventListener } = useHermsContext();
|
|
470
|
+
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]);
|
|
494
|
+
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();
|
|
535
|
+
}),
|
|
536
|
+
[addEventListener, reconcileFirstPage]
|
|
537
|
+
);
|
|
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
|
+
}
|
|
578
|
+
|
|
579
|
+
// src/react/usePreferences.ts
|
|
580
|
+
import { useCallback as useCallback2, useEffect as useEffect4, useState as useState3 } from "react";
|
|
581
|
+
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]
|
|
626
|
+
);
|
|
627
|
+
return { preferences, isLoading, error, setPreference, reload: load };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// src/react/HermsInbox.tsx
|
|
631
|
+
import { useCallback as useCallback3, useEffect as useEffect5, useMemo as useMemo2, useRef as useRef3, useState as useState4 } from "react";
|
|
632
|
+
import * as Popover from "@radix-ui/react-popover";
|
|
633
|
+
|
|
634
|
+
// src/react/locale.ts
|
|
635
|
+
var en = {
|
|
636
|
+
bellLabel: "Notifications",
|
|
637
|
+
bellLabelWithUnread: (count) => `Notifications, ${count} unread`,
|
|
638
|
+
panelTitle: "Notifications",
|
|
639
|
+
markAllRead: "Mark all as read",
|
|
640
|
+
close: "Close",
|
|
641
|
+
loading: "Loading notifications\u2026",
|
|
642
|
+
empty: "You're all caught up",
|
|
643
|
+
errorTitle: "Couldn't load notifications.",
|
|
644
|
+
retry: "Retry",
|
|
645
|
+
loadMore: "Load more",
|
|
646
|
+
loadingMore: "Loading\u2026",
|
|
647
|
+
archive: "Archive",
|
|
648
|
+
unreadDot: "Unread"
|
|
649
|
+
};
|
|
650
|
+
var fr = {
|
|
651
|
+
bellLabel: "Notifications",
|
|
652
|
+
bellLabelWithUnread: (count) => `Notifications, ${count} non lues`,
|
|
653
|
+
panelTitle: "Notifications",
|
|
654
|
+
markAllRead: "Tout marquer comme lu",
|
|
655
|
+
close: "Fermer",
|
|
656
|
+
loading: "Chargement des notifications\u2026",
|
|
657
|
+
empty: "Vous \xEAtes \xE0 jour",
|
|
658
|
+
errorTitle: "Impossible de charger les notifications.",
|
|
659
|
+
retry: "R\xE9essayer",
|
|
660
|
+
loadMore: "Charger plus",
|
|
661
|
+
loadingMore: "Chargement\u2026",
|
|
662
|
+
archive: "Archiver",
|
|
663
|
+
unreadDot: "Non lu"
|
|
664
|
+
};
|
|
665
|
+
var dictionaries = { en, fr };
|
|
666
|
+
function getHermsInboxStrings(locale) {
|
|
667
|
+
return dictionaries[locale ?? "en"] ?? en;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// src/react/HermsInbox.tsx
|
|
671
|
+
import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
|
|
672
|
+
var placementToSideAlign = {
|
|
673
|
+
"bottom-start": { side: "bottom", align: "start" },
|
|
674
|
+
"bottom-end": { side: "bottom", align: "end" },
|
|
675
|
+
"top-start": { side: "top", align: "start" },
|
|
676
|
+
"top-end": { side: "top", align: "end" }
|
|
677
|
+
};
|
|
678
|
+
function BellIcon() {
|
|
679
|
+
return /* @__PURE__ */ jsxs("svg", { className: "herms-inbox__bell", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
|
|
680
|
+
/* @__PURE__ */ jsx2("path", { d: "M6 8a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6" }),
|
|
681
|
+
/* @__PURE__ */ jsx2("path", { d: "M10 21a2 2 0 0 0 4 0" })
|
|
682
|
+
] });
|
|
683
|
+
}
|
|
684
|
+
function CloseIcon() {
|
|
685
|
+
return /* @__PURE__ */ jsx2("svg", { viewBox: "0 0 24 24", width: "14", height: "14", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx2("path", { d: "M18 6 6 18M6 6l12 12" }) });
|
|
686
|
+
}
|
|
687
|
+
function ArchiveIcon() {
|
|
688
|
+
return /* @__PURE__ */ jsxs("svg", { viewBox: "0 0 24 24", width: "15", height: "15", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
|
|
689
|
+
/* @__PURE__ */ jsx2("rect", { x: "3", y: "4", width: "18", height: "4", rx: "1" }),
|
|
690
|
+
/* @__PURE__ */ jsx2("path", { d: "M5 8v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8" }),
|
|
691
|
+
/* @__PURE__ */ jsx2("path", { d: "M10 13h4" })
|
|
692
|
+
] });
|
|
693
|
+
}
|
|
694
|
+
function AlertIcon() {
|
|
695
|
+
return /* @__PURE__ */ jsxs("svg", { viewBox: "0 0 24 24", width: "20", height: "20", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
|
|
696
|
+
/* @__PURE__ */ jsx2("circle", { cx: "12", cy: "12", r: "9" }),
|
|
697
|
+
/* @__PURE__ */ jsx2("path", { d: "M12 8v5M12 16h.01" })
|
|
698
|
+
] });
|
|
699
|
+
}
|
|
700
|
+
function relativeTime(iso, locale) {
|
|
701
|
+
const then = new Date(iso).getTime();
|
|
702
|
+
if (Number.isNaN(then)) return "";
|
|
703
|
+
const diffSeconds = Math.max(0, Math.round((Date.now() - then) / 1e3));
|
|
704
|
+
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
|
705
|
+
if (diffSeconds < 60) return formatter.format(0, "minute");
|
|
706
|
+
const diffMinutes = Math.round(diffSeconds / 60);
|
|
707
|
+
if (diffMinutes < 60) return formatter.format(-diffMinutes, "minute");
|
|
708
|
+
const diffHours = Math.round(diffMinutes / 60);
|
|
709
|
+
if (diffHours < 24) return formatter.format(-diffHours, "hour");
|
|
710
|
+
const diffDays = Math.round(diffHours / 24);
|
|
711
|
+
return formatter.format(-diffDays, "day");
|
|
712
|
+
}
|
|
713
|
+
function HermsInbox({ placement = "bottom-end", onItemClick, theme, colorScheme = "auto", locale, className }) {
|
|
714
|
+
const { client } = useHermsContext();
|
|
715
|
+
const { unread, unseen } = useUnreadCount();
|
|
716
|
+
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());
|
|
721
|
+
const strings = getHermsInboxStrings(locale);
|
|
722
|
+
useEffect5(() => {
|
|
723
|
+
if (!open || items.length === 0) return;
|
|
724
|
+
const toMark = items.filter((item) => !seenIdsRef.current.has(item.id)).map((item) => item.id);
|
|
725
|
+
if (toMark.length === 0) return;
|
|
726
|
+
for (const id of toMark) seenIdsRef.current.add(id);
|
|
727
|
+
void client.markSeen(toMark);
|
|
728
|
+
}, [open, items, client]);
|
|
729
|
+
useEffect5(() => {
|
|
730
|
+
if (open) setActiveIndex(0);
|
|
731
|
+
}, [open]);
|
|
732
|
+
const focusItem = useCallback3((index) => {
|
|
733
|
+
setActiveIndex(index);
|
|
734
|
+
itemRefs.current[index]?.focus();
|
|
735
|
+
}, []);
|
|
736
|
+
const handleListKeyDown = useCallback3(
|
|
737
|
+
(event) => {
|
|
738
|
+
if (items.length === 0) return;
|
|
739
|
+
switch (event.key) {
|
|
740
|
+
case "ArrowDown":
|
|
741
|
+
event.preventDefault();
|
|
742
|
+
focusItem(Math.min(activeIndex + 1, items.length - 1));
|
|
743
|
+
break;
|
|
744
|
+
case "ArrowUp":
|
|
745
|
+
event.preventDefault();
|
|
746
|
+
focusItem(Math.max(activeIndex - 1, 0));
|
|
747
|
+
break;
|
|
748
|
+
case "Home":
|
|
749
|
+
event.preventDefault();
|
|
750
|
+
focusItem(0);
|
|
751
|
+
break;
|
|
752
|
+
case "End":
|
|
753
|
+
event.preventDefault();
|
|
754
|
+
focusItem(items.length - 1);
|
|
755
|
+
break;
|
|
756
|
+
default:
|
|
757
|
+
break;
|
|
758
|
+
}
|
|
759
|
+
},
|
|
760
|
+
[items.length, activeIndex, focusItem]
|
|
761
|
+
);
|
|
762
|
+
const handleActivate = useCallback3(
|
|
763
|
+
(item) => {
|
|
764
|
+
if (!item.readAt) void markRead(item.id);
|
|
765
|
+
if (onItemClick) {
|
|
766
|
+
onItemClick(item);
|
|
767
|
+
} else if (item.actionUrl && typeof window !== "undefined") {
|
|
768
|
+
window.location.assign(item.actionUrl);
|
|
769
|
+
}
|
|
770
|
+
},
|
|
771
|
+
[markRead, onItemClick]
|
|
772
|
+
);
|
|
773
|
+
const rootStyle = useMemo2(() => {
|
|
774
|
+
const style = {};
|
|
775
|
+
if (theme?.accent) style["--herms-color-accent"] = theme.accent;
|
|
776
|
+
if (theme?.radius) style["--herms-radius"] = theme.radius;
|
|
777
|
+
return style;
|
|
778
|
+
}, [theme?.accent, theme?.radius]);
|
|
779
|
+
const { side, align } = placementToSideAlign[placement];
|
|
780
|
+
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: [
|
|
782
|
+
/* @__PURE__ */ jsx2("span", { className: "herms-inbox__visually-hidden", role: "status", "aria-live": "polite", children: unread > 0 ? strings.bellLabelWithUnread(unread) : "" }),
|
|
783
|
+
/* @__PURE__ */ jsxs(Popover.Root, { open, onOpenChange: setOpen, children: [
|
|
784
|
+
/* @__PURE__ */ jsx2(Popover.Trigger, { asChild: true, children: /* @__PURE__ */ jsxs(
|
|
785
|
+
"button",
|
|
786
|
+
{
|
|
787
|
+
type: "button",
|
|
788
|
+
className: "herms-inbox__trigger",
|
|
789
|
+
"aria-label": unread > 0 ? strings.bellLabelWithUnread(unread) : strings.bellLabel,
|
|
790
|
+
"aria-expanded": open,
|
|
791
|
+
children: [
|
|
792
|
+
/* @__PURE__ */ jsx2(BellIcon, {}),
|
|
793
|
+
unseen > 0 && /* @__PURE__ */ jsx2("span", { className: "herms-inbox__badge", "aria-hidden": "true", children: unseen > 99 ? "99+" : unseen })
|
|
794
|
+
]
|
|
795
|
+
}
|
|
796
|
+
) }),
|
|
797
|
+
/* @__PURE__ */ jsx2(Popover.Portal, { children: /* @__PURE__ */ jsxs(
|
|
798
|
+
Popover.Content,
|
|
799
|
+
{
|
|
800
|
+
className: "herms-inbox__panel",
|
|
801
|
+
side,
|
|
802
|
+
align,
|
|
803
|
+
sideOffset: 8,
|
|
804
|
+
onOpenAutoFocus: (event) => {
|
|
805
|
+
if (items.length > 0) {
|
|
806
|
+
event.preventDefault();
|
|
807
|
+
itemRefs.current[0]?.focus();
|
|
808
|
+
}
|
|
809
|
+
},
|
|
810
|
+
children: [
|
|
811
|
+
/* @__PURE__ */ jsxs("div", { className: "herms-inbox__header", children: [
|
|
812
|
+
/* @__PURE__ */ jsx2("p", { className: "herms-inbox__title", children: strings.panelTitle }),
|
|
813
|
+
/* @__PURE__ */ jsxs("div", { className: "herms-inbox__header-actions", children: [
|
|
814
|
+
/* @__PURE__ */ jsx2("button", { type: "button", className: "herms-inbox__text-button", onClick: () => void markAllRead(), disabled: unread === 0, children: strings.markAllRead }),
|
|
815
|
+
/* @__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, {}) }) })
|
|
816
|
+
] })
|
|
817
|
+
] }),
|
|
818
|
+
isLoading ? /* @__PURE__ */ jsx2("div", { role: "status", "aria-label": strings.loading, children: [0, 1, 2, 3].map((i) => /* @__PURE__ */ jsx2("div", { className: "herms-inbox__skeleton-line", style: { width: i % 2 === 0 ? "85%" : "65%" } }, i)) }) : error ? /* @__PURE__ */ jsxs("div", { className: "herms-inbox__state", role: "alert", children: [
|
|
819
|
+
/* @__PURE__ */ jsx2(AlertIcon, {}),
|
|
820
|
+
/* @__PURE__ */ jsx2("p", { className: "herms-inbox__state-title", children: strings.errorTitle }),
|
|
821
|
+
/* @__PURE__ */ jsx2("button", { type: "button", className: "herms-inbox__text-button", onClick: () => refetch(), children: strings.retry })
|
|
822
|
+
] }) : items.length === 0 ? /* @__PURE__ */ jsx2("div", { className: "herms-inbox__state", children: strings.empty }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
823
|
+
/* @__PURE__ */ jsx2("ul", { className: "herms-inbox__list", role: "menu", "aria-label": strings.panelTitle, onKeyDown: handleListKeyDown, children: items.map((item, index) => /* @__PURE__ */ jsxs("li", { className: "herms-inbox__list-row", role: "none", children: [
|
|
824
|
+
/* @__PURE__ */ jsxs(
|
|
825
|
+
"button",
|
|
826
|
+
{
|
|
827
|
+
type: "button",
|
|
828
|
+
role: "menuitem",
|
|
829
|
+
ref: (el) => {
|
|
830
|
+
itemRefs.current[index] = el;
|
|
831
|
+
},
|
|
832
|
+
tabIndex: index === activeIndex ? 0 : -1,
|
|
833
|
+
className: "herms-inbox__item",
|
|
834
|
+
"data-unread": !item.readAt,
|
|
835
|
+
onFocus: () => setActiveIndex(index),
|
|
836
|
+
onClick: () => handleActivate(item),
|
|
837
|
+
children: [
|
|
838
|
+
/* @__PURE__ */ jsx2("span", { className: "herms-inbox__item-title", children: item.title }),
|
|
839
|
+
item.body && /* @__PURE__ */ jsx2("span", { className: "herms-inbox__item-body", children: item.body }),
|
|
840
|
+
/* @__PURE__ */ jsx2("span", { className: "herms-inbox__item-meta", children: relativeTime(item.createdAt, locale ?? "en") })
|
|
841
|
+
]
|
|
842
|
+
}
|
|
843
|
+
),
|
|
844
|
+
/* @__PURE__ */ jsx2(
|
|
845
|
+
"button",
|
|
846
|
+
{
|
|
847
|
+
type: "button",
|
|
848
|
+
className: "herms-inbox__archive-button",
|
|
849
|
+
"aria-label": `${strings.archive}: ${item.title}`,
|
|
850
|
+
onClick: (event) => {
|
|
851
|
+
event.stopPropagation();
|
|
852
|
+
void archive(item.id);
|
|
853
|
+
},
|
|
854
|
+
children: /* @__PURE__ */ jsx2(ArchiveIcon, {})
|
|
855
|
+
}
|
|
856
|
+
)
|
|
857
|
+
] }, item.id)) }),
|
|
858
|
+
hasMore && /* @__PURE__ */ jsx2("button", { type: "button", className: "herms-inbox__load-more", onClick: loadMore, disabled: isLoadingMore, children: isLoadingMore ? strings.loadingMore : strings.loadMore })
|
|
859
|
+
] })
|
|
860
|
+
]
|
|
861
|
+
}
|
|
862
|
+
) })
|
|
863
|
+
] })
|
|
864
|
+
] });
|
|
865
|
+
}
|
|
866
|
+
export {
|
|
867
|
+
HERMS_CHANNELS,
|
|
868
|
+
HermsApiError,
|
|
869
|
+
HermsClient,
|
|
870
|
+
HermsInbox,
|
|
871
|
+
HermsProvider,
|
|
872
|
+
decodeSubscriberTokenExp,
|
|
873
|
+
useHermsContext,
|
|
874
|
+
useInbox,
|
|
875
|
+
usePreferences,
|
|
876
|
+
useUnreadCount
|
|
877
|
+
};
|
|
878
|
+
//# sourceMappingURL=index.js.map
|