@hermesihq/react 0.1.0 → 0.2.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 +33 -0
- package/README.md +115 -123
- package/dist/index.cjs +49 -603
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1 -1
- 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 +59 -611
- package/dist/index.js.map +1 -1
- package/package.json +67 -71
package/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,39 @@ at least one real integration exists.
|
|
|
19
19
|
Each release lists breaking changes first, because that is the only section that
|
|
20
20
|
decides whether an upgrade is a decision or a formality.
|
|
21
21
|
|
|
22
|
+
## 0.2.0 (2026-09-30)
|
|
23
|
+
|
|
24
|
+
No breaking changes, and nothing to change in your code: everything this package exported
|
|
25
|
+
before it exports now, from the same place.
|
|
26
|
+
|
|
27
|
+
**The client and the state moved into a new package, `@hermesihq/js`, which this one now
|
|
28
|
+
depends on.** The list, count and preference state that lived inside the hooks is now held
|
|
29
|
+
in framework-free stores, and the hooks are a thin binding over them. That is what lets a
|
|
30
|
+
Vue, Angular or plain JavaScript page use the same code. You do not need to install or
|
|
31
|
+
import `@hermesihq/js` yourself; `HermsClient`, `HermsApiError`, `HERMS_CHANNELS` and the
|
|
32
|
+
rest are still exported from here. If you also import `@hermesihq/js` directly, npm
|
|
33
|
+
installs one shared copy, so both see the same `HermsClient` class.
|
|
34
|
+
|
|
35
|
+
Four things a consumer can notice:
|
|
36
|
+
|
|
37
|
+
- **A further page for the previous filter no longer lands in the new list.** With
|
|
38
|
+
`useInbox`, switching tabs while "load more" was in flight could append the old tab's
|
|
39
|
+
rows to the new tab's list. Any response that arrives after the list was reloaded, the
|
|
40
|
+
filter changed or the hook unmounted is now discarded.
|
|
41
|
+
- **A load's result and its loading flag now change together.** The rows, or the error,
|
|
42
|
+
used to be published one update before `isLoading` cleared, so a component that
|
|
43
|
+
renders on every update could briefly show "failed and still loading" or "here are
|
|
44
|
+
your rows and still loading".
|
|
45
|
+
- **The functions `useInbox` and `usePreferences` return keep their identity.**
|
|
46
|
+
`loadMore`, `markRead`, `archive` and the rest used to be recreated whenever
|
|
47
|
+
`isLoadingMore` or `hasMore` changed, so a `useCallback` or `memo` listing one re-ran
|
|
48
|
+
on every page.
|
|
49
|
+
- **Two providers over one client no longer close each other's connection.** The
|
|
50
|
+
real-time stream is reference-counted: it opens for the first and closes with the last.
|
|
51
|
+
|
|
52
|
+
A listener that throws is now reported to the host without stopping the other listeners
|
|
53
|
+
or corrupting the state it was notified about.
|
|
54
|
+
|
|
22
55
|
## 0.1.0 (2026-09-28)
|
|
23
56
|
|
|
24
57
|
The first published release. Nothing precedes it, so there is nothing to migrate from
|
package/README.md
CHANGED
|
@@ -1,128 +1,120 @@
|
|
|
1
1
|
# @hermesihq/react
|
|
2
2
|
|
|
3
|
-
Hermesi's
|
|
4
|
-
|
|
5
|
-
`useInbox()`, `<HermsInbox />`).
|
|
6
|
-
|
|
7
|
-
## Scope of this package, honestly
|
|
8
|
-
|
|
9
|
-
This repo has no monorepo tooling (no pnpm/Turborepo workspaces, no root
|
|
10
|
-
`package.json`). The dashboard (`frontend/`) still consumes this package as
|
|
11
|
-
TypeScript source in development, via a `resolve.alias` in
|
|
12
|
-
`frontend/vite.config.ts` (for the real bundler) and a matching `paths` entry
|
|
13
|
-
in `frontend/tsconfig.app.json` (for `tsc -b`), not through `node_modules`,
|
|
14
|
-
and not via a `file:` dependency. That wiring is deliberately left as-is: the
|
|
15
|
-
dashboard isn't this package's audience, external integrators are, and
|
|
16
|
-
changing how the dashboard resolves its own in-repo copy isn't needed to make
|
|
17
|
-
*this package* installable elsewhere.
|
|
18
|
-
|
|
19
|
-
`npm run build` now runs `tsup` (`tsup.config.ts`), which really does produce
|
|
20
|
-
`dist/index.js` (ESM), `dist/index.cjs` (CJS), `dist/index.d.ts`/`.d.cts`, and
|
|
21
|
-
`dist/index.css` (extracted from `HermsInbox.tsx`'s `import
|
|
22
|
-
'./HermsInbox.css'`). `main`/`module`/`types`/`exports` point at that `dist/`
|
|
23
|
-
output, `files` publishes only `dist/`, this `README.md`, and `LICENSE`
|
|
24
|
-
(MIT, see that file), and `prepublishOnly` reruns the typecheck and the
|
|
25
|
-
build before anything ships. `private: true` has been removed. `npm publish`
|
|
26
|
-
from this directory (after `npm login` with an account that has publish
|
|
27
|
-
rights on the `@hermesi` npm org/scope) now does the real thing.
|
|
28
|
-
|
|
29
|
-
The `repository`/`homepage`/`bugs` URLs were placeholders pointing at
|
|
30
|
-
`github.com/hermesi/hermesi`, a repository that is not this one. npm renders
|
|
31
|
-
those as the package's own links, so they would have sent every integrator
|
|
32
|
-
somewhere that does not exist. They point at the real remote now.
|
|
33
|
-
|
|
34
|
-
**Still worth deciding before v1:** semver policy, a bundle-size CI gate, and a
|
|
35
|
-
fuller SSR render test matrix (see "Not in this pass" below).
|
|
36
|
-
|
|
37
|
-
## What's real here
|
|
38
|
-
|
|
39
|
-
The packaging is real too, now: `npm run verify:package` builds, packs,
|
|
40
|
-
installs the tarball into a throwaway consumer and imports from it under ESM,
|
|
41
|
-
CJS and two TypeScript module resolutions, and CI runs it on every change.
|
|
42
|
-
Until that existed nothing had ever loaded `dist/`: the dashboard imports this
|
|
43
|
-
package's *source* through a bundler alias, so every test proved the source
|
|
44
|
-
works and nothing at all about what npm would serve.
|
|
45
|
-
|
|
46
|
-
`HermsClient` makes real HTTP requests
|
|
47
|
-
against `/v1/client/inbox/*`, opens a real `EventSource` against
|
|
48
|
-
`/v1/client/inbox/stream` (falling back to polling `/v1/client/inbox/counts`
|
|
49
|
-
every 60s when SSE isn't available or keeps failing), and
|
|
50
|
-
`<HermsInbox />` is a real, accessible bell + dropdown panel wired to that
|
|
51
|
-
client through the headless hooks.
|
|
52
|
-
|
|
53
|
-
A full working consumer lives in this package's repository, at
|
|
54
|
-
`frontend/src/pages/InboxWidgetDemoPage.tsx`. It is linked rather than
|
|
55
|
-
described because an integrator installing from npm does not have that
|
|
56
|
-
file. See the repository link in `package.json`.
|
|
57
|
-
|
|
58
|
-
## Exports a consumer needs and this README used to omit
|
|
59
|
-
|
|
60
|
-
`HermsApiError`: every failed request rejects with it, carrying the API's own
|
|
61
|
-
`type`, `code`, `message` and `request_id`. Catching it by type is the
|
|
62
|
-
difference between showing a user "rate limited, try again" and showing them
|
|
63
|
-
nothing. It was exported and undocumented, which means it was effectively
|
|
64
|
-
unavailable.
|
|
65
|
-
|
|
66
|
-
`HERMS_CHANNELS`: the channels an identity can be registered for, as an array
|
|
67
|
-
so a preference centre can iterate them, with `HermsChannel` derived from it.
|
|
68
|
-
|
|
69
|
-
`decodeSubscriberTokenExp`: reads a subscriber token's `exp` client-side so a
|
|
70
|
-
host can refresh ahead of expiry. It parses; it does **not** validate a
|
|
71
|
-
signature and cannot, since this package never holds a key.
|
|
72
|
-
|
|
73
|
-
`client.registerChannel({ channel, identifier })` and
|
|
74
|
-
`client.deregisterChannel(channel, identifier)`: where a subscriber can be
|
|
75
|
-
reached on a channel that needs an address.
|
|
76
|
-
|
|
77
|
-
`client.getPreferences()` / `client.updatePreference({ channel, enabled,
|
|
78
|
-
categoryId })` and the `usePreferences()` hook: the subscriber's own
|
|
79
|
-
notification settings, for building a preference centre. `PATCH` answers with
|
|
80
|
-
the whole updated state, so a caller never refetches, and the hook does **not**
|
|
81
|
-
update optimistically: turning off a channel is a consent decision, and showing
|
|
82
|
-
it as done before the server agreed shows somebody they have opted out when
|
|
83
|
-
they may not have.
|
|
84
|
-
|
|
85
|
-
A setting of `null` means no preference was expressed and the default applies.
|
|
86
|
-
It is not `false`, and collapsing the two would opt somebody out of something
|
|
87
|
-
they never declined. Categories with `isCritical: true` are always delivered.
|
|
88
|
-
Render them so a subscriber can see what they receive, but not as a control.
|
|
89
|
-
|
|
90
|
-
There is deliberately **no** `unsubscribe()`, although `POST
|
|
91
|
-
/v1/client/unsubscribe` exists. It is the target of a `List-Unsubscribe`
|
|
92
|
-
one-click link (RFC 8058) whose token Hermesi mints into an outgoing email's
|
|
93
|
-
headers, invoked by a mail client rather than by application code. Wrapping it
|
|
94
|
-
here would invite you to build a button on a token you cannot obtain. The
|
|
95
|
-
equivalent is `updatePreference({ channel: 'email', enabled: false })`.
|
|
96
|
-
|
|
97
|
-
## Not in this pass
|
|
98
|
-
|
|
99
|
-
For `<HermsPreferences />`, the headless surface is here
|
|
100
|
-
(`usePreferences()`), the rendered component is not: a preference centre is a
|
|
101
|
-
real interface design, and inventing one without that pass is how you get a
|
|
102
|
-
screen that technically works. A Vue/vanilla wrapper (the framework-agnostic core is
|
|
103
|
-
what makes one *possible* later, not a promise it exists), a category-filter
|
|
104
|
-
picker inside `<HermsInbox />`'s own chrome, and the real-repo-URL/semver/CI
|
|
105
|
-
follow-ups called out just above. See the task this package was built
|
|
106
|
-
against for the exact, deliberate cut lines.
|
|
107
|
-
|
|
108
|
-
## Layout
|
|
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.
|
|
109
5
|
|
|
6
|
+
```sh
|
|
7
|
+
npm install @hermesihq/react
|
|
110
8
|
```
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
+
}
|
|
128
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 |
|
|
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
|
+
The stylesheet is scoped under `.herms-inbox` and uses no Tailwind, so your build cannot bleed
|
|
71
|
+
into the widget or the reverse.
|
|
72
|
+
|
|
73
|
+
## Hooks
|
|
74
|
+
|
|
75
|
+
Use these when the bell and panel are not what you want. Same data, no chrome.
|
|
76
|
+
|
|
77
|
+
| Hook | Returns |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `useUnreadCount()` | `{ unread, unseen, isLoading }`, kept live |
|
|
80
|
+
| `useInbox({ status?, category? })` | `{ items, isLoading, isLoadingMore, error, hasMore, loadMore, markRead, markAllRead, archive, remove, refetch }` |
|
|
81
|
+
| `usePreferences()` | `{ preferences, isLoading, error, setPreference, reload }` |
|
|
82
|
+
|
|
83
|
+
They are a thin binding over the stores in `@hermesihq/js`, which is where the behaviour is
|
|
84
|
+
documented. The parts worth knowing here:
|
|
85
|
+
|
|
86
|
+
- The functions a hook returns keep the same identity for as long as it is mounted, so they
|
|
87
|
+
are safe in a `useCallback` or `memo` dependency list.
|
|
88
|
+
- Changing `status` or `category` reloads the list and keeps the previous rows on screen,
|
|
89
|
+
flagged as loading, until the new ones arrive. A response for the old filter that lands
|
|
90
|
+
late is discarded.
|
|
91
|
+
- Mutations go to the server first and patch local state with what it answered. A failed one
|
|
92
|
+
rejects.
|
|
93
|
+
- `usePreferences` does not update optimistically. Turning off a channel is a consent
|
|
94
|
+
decision, and showing it as done before the server agreed tells somebody they have opted
|
|
95
|
+
out when they may not have.
|
|
96
|
+
- A setting of `null` means no preference was expressed and the default applies. It is not
|
|
97
|
+
`false`, and treating them as the same would opt somebody out of something they never
|
|
98
|
+
declined. Categories with `isCritical: true` are always delivered: show them, but not as a
|
|
99
|
+
control.
|
|
100
|
+
|
|
101
|
+
## Server rendering
|
|
102
|
+
|
|
103
|
+
Importing this package touches no DOM, and a component using the hooks renders its loading
|
|
104
|
+
state on the server. Nothing connects and no request is made until it mounts in the browser.
|
|
105
|
+
|
|
106
|
+
## Also exported
|
|
107
|
+
|
|
108
|
+
`HermsClient`, `HermsApiError`, `HERMS_CHANNELS` and `decodeSubscriberTokenExp`, with their
|
|
109
|
+
types, are re-exported from `@hermesihq/js`, so an existing import from this package keeps
|
|
110
|
+
working. Every failed request rejects with a `HermsApiError`; quote its `requestId` in a
|
|
111
|
+
support conversation.
|
|
112
|
+
|
|
113
|
+
## Not included
|
|
114
|
+
|
|
115
|
+
A rendered preference centre. The headless `usePreferences()` is here; the interface is not,
|
|
116
|
+
because it wants a design pass rather than an invention.
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT
|