@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.d.ts
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared, framework-agnostic shapes for `HermsClient` and every React binding
|
|
6
|
+
* built on top of it. Camel-cased on purpose. The wire (§6.6 `/v1/client/inbox/*`)
|
|
7
|
+
* is snake_case; `HermsClient` is the one place that translation happens, the same
|
|
8
|
+
* posture the dashboard's own hand-written API layer takes (`frontend/src/api/*.ts`)
|
|
9
|
+
* until generated OpenAPI types exist for either surface.
|
|
10
|
+
*/
|
|
11
|
+
interface HermsClientOptions {
|
|
12
|
+
/** `hm_pk_<env>_<random>`, safe to embed in a browser bundle (§6.2: "grant
|
|
13
|
+
* nothing by themselves"). */
|
|
14
|
+
publicKey: string;
|
|
15
|
+
/** Base URL of the client API, e.g. `https://api.hermesi.dev/v1/client` or,
|
|
16
|
+
* same-origin behind a dev proxy, `/v1/client`. No trailing slash required:
|
|
17
|
+
* normalised internally. */
|
|
18
|
+
apiBaseUrl: string;
|
|
19
|
+
/**
|
|
20
|
+
* Host-app-supplied. This SDK never has the secret key and never could
|
|
21
|
+
* (F-INB-2: a subscriber token is minted server-side, by the host's own
|
|
22
|
+
* backend). Called fresh before *every* request; never cached past what the
|
|
23
|
+
* host itself returns, since refreshing ahead of expiry is explicitly the
|
|
24
|
+
* host's job, not this client's (see `onTokenExpiring`).
|
|
25
|
+
*/
|
|
26
|
+
getSubscriberToken: () => string | Promise<string>;
|
|
27
|
+
/**
|
|
28
|
+
* Called proactively, once, some seconds before the *currently held* token's
|
|
29
|
+
* own `exp` claim (read back out of the token's payload segment client-side:
|
|
30
|
+
* it's HMAC-signed, not encrypted, so this never requires verifying the
|
|
31
|
+
* signature). This is a hook for the host app to refresh ahead of expiry rather
|
|
32
|
+
* than reactively after a `401`. Best-effort: a token whose payload can't be
|
|
33
|
+
* decoded (malformed, or a host app testing with a garbage string) simply
|
|
34
|
+
* never schedules a call.
|
|
35
|
+
*/
|
|
36
|
+
onTokenExpiring?: () => void;
|
|
37
|
+
}
|
|
38
|
+
type HermsInboxReadStatus = 'read' | 'unread';
|
|
39
|
+
interface HermsInboxCategory {
|
|
40
|
+
key: string;
|
|
41
|
+
name: string;
|
|
42
|
+
}
|
|
43
|
+
interface HermsInboxItem {
|
|
44
|
+
id: string;
|
|
45
|
+
title: string;
|
|
46
|
+
body: string;
|
|
47
|
+
actionUrl: string | null;
|
|
48
|
+
category: HermsInboxCategory | null;
|
|
49
|
+
seenAt: string | null;
|
|
50
|
+
readAt: string | null;
|
|
51
|
+
createdAt: string;
|
|
52
|
+
}
|
|
53
|
+
interface HermsInboxCounts {
|
|
54
|
+
unread: number;
|
|
55
|
+
unseen: number;
|
|
56
|
+
}
|
|
57
|
+
interface HermsInboxPage {
|
|
58
|
+
items: HermsInboxItem[];
|
|
59
|
+
hasMore: boolean;
|
|
60
|
+
nextCursor: string | null;
|
|
61
|
+
}
|
|
62
|
+
interface HermsInboxListParams {
|
|
63
|
+
/** Maps to `GET /inbox?read=<bool>`: `'read'` -> `read=true`, `'unread'` ->
|
|
64
|
+
* `read=false`, omitted -> both. */
|
|
65
|
+
status?: HermsInboxReadStatus;
|
|
66
|
+
/** Category key(s), `?category=` (repeatable server-side). */
|
|
67
|
+
category?: string | string[];
|
|
68
|
+
cursor?: string;
|
|
69
|
+
limit?: number;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Every channel Hermesi can hold an identity for.
|
|
73
|
+
*
|
|
74
|
+
* **Written out, and checked against the API by a test rather than trusted.** This
|
|
75
|
+
* union said `'push' | 'sms' | 'email' | 'in_app'` while the platform had grown to
|
|
76
|
+
* nine, so an integrator could not register a Telegram or Slack identity at all:
|
|
77
|
+
* TypeScript refused it at their call site, and no test here or in the dashboard had
|
|
78
|
+
* any reason to notice, because the demo page never registers a channel.
|
|
79
|
+
*
|
|
80
|
+
* It cannot be generated: this package is published standalone and must not depend on
|
|
81
|
+
* the repository's OpenAPI snapshot at build time. So `channelParity.test.ts` compares
|
|
82
|
+
* it to that snapshot's `Channel` enum instead. The list stays hand-written, and
|
|
83
|
+
* drifting from the server is what fails.
|
|
84
|
+
*
|
|
85
|
+
* `push` is included because the API accepts it. No adapter delivers it today, which is
|
|
86
|
+
* a deployment fact rather than a reason to refuse the identity.
|
|
87
|
+
*
|
|
88
|
+
* Exported as a value, not only a type: a host building a preference centre needs to
|
|
89
|
+
* iterate the channels at runtime, and deriving the type from the array means the two
|
|
90
|
+
* cannot disagree.
|
|
91
|
+
*/
|
|
92
|
+
declare const HERMS_CHANNELS: readonly ["in_app", "email", "push", "sms", "whatsapp", "telegram", "slack", "teams", "discord"];
|
|
93
|
+
type HermsChannel = (typeof HERMS_CHANNELS)[number];
|
|
94
|
+
interface HermsRegisterChannelParams {
|
|
95
|
+
channel: HermsChannel;
|
|
96
|
+
identifier: string;
|
|
97
|
+
metadata?: Record<string, unknown>;
|
|
98
|
+
}
|
|
99
|
+
/** One channel's setting, either globally or inside a category. `null` means the
|
|
100
|
+
* subscriber has expressed no preference and the category's or platform's default
|
|
101
|
+
* applies. It is not the same as `false`, and collapsing the two would silently
|
|
102
|
+
* opt somebody out of something they never declined. */
|
|
103
|
+
interface HermsChannelPreference {
|
|
104
|
+
channel: HermsChannel;
|
|
105
|
+
enabled: boolean | null;
|
|
106
|
+
}
|
|
107
|
+
interface HermsCategoryPreference {
|
|
108
|
+
categoryId: string;
|
|
109
|
+
key: string;
|
|
110
|
+
name: string;
|
|
111
|
+
/** A critical category is always delivered on every live channel and cannot be turned
|
|
112
|
+
* off. Render it, but not as a control: an affordance that refuses is worse than
|
|
113
|
+
* none. */
|
|
114
|
+
isCritical: boolean;
|
|
115
|
+
channels: HermsChannelPreference[];
|
|
116
|
+
emailEnabled: boolean | null;
|
|
117
|
+
inAppEnabled: boolean | null;
|
|
118
|
+
}
|
|
119
|
+
interface HermsPreferences {
|
|
120
|
+
/** For addressing the subscriber in a preference page's own copy. `null` when the
|
|
121
|
+
* application never supplied one. */
|
|
122
|
+
firstName: string | null;
|
|
123
|
+
/** Settings that apply across every category. */
|
|
124
|
+
globalChannels: HermsChannelPreference[];
|
|
125
|
+
/** `email` and `in_app` again, as scalars.
|
|
126
|
+
*
|
|
127
|
+
* Carried rather than dropped because the API publishes them, and dropping a
|
|
128
|
+
* published field is the defect `HermsApiError` had. They are the same values as
|
|
129
|
+
* the matching entries in `globalChannels`; read whichever suits, but do not treat
|
|
130
|
+
* a disagreement between them as meaningful. */
|
|
131
|
+
globalEmailEnabled: boolean | null;
|
|
132
|
+
globalInAppEnabled: boolean | null;
|
|
133
|
+
categories: HermsCategoryPreference[];
|
|
134
|
+
}
|
|
135
|
+
/** One setting to change. Omit `categoryId` to change the global setting for that
|
|
136
|
+
* channel; supply it to change the setting inside one category. */
|
|
137
|
+
interface HermsPreferenceUpdate {
|
|
138
|
+
channel: HermsChannel;
|
|
139
|
+
enabled: boolean;
|
|
140
|
+
categoryId?: string;
|
|
141
|
+
}
|
|
142
|
+
interface HermsItemCreatedEvent {
|
|
143
|
+
type: 'item.created';
|
|
144
|
+
data: {
|
|
145
|
+
id: string;
|
|
146
|
+
title: string;
|
|
147
|
+
created_at: string;
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
interface HermsCountsChangedEvent {
|
|
151
|
+
type: 'counts.changed';
|
|
152
|
+
data: HermsInboxCounts;
|
|
153
|
+
}
|
|
154
|
+
type HermsRealtimeEvent = HermsItemCreatedEvent | HermsCountsChangedEvent;
|
|
155
|
+
type HermsEventListener = (event: HermsRealtimeEvent) => void;
|
|
156
|
+
/**
|
|
157
|
+
* One specific problem inside an error.
|
|
158
|
+
*
|
|
159
|
+
* Open, not closed, because the API says so: most entries name a request field
|
|
160
|
+
* (`field` + `issue`), some name a dependency instead, and its own schema declares
|
|
161
|
+
* `additionalProperties` rather than publishing a shape a client would be right to
|
|
162
|
+
* reject on. Typing it closed here would reintroduce exactly that.
|
|
163
|
+
*/
|
|
164
|
+
type HermsErrorDetail = Record<string, unknown>;
|
|
165
|
+
/** Thrown for any failed request to the client API. It mirrors the server's error
|
|
166
|
+
* envelope where one was returned, and falls back to a generic shape when the body was
|
|
167
|
+
* not JSON at all, which is usually an upstream proxy's own HTML error page.
|
|
168
|
+
*
|
|
169
|
+
* `detail` and `docUrl` were missing for a long time while the API published both as
|
|
170
|
+
* required fields. A 422's per-field `detail` reached a consumer as a bare sentence,
|
|
171
|
+
* and `doc_url` (the one thing that lets an integrator resolve an error without
|
|
172
|
+
* asking anybody) never arrived at all. */
|
|
173
|
+
declare class HermsApiError extends Error {
|
|
174
|
+
readonly status: number;
|
|
175
|
+
readonly code: string;
|
|
176
|
+
readonly type: string;
|
|
177
|
+
readonly requestId: string;
|
|
178
|
+
/** Per-problem specifics. Possibly empty, never absent: the API's own words. */
|
|
179
|
+
readonly detail: readonly HermsErrorDetail[];
|
|
180
|
+
/** Documentation for this `code`, or `''` when the server sent none. */
|
|
181
|
+
readonly docUrl: string;
|
|
182
|
+
constructor(status: number, body: unknown);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
declare class HermsClient {
|
|
186
|
+
private readonly options;
|
|
187
|
+
private readonly baseUrl;
|
|
188
|
+
private expiryTimer;
|
|
189
|
+
private lastScheduledExp;
|
|
190
|
+
constructor(options: HermsClientOptions);
|
|
191
|
+
/** Every request goes through here: always a *fresh* call to
|
|
192
|
+
* `getSubscriberToken()` (never cached past what the host itself returns,
|
|
193
|
+
* per this class's own contract), which also re-arms `onTokenExpiring`
|
|
194
|
+
* against whatever token comes back. */
|
|
195
|
+
private getFreshToken;
|
|
196
|
+
private scheduleExpiryHook;
|
|
197
|
+
/** Stops the scheduled `onTokenExpiring` timer. Called from
|
|
198
|
+
* `HermsProvider`'s unmount cleanup so a torn-down client never fires the
|
|
199
|
+
* hook after the app has stopped caring. */
|
|
200
|
+
destroy(): void;
|
|
201
|
+
/**
|
|
202
|
+
* `expectsBody` is how this knows a missing response body is a failure rather than a
|
|
203
|
+
* normal outcome. It defaults to `true` because most calls here read a payload; the
|
|
204
|
+
* methods that resolve to `void` pass `false`.
|
|
205
|
+
*
|
|
206
|
+
* Without it the success path did no checking at all. A `200` carrying a proxy's HTML
|
|
207
|
+
* page, or a `204` where a body was expected, left `parsed` null and returned it as
|
|
208
|
+
* `T`, so `listInbox()` rejected with `TypeError: Cannot read properties of null
|
|
209
|
+
* (reading 'data')` and the stack pointed at this SDK instead of at whatever was
|
|
210
|
+
* actually in the way. The *error* path already handled exactly that case carefully;
|
|
211
|
+
* only the success path did not.
|
|
212
|
+
*/
|
|
213
|
+
private request;
|
|
214
|
+
listInbox(params?: HermsInboxListParams): Promise<HermsInboxPage>;
|
|
215
|
+
getCounts(): Promise<HermsInboxCounts>;
|
|
216
|
+
markRead(id: string): Promise<HermsInboxItem>;
|
|
217
|
+
markAllRead(): Promise<{
|
|
218
|
+
updated: number;
|
|
219
|
+
}>;
|
|
220
|
+
markSeen(ids: string[]): Promise<{
|
|
221
|
+
updated: number;
|
|
222
|
+
}>;
|
|
223
|
+
archive(id: string): Promise<HermsInboxItem>;
|
|
224
|
+
delete(id: string): Promise<void>;
|
|
225
|
+
registerChannel(params: HermsRegisterChannelParams): Promise<void>;
|
|
226
|
+
/**
|
|
227
|
+
* The subscriber's own notification settings, for rendering a preference centre.
|
|
228
|
+
*
|
|
229
|
+
* There is deliberately no `unsubscribe()` beside this, although the API exposes
|
|
230
|
+
* `POST /v1/client/unsubscribe`. That route is the target of a `List-Unsubscribe`
|
|
231
|
+
* one-click link (RFC 8058): its token is minted by Hermesi and embedded in an
|
|
232
|
+
* outgoing email's headers, and the API's own description says it is invoked by a
|
|
233
|
+
* mail client rather than by application code. Wrapping it here would invite a host
|
|
234
|
+
* to build an unsubscribe button on a token it cannot obtain. The equivalent here
|
|
235
|
+
* is `updatePreference({ channel: 'email', enabled: false })`.
|
|
236
|
+
*/
|
|
237
|
+
getPreferences(): Promise<HermsPreferences>;
|
|
238
|
+
/**
|
|
239
|
+
* Change one setting, and receive the whole updated state back.
|
|
240
|
+
*
|
|
241
|
+
* The server answers `PATCH` with the full preferences rather than an
|
|
242
|
+
* acknowledgement, so a caller never has to refetch, and never renders a view
|
|
243
|
+
* assembled from its own optimistic guess plus whatever else has changed since.
|
|
244
|
+
* Omit `categoryId` for the global setting; supply it to change one category.
|
|
245
|
+
*/
|
|
246
|
+
updatePreference(update: HermsPreferenceUpdate): Promise<HermsPreferences>;
|
|
247
|
+
/**
|
|
248
|
+
* **Path parameters, not a query string.** This sent
|
|
249
|
+
* `DELETE /channels?channel=...&identifier=...` for as long as the method has existed,
|
|
250
|
+
* and there is no `DELETE` on `/v1/client/channels` at all: the route is
|
|
251
|
+
* `/v1/client/channels/{channel}/{identifier}`, so every call 404'd. Nothing caught
|
|
252
|
+
* it: the reference integration never deregisters, and the package had no tests.
|
|
253
|
+
*
|
|
254
|
+
* `identifier` is encoded because it is somebody's address (an email, a phone
|
|
255
|
+
* number, a chat conversation id), and those carry `@`, `+` and `:`.
|
|
256
|
+
*/
|
|
257
|
+
deregisterChannel(channel: HermsChannel, identifier: string): Promise<void>;
|
|
258
|
+
/**
|
|
259
|
+
* Opens a live connection for real-time inbox events and returns an
|
|
260
|
+
* unsubscribe function. SSR-safe: `window`/`EventSource` are only read here,
|
|
261
|
+
* at call time, never at module or constructor time.
|
|
262
|
+
*
|
|
263
|
+
* - Prefers SSE (`GET /inbox/stream`) when `EventSource` exists.
|
|
264
|
+
* - Falls back to polling `getCounts()` every 60s (§6.6) when `EventSource`
|
|
265
|
+
* doesn't exist at all, or after `MAX_FAST_RECONNECT_ATTEMPTS` consecutive
|
|
266
|
+
* immediate reconnect failures (an invalid/expired token, or a network
|
|
267
|
+
* that blocks SSE outright). It still retries SSE in the background with
|
|
268
|
+
* exponential backoff rather than abandoning it permanently, so a fixed
|
|
269
|
+
* token/network problem self-heals without a page reload.
|
|
270
|
+
*/
|
|
271
|
+
subscribe(onEvent: HermsEventListener): () => void;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Pure, dependency-free helpers for the *reading* half of F-INB-2's subscriber
|
|
276
|
+
* token (§6.2). `HermsClient` never mints or verifies a signature (it never has
|
|
277
|
+
* the secret key hash to do either), it only needs to read the `exp` claim back
|
|
278
|
+
* out of a token the host app already handed it, to drive `onTokenExpiring`.
|
|
279
|
+
*
|
|
280
|
+
* Token shape (verbatim, `hermesi.core.security`'s "F-INB-2 subscriber token"
|
|
281
|
+
* section): `base64url(payload) + "." + base64url(hmac_sha256(...))`, where
|
|
282
|
+
* `payload` is a plain JSON object `{sub, env, exp}`. No JOSE/JWT header segment.
|
|
283
|
+
*/
|
|
284
|
+
/**
|
|
285
|
+
* Returns the token's `exp` claim (unix seconds) or `null` for anything that
|
|
286
|
+
* isn't a well-formed `<payload>.<signature>` token with a numeric `exp`. A
|
|
287
|
+
* malformed value never throws, it just means `onTokenExpiring` won't be
|
|
288
|
+
* scheduled for this particular token.
|
|
289
|
+
*/
|
|
290
|
+
declare function decodeSubscriberTokenExp(token: string): number | null;
|
|
291
|
+
|
|
292
|
+
interface HermsContextValue {
|
|
293
|
+
client: HermsClient;
|
|
294
|
+
/** Registers `listener` against the *one* `client.subscribe(...)` connection
|
|
295
|
+
* this provider owns for the whole subtree, returning an unsubscribe
|
|
296
|
+
* function. Every `useUnreadCount`/`useInbox` consumer calls this instead
|
|
297
|
+
* of opening its own `EventSource`/poll loop. */
|
|
298
|
+
addEventListener: (listener: HermsEventListener) => () => void;
|
|
299
|
+
}
|
|
300
|
+
interface HermsProviderProps {
|
|
301
|
+
client: HermsClient;
|
|
302
|
+
children: ReactNode;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* `<HermsProvider client={new HermsClient({...})}>`: one `HermsClient`
|
|
306
|
+
* instance for the whole subtree. Opens the client's real-time subscription
|
|
307
|
+
* once on mount and tears it (and the client's own `onTokenExpiring` timer)
|
|
308
|
+
* down on unmount; every listener registered via `addEventListener` below
|
|
309
|
+
* shares that single connection rather than each hook paying for its own.
|
|
310
|
+
*
|
|
311
|
+
* `client` is expected to be a stable reference (constructed once, e.g. in
|
|
312
|
+
* `useMemo`/module scope in the host app). Passing a new instance on every
|
|
313
|
+
* render tears down and reopens the underlying connection every render.
|
|
314
|
+
*/
|
|
315
|
+
declare function HermsProvider({ client, children }: HermsProviderProps): react.JSX.Element;
|
|
316
|
+
/** Every hook/component in this package throws this same error outside a
|
|
317
|
+
* `<HermsProvider>`: there is no sensible default client to fall back to. */
|
|
318
|
+
declare function useHermsContext(): HermsContextValue;
|
|
319
|
+
|
|
320
|
+
interface UseUnreadCountResult extends HermsInboxCounts {
|
|
321
|
+
isLoading: boolean;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Headless. `{unread, unseen, isLoading}`, kept live via the provider's
|
|
325
|
+
* shared subscription: one `getCounts()` call on mount, then recomputed on
|
|
326
|
+
* every `counts.changed` event (real-time push, or the client's own 60s
|
|
327
|
+
* polling fallback) for as long as this component stays mounted.
|
|
328
|
+
*/
|
|
329
|
+
declare function useUnreadCount(): UseUnreadCountResult;
|
|
330
|
+
|
|
331
|
+
interface UseInboxOptions {
|
|
332
|
+
status?: HermsInboxReadStatus;
|
|
333
|
+
category?: string;
|
|
334
|
+
}
|
|
335
|
+
interface UseInboxResult {
|
|
336
|
+
items: HermsInboxItem[];
|
|
337
|
+
isLoading: boolean;
|
|
338
|
+
isLoadingMore: boolean;
|
|
339
|
+
error: Error | null;
|
|
340
|
+
hasMore: boolean;
|
|
341
|
+
loadMore: () => void;
|
|
342
|
+
markRead: (id: string) => Promise<void>;
|
|
343
|
+
markAllRead: () => Promise<void>;
|
|
344
|
+
archive: (id: string) => Promise<void>;
|
|
345
|
+
remove: (id: string) => Promise<void>;
|
|
346
|
+
refetch: () => void;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Headless, cursor-paginated inbox list state: thin wrappers around
|
|
350
|
+
* `HermsClient`'s own methods that also patch local state, so a consumer
|
|
351
|
+
* never has to manually refetch the whole list after every mutating action.
|
|
352
|
+
*
|
|
353
|
+
* Live-updated on `item.created`: the new item is prepended immediately from
|
|
354
|
+
* the (deliberately minimal: `{id, title, created_at}`, §6.6) SSE payload so
|
|
355
|
+
* the UI reacts instantly, then reconciled against a fresh first page fetched
|
|
356
|
+
* in the background so the authoritative row (body, category, action_url)
|
|
357
|
+
* lands without a full list reload/flicker.
|
|
358
|
+
*/
|
|
359
|
+
declare function useInbox(options?: UseInboxOptions): UseInboxResult;
|
|
360
|
+
|
|
361
|
+
interface UsePreferencesResult {
|
|
362
|
+
/** `null` until the first read resolves. */
|
|
363
|
+
preferences: HermsPreferences | null;
|
|
364
|
+
isLoading: boolean;
|
|
365
|
+
/** The failure of the last read or write, or `null`. Kept rather than thrown so a
|
|
366
|
+
* preference centre can show it beside the controls instead of unmounting them. */
|
|
367
|
+
error: Error | null;
|
|
368
|
+
/** Change one setting. Resolves once the server's new state has been applied. */
|
|
369
|
+
setPreference: (update: HermsPreferenceUpdate) => Promise<void>;
|
|
370
|
+
/** Re-read, for a retry button. */
|
|
371
|
+
reload: () => Promise<void>;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Headless preferences, the counterpart to `useInbox`.
|
|
375
|
+
*
|
|
376
|
+
* **No optimistic update, deliberately.** A toggle here is not a like button: turning
|
|
377
|
+
* off a channel is a consent decision, and showing it as done before the server agreed
|
|
378
|
+
* is showing somebody they have opted out when they may not have. The server answers
|
|
379
|
+
* `PATCH` with the *whole* updated state, so the correct view arrives with the write
|
|
380
|
+
* and there is nothing to reconcile. A per-row pending state in the host is a better
|
|
381
|
+
* answer to the latency than a lie.
|
|
382
|
+
*
|
|
383
|
+
* **`error` is state, not an exception.** `setPreference` still rejects, so a caller
|
|
384
|
+
* can `await` it and react per-control, but the hook also holds the failure so a
|
|
385
|
+
* preference centre can render it without every consumer writing the same try/catch.
|
|
386
|
+
* A failed write leaves the last known-good state on screen rather than an empty page.
|
|
387
|
+
*
|
|
388
|
+
* Critical categories arrive with `isCritical: true` and are always delivered. Render
|
|
389
|
+
* them (a subscriber should see what they are receiving), but not as a control: an
|
|
390
|
+
* affordance that refuses is worse than none.
|
|
391
|
+
*/
|
|
392
|
+
declare function usePreferences(): UsePreferencesResult;
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* `<HermsInbox />`'s own tiny, self-contained EN/FR string table.
|
|
396
|
+
*
|
|
397
|
+
* Deliberately **not** i18next: this package ships to arbitrary host apps
|
|
398
|
+
* (§8.5: "zero hard dependency on the host app's state library") that may run
|
|
399
|
+
* their own i18n stack, a different one, or none at all. Pulling i18next in
|
|
400
|
+
* as a real dependency here would be exactly the kind of host-app-shaped
|
|
401
|
+
* assumption the SDK is supposed to avoid, for a handful of short strings.
|
|
402
|
+
* `locale` mirrors §8.5's own `<HermsProvider locale="fr">` example prop.
|
|
403
|
+
*/
|
|
404
|
+
type HermsLocale = 'en' | 'fr';
|
|
405
|
+
|
|
406
|
+
type HermsInboxPlacement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
|
|
407
|
+
interface HermsInboxTheme {
|
|
408
|
+
accent?: string;
|
|
409
|
+
radius?: string;
|
|
410
|
+
}
|
|
411
|
+
interface HermsInboxProps {
|
|
412
|
+
/** Popover position relative to the bell trigger. Default `bottom-end`. */
|
|
413
|
+
placement?: HermsInboxPlacement;
|
|
414
|
+
/** Called when an item is activated (click, or Enter on a focused row).
|
|
415
|
+
* Lets a host app drive its own router (§8.5's own example:
|
|
416
|
+
* `onItemClick={(item) => navigate(item.actionUrl)}`) instead of a hard
|
|
417
|
+
* page navigation. When omitted, activating an item with a non-null
|
|
418
|
+
* `actionUrl` does a plain `window.location.assign` to it. Either way, the
|
|
419
|
+
* item is marked read first. */
|
|
420
|
+
onItemClick?: (item: HermsInboxItem) => void;
|
|
421
|
+
/** A couple of the most commonly re-themed CSS custom properties, settable
|
|
422
|
+
* without a host app writing any CSS of its own. Anything else is
|
|
423
|
+
* reachable by overriding `--herms-color-*`/`--herms-radius` variables
|
|
424
|
+
* directly (see `HermsInbox.css`). */
|
|
425
|
+
theme?: HermsInboxTheme;
|
|
426
|
+
/** `'auto'` (default) follows the visitor's OS `prefers-color-scheme`;
|
|
427
|
+
* `'light'`/`'dark'` force one regardless of it. */
|
|
428
|
+
colorScheme?: 'auto' | 'light' | 'dark';
|
|
429
|
+
locale?: HermsLocale;
|
|
430
|
+
className?: string;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* `<HermsInbox />`: bell trigger + dropdown panel (§4.8/F-INB-5). Built
|
|
434
|
+
* entirely on the headless `useInbox`/`useUnreadCount` hooks; every state
|
|
435
|
+
* (loading, empty, error, populated) renders here, none of it invented by a
|
|
436
|
+
* host app. Accessible: a real `<button>` trigger with `aria-label`/
|
|
437
|
+
* `aria-expanded`, the panel as `role="menu"` with roving-tabindex arrow-key
|
|
438
|
+
* navigation, `Home`/`End`, `Escape`-to-close (native to Radix `Popover`,
|
|
439
|
+
* which also returns focus to the trigger), and `Enter`/`Space` (native
|
|
440
|
+
* `<button>` behavior) to activate the focused item.
|
|
441
|
+
*/
|
|
442
|
+
declare function HermsInbox({ placement, onItemClick, theme, colorScheme, locale, className }: HermsInboxProps): react.JSX.Element;
|
|
443
|
+
|
|
444
|
+
export { HERMS_CHANNELS, HermsApiError, type HermsCategoryPreference, type HermsChannel, type HermsChannelPreference, HermsClient, type HermsClientOptions, type HermsErrorDetail, type HermsEventListener, HermsInbox, type HermsInboxCategory, type HermsInboxCounts, type HermsInboxItem, type HermsInboxListParams, type HermsInboxPage, type HermsInboxPlacement, type HermsInboxProps, type HermsInboxReadStatus, type HermsInboxTheme, type HermsLocale, type HermsPreferenceUpdate, type HermsPreferences, HermsProvider, type HermsProviderProps, type HermsRealtimeEvent, type HermsRegisterChannelParams, type UseInboxOptions, type UseInboxResult, type UsePreferencesResult, type UseUnreadCountResult, decodeSubscriberTokenExp, useHermsContext, useInbox, usePreferences, useUnreadCount };
|