@poolse/react 1.0.5 → 1.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 CHANGED
@@ -4,6 +4,23 @@ All notable changes to `@poolse/react` are documented here. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
5
5
  [semver](https://semver.org).
6
6
 
7
+ ## [1.1.0] — 2026-06-02
8
+
9
+ Lockstep release with `@poolse/sdk@1.1.0` and `@poolse/react-ui@1.1.0`.
10
+ No API changes in `@poolse/react` itself — version bumped to keep the
11
+ three packages in sync, so customers can pin one version across the
12
+ whole client surface.
13
+
14
+ ### Changed
15
+
16
+ - README rewritten with a full per-hook reference: signatures, return
17
+ shapes, the realtime channels each hook subscribes to, the optimistic
18
+ send + thread routing strategy in `useMessages`, the buffered-reads
19
+ race fix in `useMembers`, the cache + dedup semantics in `useUser`,
20
+ and the synchronous-snapshot rollback pattern used by edits and
21
+ optimistic removes.
22
+ - Repo-wide prettier formatting fixes (whitespace only).
23
+
7
24
  ## [1.0.0-beta.0] — 2026-06-01
8
25
 
9
26
  First beta of the stable API. Promotes the full 0.2.x hook surface
package/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # `@poolse/react`
2
2
 
3
- Headless React hooks for **[poolse](https://poolse.dev)** — realtime chat infrastructure.
4
-
5
- Provider + hooks for `useMe`, `useConversations`, `useMessages`, `useTyping`, `usePresence`, `useReactions`, `useThread`, `useAttachmentUpload`, and more. No UI — bring your own, or use the prebuilt components in **[`@poolse/react-ui`](https://www.npmjs.com/package/@poolse/react-ui)**.
3
+ React hooks for [poolse](https://poolse.dev). Wraps the [`@poolse/sdk`](https://www.npmjs.com/package/@poolse/sdk) client in a provider + a hook per resource — `useMessages`, `useThread`, `useReactions`, `useTyping`, `usePresence`, `useMembers`, `useAttachmentUpload`, and so on. No UI. If you want components, see [`@poolse/react-ui`](https://www.npmjs.com/package/@poolse/react-ui).
6
4
 
7
5
  ## Install
8
6
 
@@ -10,6 +8,8 @@ Provider + hooks for `useMe`, `useConversations`, `useMessages`, `useTyping`, `u
10
8
  npm install @poolse/react @poolse/sdk
11
9
  ```
12
10
 
11
+ `react@>=18` is a peer dependency. React 19 works.
12
+
13
13
  ## Quick start
14
14
 
15
15
  ```tsx
@@ -19,7 +19,7 @@ function App() {
19
19
  return (
20
20
  <PoolseProvider
21
21
  config={{
22
- apiUrl: 'https://chat.example.com',
22
+ apiUrl: 'https://api.poolse.dev',
23
23
  getToken: async () => {
24
24
  const res = await fetch('/api/chat-token', { method: 'POST' });
25
25
  const { token } = await res.json();
@@ -27,7 +27,7 @@ function App() {
27
27
  },
28
28
  }}
29
29
  >
30
- <Chat conversationId="<conversation-id>" />
30
+ <Chat conversationId="00000000-0000-0000-0000-000000000000" />
31
31
  </PoolseProvider>
32
32
  );
33
33
  }
@@ -41,63 +41,295 @@ function Chat({ conversationId }: { conversationId: string }) {
41
41
  {messages.map((m) => (
42
42
  <li key={m.id}>{m.body}</li>
43
43
  ))}
44
- <button onClick={() => send({ body: 'Hi!' })}>Send hi</button>
44
+ <button onClick={() => send({ body: 'Hi' })}>Send</button>
45
45
  </ul>
46
46
  );
47
47
  }
48
48
  ```
49
49
 
50
- ## Hooks at a glance
50
+ ## `<PoolseProvider>`
51
+
52
+ ```tsx
53
+ <PoolseProvider config={config}>{children}</PoolseProvider>
54
+ ```
55
+
56
+ `config` is the same `PoolseConfig` the bare SDK accepts (see [`@poolse/sdk`](https://www.npmjs.com/package/@poolse/sdk) for the full reference).
57
+
58
+ The provider builds **one** `Poolse` instance per mount. Connection-shaped fields (`apiUrl`, `wsUrl`, `socketPath`) are captured at construction and don't react to subsequent changes; the development build warns when you change them after mount. Function-valued fields (`getToken`, `fetch`, `userResolver`, `generateIdempotencyKey`, `onSocketError`) are read from a ref on every call, so passing fresh closures on every render is safe and expected:
59
+
60
+ ```tsx
61
+ // Fine — getToken is re-read through a ref each call.
62
+ <PoolseProvider config={{ apiUrl, getToken: () => session.token }}>
63
+ ```
64
+
65
+ To force a fresh client (sign-out, tenant swap, environment switch), remount the provider with a new `key`. On unmount, `chat.destroy()` runs, which closes the WebSocket and drops every joined channel.
66
+
67
+ ### `usePoolse()`
68
+
69
+ Escape hatch — returns the underlying `Poolse` instance for callers that need direct REST or realtime access. Throws if there's no provider above it.
70
+
71
+ ```ts
72
+ const chat = usePoolse();
73
+ const me = await chat.me.show();
74
+ ```
75
+
76
+ ## Hooks
77
+
78
+ Each hook below is briefly described, then documented in full. All hooks that touch a conversation subscribe to its realtime channel for the duration of the component's lifetime.
79
+
80
+ ### `useMe(): { me, loading, error }`
81
+
82
+ Fetches `GET /v1/me` once on mount. Identity is stable for the JWT's lifetime, so the hook doesn't refetch on its own — remount the provider to switch users.
83
+
84
+ ### `useConversation(id): { conversation, loading, error, refetch }`
85
+
86
+ Single conversation, REST-only. The server doesn't broadcast `conversation:updated` today, so changes (name, settings) won't show up live — call `refetch()` after a mutation. Pass `null` to skip the fetch.
87
+
88
+ ### `useConversations(): { conversations, create, unreadCounts, markConversationRead, ... }`
89
+
90
+ The user's conversation list. On mount: fetches `GET /v1/conversations` and subscribes to the user's own `user:<id>` channel for `conversation:created` events (fires both when this user creates a conversation and when someone else adds them to one). `unreadCounts` is populated from `Conversation.unread_count` on the initial fetch.
91
+
92
+ ```ts
93
+ const {
94
+ conversations, // Conversation[]
95
+ loading,
96
+ error,
97
+ refetch, // () => Promise<void>
98
+ create, // (attrs: ConversationCreateRequest) => Promise<Conversation>
99
+ unreadCounts, // Record<Uuid, number>
100
+ markConversationRead, // (conversationId: Uuid) => void — zeroes the badge locally
101
+ } = useConversations();
102
+ ```
103
+
104
+ `create()` optimistically prepends the conversation to local state. When the realtime echo arrives with the same id, an id-based dedup replaces the row in place — no double-insert.
105
+
106
+ Unread counts only stay correct for the conversation the user is actually viewing (`useMessages` advances the read cursor) plus whatever was fresh at fetch time. The hook doesn't subscribe to every conversation in the list — that would mean N channels just to keep N sidebar badges live. Refetch on tab focus or accept the drift.
107
+
108
+ ### `useMessages(conversationId): { messages, send, edit, delete, loadMore, hasMore, markReadUpTo, ... }`
109
+
110
+ Live messages for one conversation. Initial fetch is the newest 50; pagination loads older pages with `loadMore()`. Subscribes to `message:new`, `message:updated`, `message:deleted` on the channel.
111
+
112
+ ```ts
113
+ const {
114
+ messages, // Message[] — newest-LAST (the wire is newest-first; reversed)
115
+ loading,
116
+ error,
117
+ hasMore,
118
+ loadMore, // () => Promise<void>
119
+ send, // (attrs: MessageCreateRequest) => Promise<Message>
120
+ edit, // (messageId: Uuid, body: string) => Promise<Message>
121
+ delete: del, // (messageId: Uuid) => Promise<void>
122
+ markReadUpTo, // (messageId: Uuid) => Promise<void>
123
+ } = useMessages(conversationId);
124
+ ```
125
+
126
+ **Optimistic sends with id-based dedup.** `send()` generates a UUID client-side, appends an optimistic row sorted to the very end (`sequence: MAX_SAFE_INTEGER`), then POSTs with that same id. Whichever lands first — the REST response or the realtime `message:new` echo — wins via `upsertById`; the other arrival is a no-op replace. Errors filter the temp row by id.
127
+
128
+ **Thread routing.** Realtime `message:new` events with `thread_root_id` set are **not** added to `messages` (replies live in the thread side-pane). Instead the hook finds the root locally and increments its `reply_count`. The REST list excludes replies too (`WHERE thread_root_id IS NULL`), so a refresh and a realtime burst stay consistent.
129
+
130
+ **Edits and deletes.** Both are optimistic with synchronous rollback on error. The hook captures a snapshot before mutating so the rollback isn't racing React's update queue.
131
+
132
+ **Read cursor.** `markReadUpTo(messageId)` fires the read against `POST /v1/conversations/:id/read`. The server broadcasts `member:read` to other clients; this hook itself doesn't mutate state (the `useMembers` hook does — see below).
133
+
134
+ ### `useThread(conversationId, rootMessageId): { replies, sendReply, edit, delete, loadMore, hasMore, ... }`
135
+
136
+ Side-pane replies for one root message. The wire returns replies oldest-first; the hook keeps that order. The default fetch is 500 replies in one round-trip — most threads load whole. `loadMore()` paginates with an `after` cursor when a thread overflows.
137
+
138
+ ```ts
139
+ const {
140
+ replies, // Message[] — oldest-first
141
+ loading,
142
+ error,
143
+ hasMore,
144
+ loadMore,
145
+ sendReply, // (attrs: Omit<MessageCreateRequest, 'reply_to_id'>) => Promise<Message>
146
+ edit,
147
+ delete: del,
148
+ } = useThread(conversationId, rootMessageId);
149
+ ```
150
+
151
+ Subscriptions filter by `thread_root_id === rootMessageId` so unrelated messages in the same conversation don't end up here. `sendReply` auto-fills `reply_to_id` with the root id — same optimistic-id dedup pattern as `useMessages.send`.
152
+
153
+ ### `useReactions(messageId, opts): { reactions, addReaction, removeReaction }`
154
+
155
+ Live reaction map for one message. Subscribes to `reaction:added` / `reaction:removed` filtered to the message id.
156
+
157
+ ```ts
158
+ const { reactions, addReaction, removeReaction } = useReactions(messageId, {
159
+ conversationId,
160
+ initialReactions: msg.reactions, // seed from the Message you already have
161
+ currentUserId: meId, // enables optimistic add/remove
162
+ });
163
+ ```
164
+
165
+ `reactions` is a `Record<emoji, Uuid[]>` (matching `Message.reactions`). Adds + removes are optimistic when `currentUserId` is provided; the optimistic delta is rolled back on error.
166
+
167
+ ### `useTyping(conversationId): { typing, signalTyping }`
168
+
169
+ ```ts
170
+ const { typing, signalTyping } = useTyping(conversationId);
171
+ // typing: Set<Uuid> of users currently typing (your own id is filtered out)
172
+ // signalTyping: client-side debounced to ≤ 1 ping / 500ms
173
+ ```
174
+
175
+ Call `signalTyping` on every keystroke; the hook drops events that arrive within 500ms of the last one, and the server has its own rate limit per (user, conversation) on top.
176
+
177
+ ### `usePresence(conversationId): { online }`
178
+
179
+ ```ts
180
+ const { online } = usePresence(conversationId);
181
+ // online: Set<Uuid> of users currently joined to the channel
182
+ ```
183
+
184
+ Replays the initial `presence_state` snapshot on join, then applies `presence_diff` deltas. Pass an empty string id to opt out (the set stays empty).
185
+
186
+ ### `useMembers(conversationId): { members, addMembers, removeMember, refetch, ... }`
187
+
188
+ Conversation roster. Initial fetch is `GET /v1/conversations/:id/members`. Subscribes to `member:read` on the channel and advances the matching membership's `last_read_message_id` and `last_read_at` in place — which is how `<MessageBubble>`'s read-receipt glyph (in `@poolse/react-ui`) flips from sent to read in real time.
189
+
190
+ ```ts
191
+ const {
192
+ members, // Membership[]
193
+ loading,
194
+ error,
195
+ refetch,
196
+ addMembers, // (externalIds: string[], { role? }?) => Promise<Membership[]>
197
+ removeMember, // (userId: Uuid) => Promise<void>
198
+ } = useMembers(conversationId);
199
+ ```
200
+
201
+ `addMembers` deduplicates by id (server returns an idempotent add). `removeMember` is optimistic with snapshot rollback.
202
+
203
+ Empty `conversationId` opts out of the fetch (returns `{ members: [], loading: false }`).
204
+
205
+ `member:read` events that arrive before the initial fetch lands are buffered and replayed after `setMembers` commits — without that, reads in the subscribe → fetch-response gap would silently no-op against an empty member list.
51
206
 
52
- | Hook | Returns |
53
- | ------------------------------- | ------------------------------------------------------------------------------------ |
54
- | `useMe()` | The signed-in End User |
55
- | `useConversations()` | List + `create` + realtime new-conversation push |
56
- | `useConversation(id)` | Single conversation read + refetch |
57
- | `useMembers(convId)` | Membership list + add/remove helpers |
58
- | `useMessages(convId)` | Live messages + `send` + pagination + optimistic dedup |
59
- | `useThread(convId, rootId)` | Replies + `sendReply` + pagination |
60
- | `useTyping(convId)` | Live `typing` set + `signalTyping` (debounced) |
61
- | `usePresence(convId)` | Live `online` set |
62
- | `useReactions(messageId, opts)` | Live reaction map + add/remove |
63
- | `useAttachmentUpload()` | Upload queue with per-item progress, `upload` / `uploadAll` / `cancel` / `remove` |
64
- | `useAttachmentUrl(id)` | Presigned download URL |
65
- | `useUser(userId)` | Customer-supplied profile via `config.userResolver`, cached + dedup'd |
66
- | `useRealtimeStatus()` | Socket lifecycle (connecting / connected / reconnecting / closed) |
67
- | `usePoolse()` | Raw `Poolse` instance (escape hatch) |
207
+ ### `useAttachmentUpload(): { queue, upload, uploadAll, cancel, remove, reset, ... }`
208
+
209
+ Queued multi-file uploads with per-item progress and cancel.
210
+
211
+ ```ts
212
+ const { queue, upload, uploadAll, cancel, remove, reset, uploading, error } = useAttachmentUpload();
213
+
214
+ // Single file
215
+ const att = await upload({
216
+ body: file,
217
+ contentType: file.type,
218
+ byteSize: file.size,
219
+ filename: file.name,
220
+ });
221
+
222
+ // Batch (parallel PUTs, returned in input order)
223
+ const atts = await uploadAll(
224
+ files.map((f) => ({ body: f, contentType: f.type, byteSize: f.size, filename: f.name })),
225
+ );
226
+ ```
68
227
 
69
- ## Identity resolution (`userResolver`)
228
+ Each `UploadItem` carries `{ localId, filename, contentType, byteSize, status, loaded, attachment, error }` with `status` cycling through `'pending' → 'uploading' → 'ready' | 'error' | 'cancelled'`. `loaded` is byte progress (browser-only — `fetch` doesn't expose upload progress, so the hook switches to XHR when `onProgress` is wired internally). Cancellation per item via `cancel(localId)`; full teardown via `reset()`.
70
229
 
71
- The SDK doesn't know your users' names or where their avatars live. Wire a `userResolver` once on the provider and every component that renders a sender (`<MessageBubble>`, `<MemberList>`, `<TypingIndicator>`, `<MentionInput>`, …) lights up automatically:
230
+ The hook tracks a `mountedRef` so a fast unmount aborts all in-flight uploads instead of leaking promises.
231
+
232
+ ### `useAttachmentUrl(attachmentId): { url, loading, error }`
233
+
234
+ Fetches a presigned GET URL for an attachment. Pass `null` to skip.
235
+
236
+ The URL has a ~1 hour TTL server-side. The hook doesn't auto-refresh — most UI lifetimes are shorter than the TTL anyway, and silently re-fetching mid-render would break image elements. If you have a long-lived dashboard, remount the hook (or wrap it in a parent that re-keys on a timer).
237
+
238
+ ### `useUser(userId): { profile, loading }`
239
+
240
+ Resolves a poolse `user_id` to your customer-supplied display name + avatar via the `userResolver` you configured on the provider. Cached + deduplicated across the app.
241
+
242
+ ```ts
243
+ const { profile, loading } = useUser(message.sender_id);
244
+ // profile: { displayName: string; avatarUrl: string | null } | null
245
+ ```
246
+
247
+ If you don't configure `userResolver`, the hook always returns `{ profile: null, loading: false }`. Components in `@poolse/react-ui` (sender labels, mention dropdowns, member lists) fall back to a stable color-tinted initials avatar in that case.
248
+
249
+ Pass falsy `userId` to no-op the hook (used internally by self-message bubbles, which don't need to resolve the sender).
250
+
251
+ ### `useRealtimeStatus(): RealtimeStatus`
252
+
253
+ ```ts
254
+ const status = useRealtimeStatus();
255
+ // 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'closed'
256
+ ```
257
+
258
+ For "Reconnecting…" banners without coupling to socket internals.
259
+
260
+ ## Wiring user identity
261
+
262
+ The SDK doesn't store names or avatars — that's your app's data. Wire `userResolver` once on the provider and every component that renders a sender lights up:
72
263
 
73
264
  ```tsx
74
265
  <PoolseProvider
75
266
  config={{
76
267
  apiUrl,
77
268
  getToken,
78
- userResolver: async (userId) => {
79
- const u = await fetch(`/api/users/by-poolse-id/${userId}`).then((r) => r.json());
269
+ userResolver: async (poolseUserId) => {
270
+ // Translate poolse's user_id to your own user record.
271
+ const u = await fetch(`/api/users/by-poolse-id/${poolseUserId}`).then((r) => r.json());
80
272
  return { displayName: u.full_name, avatarUrl: u.avatar_url };
81
273
  },
82
274
  }}
83
275
  >
84
- …
85
- </PoolseProvider>
86
276
  ```
87
277
 
88
- Resolved profiles are cached in-memory and concurrent lookups dedupe — a busy chat with 50 messages from 5 senders fires the resolver 5 times, not 50.
278
+ Resolved profiles are cached in-memory and concurrent lookups for the same id share one in-flight promise. Resolver errors are logged once (via `console.error` with `[poolse]` prefix) and cached as `null` so a single transient failure doesn't trigger a retry storm.
279
+
280
+ ## What you can rely on
89
281
 
90
- ## What the provider gives you
282
+ - **Mount-once SDK instance.** Stable WebSocket and channel subscriptions for the life of the provider; safe to pass fresh inline callbacks on every render.
283
+ - **Auto-rejoin on reconnect.** Phoenix handles channel rejoin internally; the JWT is re-read from `getToken` each handshake.
284
+ - **Optimistic dedup by client-generated id.** `useMessages.send` and `useThread.sendReply` insert under the same id the server will assign, so the realtime echo upserts in place rather than duplicating.
285
+ - **Synchronous rollback for optimistic edits.** Edit / delete / `removeReaction` / `removeMember` capture a ref snapshot before mutating, then restore the snapshot on error rather than relying on `setState((prev) => ...)`.
286
+ - **Buffered early events.** `useMembers` buffers `member:read` events that race the initial member fetch.
287
+ - **Abort-safe.** Hooks that fetch on mount cancel in-flight requests on unmount and ignore `AbortError`. Aborted fetches don't surface as "Failed to load" errors.
91
288
 
92
- - **One `Poolse` instance per mount.** Stable WebSocket and channel subscriptions for the life of the provider. Pass a fresh inline `config={{ getToken: … }}` on every render — `getToken` is read from a ref each call, so the SDK doesn't tear down.
93
- - **Auto-rejoin** when the socket drops + reconnects.
94
- - **Optimistic message dedup** by client-generated id — `useMessages.send()` adds the row under the same id the server will assign, so the realtime echo upserts in place rather than appending a duplicate.
289
+ ## Exports
290
+
291
+ ```ts
292
+ // Provider
293
+ export { PoolseProvider, usePoolse, type PoolseProviderProps };
294
+
295
+ // Hooks
296
+ export { useMe, useConversation, useConversations, useMembers, useMessages };
297
+ export { useReactions, type ReactionMap };
298
+ export { useThread, useTyping, usePresence, useRealtimeStatus };
299
+ export { useAttachmentUpload, type UploadItem, type UploadItemStatus };
300
+ export { useAttachmentUrl, useUser, type UseUserState };
301
+
302
+ // Re-exported SDK types (so you don't have to add @poolse/sdk to your imports)
303
+ export type {
304
+ Attachment,
305
+ AttachmentStatus,
306
+ AttachmentUploadInput,
307
+ AttachmentUploadRequest,
308
+ AttachmentUploadResponse,
309
+ Conversation,
310
+ ConversationCreateRequest,
311
+ Me,
312
+ MemberRole,
313
+ Membership,
314
+ Message,
315
+ MessageCreateRequest,
316
+ MessageType,
317
+ PoolseConfig,
318
+ PoolseUserProfile,
319
+ RealtimeStatus,
320
+ Uuid,
321
+ };
322
+
323
+ // Re-exported error classes
324
+ export { ApiError, AuthError, PoolseError, NetworkError, RateLimitedError };
325
+ ```
95
326
 
96
- ## Documentation
327
+ ## Links
97
328
 
98
- - Full hook reference + recipes — <https://poolse.dev/docs/react>
329
+ - Full docs — <https://poolse.dev/docs/react>
330
+ - SDK reference — [`@poolse/sdk`](https://www.npmjs.com/package/@poolse/sdk)
331
+ - UI components — [`@poolse/react-ui`](https://www.npmjs.com/package/@poolse/react-ui)
99
332
  - Source — <https://github.com/poolse-hq/js-sdk>
100
- - Issues — <https://github.com/poolse-hq/js-sdk/issues>
101
333
 
102
334
  ## License
103
335
 
package/dist/index.cjs CHANGED
@@ -865,14 +865,11 @@ function useAttachmentUpload() {
865
865
  const inf = inFlightRef.current.get(localId);
866
866
  if (inf) inf.controller.abort();
867
867
  }, []);
868
- const remove = react.useCallback(
869
- (localId) => {
870
- const inf = inFlightRef.current.get(localId);
871
- if (inf) inf.controller.abort();
872
- setQueue((q) => q.filter((it) => it.localId !== localId));
873
- },
874
- []
875
- );
868
+ const remove = react.useCallback((localId) => {
869
+ const inf = inFlightRef.current.get(localId);
870
+ if (inf) inf.controller.abort();
871
+ setQueue((q) => q.filter((it) => it.localId !== localId));
872
+ }, []);
876
873
  const reset = react.useCallback(() => {
877
874
  for (const v of inFlightRef.current.values()) v.controller.abort();
878
875
  inFlightRef.current.clear();