@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/CHANGELOG.md
CHANGED
|
@@ -1,87 +1,138 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
All notable changes to `@hermesihq/react`.
|
|
4
|
-
|
|
5
|
-
This file describes what a consumer gets, not how it was built. The repository's
|
|
6
|
-
history is where the reasoning lives.
|
|
7
|
-
|
|
8
|
-
## Versioning
|
|
9
|
-
|
|
10
|
-
**`0.x` means the public API can still change.** A minor bump (`0.1` → `0.2`) may
|
|
11
|
-
contain a breaking change; a patch bump will not. That is the ordinary `0.x` reading
|
|
12
|
-
of semver and it is chosen deliberately rather than by default: this SDK's shape is
|
|
13
|
-
still being learned from the first integrations, and promising stability before anyone
|
|
14
|
-
has built against it would be a promise made to nobody and broken later.
|
|
15
|
-
|
|
16
|
-
`1.0.0` is the commitment that a breaking change requires a major bump. It waits until
|
|
17
|
-
at least one real integration exists.
|
|
18
|
-
|
|
19
|
-
Each release lists breaking changes first, because that is the only section that
|
|
20
|
-
decides whether an upgrade is a decision or a formality.
|
|
21
|
-
|
|
22
|
-
## 0.1
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
- `
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
-
|
|
64
|
-
`
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
-
|
|
86
|
-
|
|
87
|
-
-
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@hermesihq/react`.
|
|
4
|
+
|
|
5
|
+
This file describes what a consumer gets, not how it was built. The repository's
|
|
6
|
+
history is where the reasoning lives.
|
|
7
|
+
|
|
8
|
+
## Versioning
|
|
9
|
+
|
|
10
|
+
**`0.x` means the public API can still change.** A minor bump (`0.1` → `0.2`) may
|
|
11
|
+
contain a breaking change; a patch bump will not. That is the ordinary `0.x` reading
|
|
12
|
+
of semver and it is chosen deliberately rather than by default: this SDK's shape is
|
|
13
|
+
still being learned from the first integrations, and promising stability before anyone
|
|
14
|
+
has built against it would be a promise made to nobody and broken later.
|
|
15
|
+
|
|
16
|
+
`1.0.0` is the commitment that a breaking change requires a major bump. It waits until
|
|
17
|
+
at least one real integration exists.
|
|
18
|
+
|
|
19
|
+
Each release lists breaking changes first, because that is the only section that
|
|
20
|
+
decides whether an upgrade is a decision or a formality.
|
|
21
|
+
|
|
22
|
+
## 0.2.1 (2026-10-01)
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **The panel had no styling.** `<HermsInbox />` renders its panel in a portal under `<body>`, so
|
|
27
|
+
the panel is not inside the bell's root, and every colour variable was defined only on that
|
|
28
|
+
root. Measured in a browser on 0.2.0: the panel was transparent, had no border and square
|
|
29
|
+
corners, and its text stayed black in dark mode. The `theme` and `colorScheme` props put their
|
|
30
|
+
values on the root, so they could never reach the panel either. The panel now carries the
|
|
31
|
+
variables, the forced colour scheme and the `theme` values itself, and forcing light mode on a
|
|
32
|
+
dark system works. If you worked around this with your own CSS on `.herms-inbox__panel`, it may
|
|
33
|
+
now be redundant.
|
|
34
|
+
- `className` is now applied to the panel as well as to the bell, so one rule themes both.
|
|
35
|
+
- The panel is sized with `box-sizing: border-box`. Its 380px width now includes its border; it
|
|
36
|
+
used to render at 382px.
|
|
37
|
+
- The dialog has an accessible name, taken from its title. It used to be announced as just
|
|
38
|
+
"dialog".
|
|
39
|
+
|
|
40
|
+
## 0.2.0 (2026-09-30)
|
|
41
|
+
|
|
42
|
+
No breaking changes, and nothing to change in your code: everything this package exported
|
|
43
|
+
before it exports now, from the same place.
|
|
44
|
+
|
|
45
|
+
**The client and the state moved into a new package, `@hermesihq/js`, which this one now
|
|
46
|
+
depends on.** The list, count and preference state that lived inside the hooks is now held
|
|
47
|
+
in framework-free stores, and the hooks are a thin binding over them. That is what lets a
|
|
48
|
+
Vue, Angular or plain JavaScript page use the same code. You do not need to install or
|
|
49
|
+
import `@hermesihq/js` yourself; `HermsClient`, `HermsApiError`, `HERMS_CHANNELS` and the
|
|
50
|
+
rest are still exported from here. If you also import `@hermesihq/js` directly, npm
|
|
51
|
+
installs one shared copy, so both see the same `HermsClient` class.
|
|
52
|
+
|
|
53
|
+
Four things a consumer can notice:
|
|
54
|
+
|
|
55
|
+
- **A further page for the previous filter no longer lands in the new list.** With
|
|
56
|
+
`useInbox`, switching tabs while "load more" was in flight could append the old tab's
|
|
57
|
+
rows to the new tab's list. Any response that arrives after the list was reloaded, the
|
|
58
|
+
filter changed or the hook unmounted is now discarded.
|
|
59
|
+
- **A load's result and its loading flag now change together.** The rows, or the error,
|
|
60
|
+
used to be published one update before `isLoading` cleared, so a component that
|
|
61
|
+
renders on every update could briefly show "failed and still loading" or "here are
|
|
62
|
+
your rows and still loading".
|
|
63
|
+
- **The functions `useInbox` and `usePreferences` return keep their identity.**
|
|
64
|
+
`loadMore`, `markRead`, `archive` and the rest used to be recreated whenever
|
|
65
|
+
`isLoadingMore` or `hasMore` changed, so a `useCallback` or `memo` listing one re-ran
|
|
66
|
+
on every page.
|
|
67
|
+
- **Two providers over one client no longer close each other's connection.** The
|
|
68
|
+
real-time stream is reference-counted: it opens for the first and closes with the last.
|
|
69
|
+
|
|
70
|
+
A listener that throws is now reported to the host without stopping the other listeners
|
|
71
|
+
or corrupting the state it was notified about.
|
|
72
|
+
|
|
73
|
+
## 0.1.0 (2026-09-28)
|
|
74
|
+
|
|
75
|
+
The first published release. Nothing precedes it, so there is nothing to migrate from
|
|
76
|
+
and no deprecations; the entries below describe the surface rather than changes to one.
|
|
77
|
+
|
|
78
|
+
### The inbox
|
|
79
|
+
|
|
80
|
+
- `HermsClient`: the framework-agnostic core. Reads and writes the subscriber's inbox,
|
|
81
|
+
opens a realtime stream for new items and unread counts, and falls back to polling
|
|
82
|
+
when the runtime has no `EventSource` or the stream keeps failing. It imports nothing
|
|
83
|
+
from React, so a Vue or vanilla wrapper is a thin layer rather than a second
|
|
84
|
+
implementation.
|
|
85
|
+
- `HermsProvider` / `useHermsContext`: one shared connection per provider, fanned out
|
|
86
|
+
to every hook beneath it. Mounting three components does not open three streams.
|
|
87
|
+
- `useInbox`: the item list, with filtering, cursor pagination, and mark-read,
|
|
88
|
+
mark-all-read, archive and delete.
|
|
89
|
+
- `useUnreadCount`: the badge, kept live off the same stream.
|
|
90
|
+
- `HermsInbox`: a rendered bell and panel, keyboard-navigable, in English or French.
|
|
91
|
+
Optional: the hooks above are the whole API if you would rather build your own.
|
|
92
|
+
|
|
93
|
+
### Preferences
|
|
94
|
+
|
|
95
|
+
- `client.getPreferences()` and `client.updatePreference({ channel, enabled, categoryId })`,
|
|
96
|
+
plus the `usePreferences` hook.
|
|
97
|
+
- `PATCH` answers with the whole updated state, so a caller never refetches. The hook
|
|
98
|
+
does **not** update optimistically: turning off a channel is a consent decision, and
|
|
99
|
+
showing it as done before the server agreed tells somebody they have opted out when
|
|
100
|
+
they may not have.
|
|
101
|
+
- A setting of `null` means no preference was expressed and the default applies. It is
|
|
102
|
+
not `false`. Categories with `isCritical: true` are always delivered. Render them so
|
|
103
|
+
a subscriber can see what they receive, but not as a control.
|
|
104
|
+
- There is deliberately no `unsubscribe()`. `POST /v1/client/unsubscribe` is the target
|
|
105
|
+
of a `List-Unsubscribe` one-click link (RFC 8058) whose token is minted into an
|
|
106
|
+
outgoing email's headers and invoked by a mail client, not by application code. The
|
|
107
|
+
equivalent here is `updatePreference({ channel: 'email', enabled: false })`.
|
|
108
|
+
|
|
109
|
+
### Identities and errors
|
|
110
|
+
|
|
111
|
+
- `client.registerChannel` / `client.deregisterChannel`, and `HERMS_CHANNELS`: the
|
|
112
|
+
channels an identity can be registered for, exported as an array so a preference
|
|
113
|
+
centre can iterate them, with `HermsChannel` derived from it.
|
|
114
|
+
- `HermsApiError`: every failed request rejects with it, carrying the API's `type`,
|
|
115
|
+
`code`, `message`, `request_id`, `detail` and `docUrl`. A response that is not JSON
|
|
116
|
+
(a proxy's error page, an empty body where one was expected) arrives as this too,
|
|
117
|
+
rather than as a `TypeError` from inside the SDK.
|
|
118
|
+
- `decodeSubscriberTokenExp`: reads a subscriber token's expiry so a host can refresh
|
|
119
|
+
ahead of it. It parses; it does not validate a signature and cannot, since this
|
|
120
|
+
package never holds a key.
|
|
121
|
+
|
|
122
|
+
### Requirements
|
|
123
|
+
|
|
124
|
+
React 18 or newer, as a peer dependency with no upper bound. That is deliberate and
|
|
125
|
+
the opposite of how this repository pins its own dependencies: an application locks its
|
|
126
|
+
versions so two builds of one commit are identical, whereas a library that bounds a
|
|
127
|
+
peer range forces a warning on every consumer the day React ships a major, whether or
|
|
128
|
+
not anything actually broke. You control React; this package should not have an opinion
|
|
129
|
+
about which one you run.
|
|
130
|
+
|
|
131
|
+
Ships ESM and CommonJS builds with type declarations for both, and
|
|
132
|
+
`@hermesihq/react/styles.css` for `HermsInbox`.
|
|
133
|
+
|
|
134
|
+
### Known gaps
|
|
135
|
+
|
|
136
|
+
- No `<HermsPreferences />` component. The headless surface for one is here; the
|
|
137
|
+
rendered interface is not, because it wants a design pass rather than an invention.
|
|
138
|
+
- No Vue or vanilla wrapper yet.
|
package/README.md
CHANGED
|
@@ -1,128 +1,122 @@
|
|
|
1
|
-
# @hermesihq/react
|
|
2
|
-
|
|
3
|
-
Hermesi's
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
`
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
`
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
##
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
can never bleed into the widget (and vice versa).
|
|
124
|
-
locale.ts A tiny built-in EN/FR string table, not i18next, so this
|
|
125
|
-
package stays independent of whatever i18n stack (or
|
|
126
|
-
none) a host app runs.
|
|
127
|
-
index.ts
|
|
128
|
-
```
|
|
1
|
+
# @hermesihq/react
|
|
2
|
+
|
|
3
|
+
React bindings for Hermesi's in-app inbox: a ready-made bell and panel, and the hooks it is
|
|
4
|
+
built on if you would rather build your own.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npm install @hermesihq/react
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Requires React 18 or newer. It depends on [`@hermesihq/js`](https://github.com/hermesihq/sdk/tree/main/packages/js),
|
|
11
|
+
which npm installs for you. You do not import it yourself unless you want the stores directly.
|
|
12
|
+
|
|
13
|
+
## Before you start
|
|
14
|
+
|
|
15
|
+
Three things: a **public key** (`hm_pk_...`, safe to ship in a bundle), your **API base URL
|
|
16
|
+
ending in `/v1/client`**, and a **subscriber token minted by your own backend**. The token
|
|
17
|
+
proves which subscriber this page acts for. Your backend signs it with your secret key; this
|
|
18
|
+
package never sees that key and cannot mint a token. You give the client a function that
|
|
19
|
+
asks your backend for a fresh one, and it calls that function before every request.
|
|
20
|
+
|
|
21
|
+
The base URL is the client API, not the host root: every request is this string plus a path
|
|
22
|
+
such as `/inbox`. A base URL without `/v1/client` makes every request a 404.
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
import { HermsClient, HermsInbox, HermsProvider } from '@hermesihq/react'
|
|
28
|
+
import '@hermesihq/react/styles.css'
|
|
29
|
+
|
|
30
|
+
const client = new HermsClient({
|
|
31
|
+
publicKey: 'hm_pk_prod_...',
|
|
32
|
+
apiBaseUrl: 'https://your-hermesi-host/v1/client',
|
|
33
|
+
getSubscriberToken: () => fetch('/api/hermesi-token').then((response) => response.text()), // calls *your* backend
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export function AppHeader() {
|
|
37
|
+
return (
|
|
38
|
+
<HermsProvider client={client}>
|
|
39
|
+
<HermsInbox />
|
|
40
|
+
</HermsProvider>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Two lines are easy to miss. The stylesheet import is required: **the bundle does not load its
|
|
46
|
+
own CSS**, so without it the panel renders with no styling. And `HermsProvider` must sit above
|
|
47
|
+
anything that uses a hook, or the hook throws an error that names the missing provider.
|
|
48
|
+
|
|
49
|
+
Create the client **once**, at module scope or in a `useMemo`, not on every render. A new
|
|
50
|
+
client each render closes and reopens the real-time connection each render.
|
|
51
|
+
|
|
52
|
+
## `<HermsInbox />`
|
|
53
|
+
|
|
54
|
+
A real `<button>` with an accessible name that includes the unread count, and a panel with
|
|
55
|
+
arrow-key navigation, `Home` and `End`, and `Escape` to close.
|
|
56
|
+
|
|
57
|
+
| Prop | Default | |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| `placement` | `'bottom-end'` | `'bottom-start'`, `'bottom-end'`, `'top-start'` or `'top-end'` |
|
|
60
|
+
| `onItemClick` | | Called with the item when one is activated. Use it to drive your own router. Without it, an item with an `actionUrl` navigates there. Either way the item is marked read first. |
|
|
61
|
+
| `theme` | | `{ accent, radius }`, the two most re-themed values |
|
|
62
|
+
| `colorScheme` | `'auto'` | `'auto'` follows the visitor's OS setting; `'light'` or `'dark'` force one |
|
|
63
|
+
| `locale` | `'en'` | `'en'` or `'fr'` |
|
|
64
|
+
| `className` | | Added to the root element **and to the panel**, so one rule themes both |
|
|
65
|
+
|
|
66
|
+
Everything else is reachable by overriding these CSS custom properties: `--herms-color-accent`,
|
|
67
|
+
`--herms-color-accent-foreground`, `--herms-color-bg`, `--herms-color-border`,
|
|
68
|
+
`--herms-color-danger`, `--herms-color-hover`, `--herms-color-muted`, `--herms-color-surface`,
|
|
69
|
+
`--herms-color-text`, `--herms-color-unread-dot`, `--herms-font-family` and `--herms-radius`.
|
|
70
|
+
Set them on a rule that matches the `className` you pass: it is applied to the bell and to the
|
|
71
|
+
panel. The panel is rendered under `<body>`, not inside the bell, so a rule on an ancestor of the
|
|
72
|
+
bell does not reach it. The stylesheet is scoped under `.herms-inbox` and uses no Tailwind, so
|
|
73
|
+
your build cannot bleed into the widget or the reverse.
|
|
74
|
+
|
|
75
|
+
## Hooks
|
|
76
|
+
|
|
77
|
+
Use these when the bell and panel are not what you want. Same data, no chrome.
|
|
78
|
+
|
|
79
|
+
| Hook | Returns |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `useUnreadCount()` | `{ unread, unseen, isLoading }`, kept live |
|
|
82
|
+
| `useInbox({ status?, category? })` | `{ items, isLoading, isLoadingMore, error, hasMore, loadMore, markRead, markAllRead, archive, remove, refetch }` |
|
|
83
|
+
| `usePreferences()` | `{ preferences, isLoading, error, setPreference, reload }` |
|
|
84
|
+
|
|
85
|
+
They are a thin binding over the stores in `@hermesihq/js`, which is where the behaviour is
|
|
86
|
+
documented. The parts worth knowing here:
|
|
87
|
+
|
|
88
|
+
- The functions a hook returns keep the same identity for as long as it is mounted, so they
|
|
89
|
+
are safe in a `useCallback` or `memo` dependency list.
|
|
90
|
+
- Changing `status` or `category` reloads the list and keeps the previous rows on screen,
|
|
91
|
+
flagged as loading, until the new ones arrive. A response for the old filter that lands
|
|
92
|
+
late is discarded.
|
|
93
|
+
- Mutations go to the server first and patch local state with what it answered. A failed one
|
|
94
|
+
rejects.
|
|
95
|
+
- `usePreferences` does not update optimistically. Turning off a channel is a consent
|
|
96
|
+
decision, and showing it as done before the server agreed tells somebody they have opted
|
|
97
|
+
out when they may not have.
|
|
98
|
+
- A setting of `null` means no preference was expressed and the default applies. It is not
|
|
99
|
+
`false`, and treating them as the same would opt somebody out of something they never
|
|
100
|
+
declined. Categories with `isCritical: true` are always delivered: show them, but not as a
|
|
101
|
+
control.
|
|
102
|
+
|
|
103
|
+
## Server rendering
|
|
104
|
+
|
|
105
|
+
Importing this package touches no DOM, and a component using the hooks renders its loading
|
|
106
|
+
state on the server. Nothing connects and no request is made until it mounts in the browser.
|
|
107
|
+
|
|
108
|
+
## Also exported
|
|
109
|
+
|
|
110
|
+
`HermsClient`, `HermsApiError`, `HERMS_CHANNELS` and `decodeSubscriberTokenExp`, with their
|
|
111
|
+
types, are re-exported from `@hermesihq/js`, so an existing import from this package keeps
|
|
112
|
+
working. Every failed request rejects with a `HermsApiError`; quote its `requestId` in a
|
|
113
|
+
support conversation.
|
|
114
|
+
|
|
115
|
+
## Not included
|
|
116
|
+
|
|
117
|
+
A rendered preference centre. The headless `usePreferences()` is here; the interface is not,
|
|
118
|
+
because it wants a design pass rather than an invention.
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|