@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/CHANGELOG.md +138 -87
- package/README.md +122 -128
- package/dist/index.cjs +58 -606
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +14 -7
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +54 -330
- package/dist/index.d.ts +54 -330
- package/dist/index.js +68 -614
- package/dist/index.js.map +1 -1
- package/package.json +67 -71
package/dist/index.d.cts
CHANGED
|
@@ -1,330 +1,47 @@
|
|
|
1
|
+
import { HermsClient, HermsStoreHost, CountsState, HermsInboxReadStatus, HermsInboxItem, HermsPreferences, HermsPreferenceUpdate } from '@hermesihq/js';
|
|
2
|
+
export { HERMS_CHANNELS, HermsApiError, HermsCategoryPreference, HermsChannel, HermsChannelPreference, HermsClient, HermsClientOptions, HermsErrorDetail, HermsEventListener, HermsInboxCategory, HermsInboxCounts, HermsInboxItem, HermsInboxListParams, HermsInboxPage, HermsInboxReadStatus, HermsPreferenceUpdate, HermsPreferences, HermsRealtimeEvent, HermsRegisterChannelParams, decodeSubscriberTokenExp } from '@hermesihq/js';
|
|
1
3
|
import * as react from 'react';
|
|
2
4
|
import { ReactNode } from 'react';
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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.
|
|
7
|
+
* What every hook in this package reads from context. Exactly the shape the stores take
|
|
8
|
+
* as their host, so a hook hands this straight to the store it creates.
|
|
10
9
|
*/
|
|
11
|
-
|
|
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
|
-
}
|
|
10
|
+
type HermsContextValue = HermsStoreHost;
|
|
300
11
|
interface HermsProviderProps {
|
|
301
12
|
client: HermsClient;
|
|
302
13
|
children: ReactNode;
|
|
303
14
|
}
|
|
304
15
|
/**
|
|
305
|
-
* `<HermsProvider client={new HermsClient({...})}>`: one `HermsClient`
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
16
|
+
* `<HermsProvider client={new HermsClient({...})}>`: one `HermsClient` instance for the
|
|
17
|
+
* whole subtree. Opens the client's real-time subscription once on mount and tears it
|
|
18
|
+
* (and the client's own `onTokenExpiring` timer) down on unmount; every listener
|
|
19
|
+
* registered via `addEventListener` shares that single connection rather than each hook
|
|
20
|
+
* paying for its own.
|
|
21
|
+
*
|
|
22
|
+
* The connection itself is `HermsSession`, which knows nothing about React: this
|
|
23
|
+
* component is the few lines that tie its lifetime to a mount. That is deliberate, and it
|
|
24
|
+
* is the same session a Vue or vanilla page uses.
|
|
310
25
|
*
|
|
311
26
|
* `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
|
-
*
|
|
27
|
+
* `useMemo`/module scope in the host app). Passing a new instance on every render tears
|
|
28
|
+
* down and reopens the underlying connection every render.
|
|
314
29
|
*/
|
|
315
30
|
declare function HermsProvider({ client, children }: HermsProviderProps): react.JSX.Element;
|
|
316
31
|
/** Every hook/component in this package throws this same error outside a
|
|
317
32
|
* `<HermsProvider>`: there is no sensible default client to fall back to. */
|
|
318
33
|
declare function useHermsContext(): HermsContextValue;
|
|
319
34
|
|
|
320
|
-
|
|
321
|
-
isLoading: boolean;
|
|
322
|
-
}
|
|
35
|
+
type UseUnreadCountResult = CountsState;
|
|
323
36
|
/**
|
|
324
|
-
* Headless. `{unread, unseen, isLoading}`, kept live via the provider's
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
37
|
+
* Headless. `{unread, unseen, isLoading}`, kept live via the provider's shared
|
|
38
|
+
* subscription: one `getCounts()` call on mount, then recomputed on every
|
|
39
|
+
* `counts.changed` event (real-time push, or the client's own 60s polling fallback) for
|
|
40
|
+
* as long as this component stays mounted.
|
|
41
|
+
*
|
|
42
|
+
* A binding over `CountsStore`, which holds all of that. The store's constructor does
|
|
43
|
+
* nothing, so React discarding a memoised one costs nothing: it is the `connect()` in the
|
|
44
|
+
* effect that starts work.
|
|
328
45
|
*/
|
|
329
46
|
declare function useUnreadCount(): UseUnreadCountResult;
|
|
330
47
|
|
|
@@ -346,15 +63,20 @@ interface UseInboxResult {
|
|
|
346
63
|
refetch: () => void;
|
|
347
64
|
}
|
|
348
65
|
/**
|
|
349
|
-
* Headless, cursor-paginated inbox list state: thin wrappers around
|
|
350
|
-
*
|
|
351
|
-
*
|
|
66
|
+
* Headless, cursor-paginated inbox list state: thin wrappers around `HermsClient`'s own
|
|
67
|
+
* methods that also patch local state, so a consumer never has to manually refetch the
|
|
68
|
+
* whole list after every mutating action.
|
|
352
69
|
*
|
|
353
|
-
* Live-updated on `item.created`: the new item is prepended immediately from
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
70
|
+
* Live-updated on `item.created`: the new item is prepended immediately from the
|
|
71
|
+
* (deliberately minimal: `{id, title, created_at}`) SSE payload so the UI reacts
|
|
72
|
+
* instantly, then reconciled against a fresh first page fetched in the background so the
|
|
73
|
+
* authoritative row (body, category, action_url) lands without a full list
|
|
74
|
+
* reload/flicker.
|
|
75
|
+
*
|
|
76
|
+
* All of that lives in `InboxStore`; this is the binding. The filter is handed to the
|
|
77
|
+
* store rather than being part of what identifies it, so changing tabs reloads the list
|
|
78
|
+
* in place, keeping the rows on screen, instead of discarding the store and blanking the
|
|
79
|
+
* panel.
|
|
358
80
|
*/
|
|
359
81
|
declare function useInbox(options?: UseInboxOptions): UseInboxResult;
|
|
360
82
|
|
|
@@ -374,20 +96,22 @@ interface UsePreferencesResult {
|
|
|
374
96
|
* Headless preferences, the counterpart to `useInbox`.
|
|
375
97
|
*
|
|
376
98
|
* **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
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
99
|
+
* off a channel is a consent decision, and showing it as done before the server agreed is
|
|
100
|
+
* showing somebody they have opted out when they may not have. The server answers `PATCH`
|
|
101
|
+
* with the *whole* updated state, so the correct view arrives with the write and there is
|
|
102
|
+
* nothing to reconcile. A per-row pending state in the host is a better answer to the
|
|
103
|
+
* latency than a lie.
|
|
382
104
|
*
|
|
383
|
-
* **`error` is state, not an exception.** `setPreference` still rejects, so a caller
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
105
|
+
* **`error` is state, not an exception.** `setPreference` still rejects, so a caller can
|
|
106
|
+
* `await` it and react per-control, but the hook also holds the failure so a preference
|
|
107
|
+
* centre can render it without every consumer writing the same try/catch. A failed write
|
|
108
|
+
* leaves the last known-good state on screen rather than an empty page.
|
|
387
109
|
*
|
|
388
110
|
* Critical categories arrive with `isCritical: true` and are always delivered. Render
|
|
389
111
|
* them (a subscriber should see what they are receiving), but not as a control: an
|
|
390
112
|
* affordance that refuses is worse than none.
|
|
113
|
+
*
|
|
114
|
+
* A binding over `PreferencesStore`, which holds the behaviour above.
|
|
391
115
|
*/
|
|
392
116
|
declare function usePreferences(): UsePreferencesResult;
|
|
393
117
|
|
|
@@ -395,11 +119,11 @@ declare function usePreferences(): UsePreferencesResult;
|
|
|
395
119
|
* `<HermsInbox />`'s own tiny, self-contained EN/FR string table.
|
|
396
120
|
*
|
|
397
121
|
* Deliberately **not** i18next: this package ships to arbitrary host apps
|
|
398
|
-
* (
|
|
122
|
+
* (with zero hard dependency on the host app's state library) that may run
|
|
399
123
|
* their own i18n stack, a different one, or none at all. Pulling i18next in
|
|
400
124
|
* as a real dependency here would be exactly the kind of host-app-shaped
|
|
401
125
|
* assumption the SDK is supposed to avoid, for a handful of short strings.
|
|
402
|
-
* `locale`
|
|
126
|
+
* `locale` is a prop of `<HermsInbox />`.
|
|
403
127
|
*/
|
|
404
128
|
type HermsLocale = 'en' | 'fr';
|
|
405
129
|
|
|
@@ -412,7 +136,7 @@ interface HermsInboxProps {
|
|
|
412
136
|
/** Popover position relative to the bell trigger. Default `bottom-end`. */
|
|
413
137
|
placement?: HermsInboxPlacement;
|
|
414
138
|
/** Called when an item is activated (click, or Enter on a focused row).
|
|
415
|
-
* Lets a host app drive its own router (
|
|
139
|
+
* Lets a host app drive its own router (for example
|
|
416
140
|
* `onItemClick={(item) => navigate(item.actionUrl)}`) instead of a hard
|
|
417
141
|
* page navigation. When omitted, activating an item with a non-null
|
|
418
142
|
* `actionUrl` does a plain `window.location.assign` to it. Either way, the
|
|
@@ -430,7 +154,7 @@ interface HermsInboxProps {
|
|
|
430
154
|
className?: string;
|
|
431
155
|
}
|
|
432
156
|
/**
|
|
433
|
-
* `<HermsInbox />`: bell trigger + dropdown panel
|
|
157
|
+
* `<HermsInbox />`: bell trigger + dropdown panel. Built
|
|
434
158
|
* entirely on the headless `useInbox`/`useUnreadCount` hooks; every state
|
|
435
159
|
* (loading, empty, error, populated) renders here, none of it invented by a
|
|
436
160
|
* host app. Accessible: a real `<button>` trigger with `aria-label`/
|
|
@@ -441,4 +165,4 @@ interface HermsInboxProps {
|
|
|
441
165
|
*/
|
|
442
166
|
declare function HermsInbox({ placement, onItemClick, theme, colorScheme, locale, className }: HermsInboxProps): react.JSX.Element;
|
|
443
167
|
|
|
444
|
-
export {
|
|
168
|
+
export { HermsInbox, type HermsInboxPlacement, type HermsInboxProps, type HermsInboxTheme, type HermsLocale, HermsProvider, type HermsProviderProps, type UseInboxOptions, type UseInboxResult, type UsePreferencesResult, type UseUnreadCountResult, useHermsContext, useInbox, usePreferences, useUnreadCount };
|