@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 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 embeddable inbox SDK: a framework-agnostic
4
- `HermsClient` core plus React bindings (`<HermsProvider>`, `useUnreadCount()`,
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
- src/
112
- core/ HermsClient: no React import anywhere in this directory.
113
- HermsClient.ts
114
- subscriberToken.ts Reads (never verifies/mints) a token's `exp` claim.
115
- types.ts
116
- react/
117
- HermsProvider.tsx
118
- useUnreadCount.ts
119
- useInbox.ts
120
- HermsInbox.tsx
121
- HermsInbox.css Scoped under `.herms-inbox`, CSS custom properties only:
122
- no Tailwind dependency, so a host app's Tailwind build
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
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