@poolse/react 2.1.6 → 2.3.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/README.md CHANGED
@@ -1,335 +1,335 @@
1
- # `@poolse/react`
2
-
3
- React hooks library for realtime chat. Wraps [`@poolse/sdk`](https://www.npmjs.com/package/@poolse/sdk) in `PoolseProvider` + a typed hook per resource — `useMessages`, `useThread`, `useReactions`, `useTyping`, `usePresence`, `useMembers`, `useAttachmentUpload`. Works in both web React and React Native — same hook surface, same behaviour, same types. No UI: for components see [`@poolse/react-ui`](https://www.npmjs.com/package/@poolse/react-ui) (web) or [`@poolse/react-native`](https://www.npmjs.com/package/@poolse/react-native) (mobile).
4
-
5
- > **MIT-licensed alternative to `sendbird-chat-react`, `stream-chat-react`, and `talkjs-react`.** Hooks-only by design — bring your own UI, or drop into the components when you don't want to.
6
-
7
- > **⚠️ Upgrading from 1.x?** See [MIGRATING.md](https://github.com/poolse-hq/js-sdk/blob/main/MIGRATING.md). `useUser`, `useTyping`, `usePresence`, and `useMembers().removeMember` all key by your `external_id` now instead of poolse uuids.
8
-
9
- ## Install
10
-
11
- ```bash
12
- npm install @poolse/react @poolse/sdk
13
- ```
14
-
15
- `react@>=18` is a peer dependency. React 19 works.
16
-
17
- ## Quick start
18
-
19
- ```tsx
20
- import { PoolseProvider, useMessages } from '@poolse/react';
21
-
22
- function App() {
23
- return (
24
- <PoolseProvider
25
- config={{
26
- apiUrl: 'https://api.poolse.dev',
27
- getToken: async () => {
28
- const res = await fetch('/api/chat-token', { method: 'POST' });
29
- const { token } = await res.json();
30
- return token;
31
- },
32
- }}
33
- >
34
- <Chat conversationId="00000000-0000-0000-0000-000000000000" />
35
- </PoolseProvider>
36
- );
37
- }
38
-
39
- function Chat({ conversationId }: { conversationId: string }) {
40
- const { messages, send, loading } = useMessages(conversationId);
41
-
42
- if (loading) return <div>Loading…</div>;
43
- return (
44
- <ul>
45
- {messages.map((m) => (
46
- <li key={m.id}>{m.body}</li>
47
- ))}
48
- <button onClick={() => send({ body: 'Hi' })}>Send</button>
49
- </ul>
50
- );
51
- }
52
- ```
53
-
54
- ## `<PoolseProvider>`
55
-
56
- ```tsx
57
- <PoolseProvider config={config}>{children}</PoolseProvider>
58
- ```
59
-
60
- `config` is the same `PoolseConfig` the bare SDK accepts (see [`@poolse/sdk`](https://www.npmjs.com/package/@poolse/sdk) for the full reference).
61
-
62
- 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:
63
-
64
- ```tsx
65
- // Fine — getToken is re-read through a ref each call.
66
- <PoolseProvider config={{ apiUrl, getToken: () => session.token }}>
67
- ```
68
-
69
- 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.
70
-
71
- ### `usePoolse()`
72
-
73
- Escape hatch — returns the underlying `Poolse` instance for callers that need direct REST or realtime access. Throws if there's no provider above it.
74
-
75
- ```ts
76
- const chat = usePoolse();
77
- const me = await chat.me.show();
78
- ```
79
-
80
- ## Hooks
81
-
82
- 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.
83
-
84
- ### `useMe(): { me, loading, error }`
85
-
86
- 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.
87
-
88
- ### `useConversation(id): { conversation, loading, error, refetch }`
89
-
90
- Single conversation, REST-only. Settings changes (name, avatar) won't show up live on this hook — call `refetch()` after a mutation. Live message activity for the same conversation flows through `useMessages` instead. Pass `null` to skip the fetch.
91
-
92
- ### `useConversations(): { conversations, create, unreadCounts, markConversationRead, ... }`
93
-
94
- The user's conversation list. On mount it fetches `GET /v1/conversations` and subscribes to the user's own `user:<id>` channel for two events: `conversation:created` (fires both when this user creates a conversation and when someone else adds them to one) and `conversation:updated` (fires after every message sent in any of their conversations).
95
-
96
- Return shape:
97
-
98
- ```ts
99
- const { conversations, loading, error, refetch, create, unreadCounts, markConversationRead } =
100
- useConversations();
101
- ```
102
-
103
- `conversations` is the list of `Conversation` rows. `refetch()` does an explicit re-sync. `create(attrs)` calls `POST /v1/conversations` and optimistically prepends the result; when the realtime echo arrives with the same id, id-based dedup replaces the row in place so there's no double-insert.
104
-
105
- `unreadCounts` is `Record<Uuid, number>`. Initial values come from the server's `Conversation.unread_count`. The `conversation:updated` subscription keeps them live without polling: when another member sends a message, the corresponding row's preview, timestamp, and unread badge update in place; when YOU send (`by_user_id` equals your id) the row floats to the top and unread stays at 0 (the server already advanced your read cursor in the same transaction). `markConversationRead(conversationId)` zeroes the badge locally — wire this from `ConversationView.onMarkedRead` so the sidebar clears immediately, before the server's read-receipt echo round-trips.
106
-
107
- ### `useMessages(conversationId): { messages, send, edit, delete, loadMore, hasMore, markReadUpTo, ... }`
108
-
109
- 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.
110
-
111
- ```ts
112
- const {
113
- messages, // Message[] — newest-LAST (the wire is newest-first; reversed)
114
- loading,
115
- error,
116
- hasMore,
117
- loadMore, // () => Promise<void>
118
- send, // (attrs: MessageCreateRequest) => Promise<Message>
119
- edit, // (messageId: Uuid, body: string) => Promise<Message>
120
- delete: del, // (messageId: Uuid) => Promise<void>
121
- markReadUpTo, // (messageId: Uuid) => Promise<void>
122
- } = useMessages(conversationId);
123
- ```
124
-
125
- **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.
126
-
127
- **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.
128
-
129
- **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.
130
-
131
- **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).
132
-
133
- ### `useThread(conversationId, rootMessageId): { replies, sendReply, edit, delete, loadMore, hasMore, ... }`
134
-
135
- 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.
136
-
137
- ```ts
138
- const {
139
- replies, // Message[] — oldest-first
140
- loading,
141
- error,
142
- hasMore,
143
- loadMore,
144
- sendReply, // (attrs: Omit<MessageCreateRequest, 'reply_to_id'>) => Promise<Message>
145
- edit,
146
- delete: del,
147
- } = useThread(conversationId, rootMessageId);
148
- ```
149
-
150
- 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`.
151
-
152
- ### `useReactions(messageId, opts): { reactions, addReaction, removeReaction }`
153
-
154
- Live reaction map for one message. Subscribes to `reaction:added` / `reaction:removed` filtered to the message id.
155
-
156
- ```ts
157
- const { reactions, addReaction, removeReaction } = useReactions(messageId, {
158
- conversationId,
159
- initialReactions: msg.reactions, // seed from the Message you already have
160
- currentUserId: meId, // enables optimistic add/remove
161
- });
162
- ```
163
-
164
- `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.
165
-
166
- ### `useTyping(conversationId): { typing, signalTyping }`
167
-
168
- ```ts
169
- const { typing, signalTyping } = useTyping(conversationId);
170
- // typing: Set<Uuid> of users currently typing (your own id is filtered out)
171
- // signalTyping: client-side debounced to ≤ 1 ping / 500ms
172
- ```
173
-
174
- 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.
175
-
176
- ### `usePresence(conversationId): { online }`
177
-
178
- ```ts
179
- const { online } = usePresence(conversationId);
180
- // online: Set<Uuid> of users currently joined to the channel
181
- ```
182
-
183
- 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).
184
-
185
- ### `useMembers(conversationId): { members, addMembers, removeMember, refetch, ... }`
186
-
187
- 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.
188
-
189
- ```ts
190
- const {
191
- members, // Membership[]
192
- loading,
193
- error,
194
- refetch,
195
- addMembers, // (externalIds: string[], { role? }?) => Promise<Membership[]>
196
- removeMember, // (userId: Uuid) => Promise<void>
197
- } = useMembers(conversationId);
198
- ```
199
-
200
- `addMembers` deduplicates by id (server returns an idempotent add). `removeMember` is optimistic with snapshot rollback.
201
-
202
- Empty `conversationId` opts out of the fetch (returns `{ members: [], loading: false }`).
203
-
204
- `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.
205
-
206
- ### `useAttachmentUpload(): { queue, upload, uploadAll, cancel, remove, reset, ... }`
207
-
208
- Queued multi-file uploads with per-item progress and cancel.
209
-
210
- ```ts
211
- const { queue, upload, uploadAll, cancel, remove, reset, uploading, error } = useAttachmentUpload();
212
-
213
- // Single file
214
- const att = await upload({
215
- body: file,
216
- contentType: file.type,
217
- byteSize: file.size,
218
- filename: file.name,
219
- });
220
-
221
- // Batch (parallel PUTs, returned in input order)
222
- const atts = await uploadAll(
223
- files.map((f) => ({ body: f, contentType: f.type, byteSize: f.size, filename: f.name })),
224
- );
225
- ```
226
-
227
- 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()`.
228
-
229
- The hook tracks a `mountedRef` so a fast unmount aborts all in-flight uploads instead of leaking promises.
230
-
231
- ### `useAttachmentUrl(attachmentId): { url, loading, error }`
232
-
233
- Fetches a presigned GET URL for an attachment. Pass `null` to skip.
234
-
235
- 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).
236
-
237
- ### `useUser(userId): { profile, loading }`
238
-
239
- 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.
240
-
241
- ```ts
242
- const { profile, loading } = useUser(message.sender_id);
243
- // profile: { displayName: string; avatarUrl: string | null } | null
244
- ```
245
-
246
- 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.
247
-
248
- Pass falsy `userId` to no-op the hook (used internally by self-message bubbles, which don't need to resolve the sender).
249
-
250
- ### `useRealtimeStatus(): RealtimeStatus`
251
-
252
- ```ts
253
- const status = useRealtimeStatus();
254
- // 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'closed'
255
- ```
256
-
257
- For "Reconnecting…" banners without coupling to socket internals.
258
-
259
- ## Wiring user identity
260
-
261
- 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:
262
-
263
- ```tsx
264
- <PoolseProvider
265
- config={{
266
- apiUrl,
267
- getToken,
268
- userResolver: async (poolseUserId) => {
269
- // Translate poolse's user_id to your own user record.
270
- const u = await fetch(`/api/users/by-poolse-id/${poolseUserId}`).then((r) => r.json());
271
- return { displayName: u.full_name, avatarUrl: u.avatar_url };
272
- },
273
- }}
274
- >
275
- ```
276
-
277
- 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.
278
-
279
- ## What you can rely on
280
-
281
- - **Mount-once SDK instance.** Stable WebSocket and channel subscriptions for the life of the provider; safe to pass fresh inline callbacks on every render.
282
- - **Auto-rejoin on reconnect.** Phoenix handles channel rejoin internally; the JWT is re-read from `getToken` each handshake.
283
- - **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.
284
- - **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) => ...)`.
285
- - **Buffered early events.** `useMembers` buffers `member:read` events that race the initial member fetch.
286
- - **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.
287
-
288
- ## Exports
289
-
290
- ```ts
291
- // Provider
292
- export { PoolseProvider, usePoolse, type PoolseProviderProps };
293
-
294
- // Hooks
295
- export { useMe, useConversation, useConversations, useMembers, useMessages };
296
- export { useReactions, type ReactionMap };
297
- export { useThread, useTyping, usePresence, useRealtimeStatus };
298
- export { useAttachmentUpload, type UploadItem, type UploadItemStatus };
299
- export { useAttachmentUrl, useUser, type UseUserState };
300
-
301
- // Re-exported SDK types (so you don't have to add @poolse/sdk to your imports)
302
- export type {
303
- Attachment,
304
- AttachmentStatus,
305
- AttachmentUploadInput,
306
- AttachmentUploadRequest,
307
- AttachmentUploadResponse,
308
- Conversation,
309
- ConversationCreateRequest,
310
- Me,
311
- MemberRole,
312
- Membership,
313
- Message,
314
- MessageCreateRequest,
315
- MessageType,
316
- PoolseConfig,
317
- PoolseUserProfile,
318
- RealtimeStatus,
319
- Uuid,
320
- };
321
-
322
- // Re-exported error classes
323
- export { ApiError, AuthError, PoolseError, NetworkError, RateLimitedError };
324
- ```
325
-
326
- ## Links
327
-
328
- - Full docs — <https://poolse.dev/docs/react>
329
- - SDK reference — [`@poolse/sdk`](https://www.npmjs.com/package/@poolse/sdk)
330
- - UI components — [`@poolse/react-ui`](https://www.npmjs.com/package/@poolse/react-ui)
331
- - Source — <https://github.com/poolse-hq/js-sdk>
332
-
333
- ## License
334
-
335
- MIT
1
+ # `@poolse/react`
2
+
3
+ React hooks library for realtime chat. Wraps [`@poolse/sdk`](https://www.npmjs.com/package/@poolse/sdk) in `PoolseProvider` + a typed hook per resource — `useMessages`, `useThread`, `useReactions`, `useTyping`, `usePresence`, `useMembers`, `useAttachmentUpload`. Works in both web React and React Native — same hook surface, same behaviour, same types. No UI: for components see [`@poolse/react-ui`](https://www.npmjs.com/package/@poolse/react-ui) (web) or [`@poolse/react-native`](https://www.npmjs.com/package/@poolse/react-native) (mobile).
4
+
5
+ > **MIT-licensed alternative to `sendbird-chat-react`, `stream-chat-react`, and `talkjs-react`.** Hooks-only by design — bring your own UI, or drop into the components when you don't want to.
6
+
7
+ > **⚠️ Upgrading from 1.x?** See [MIGRATING.md](https://github.com/poolse-hq/js-sdk/blob/main/MIGRATING.md). `useUser`, `useTyping`, `usePresence`, and `useMembers().removeMember` all key by your `external_id` now instead of poolse uuids.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install @poolse/react @poolse/sdk
13
+ ```
14
+
15
+ `react@>=18` is a peer dependency. React 19 works.
16
+
17
+ ## Quick start
18
+
19
+ ```tsx
20
+ import { PoolseProvider, useMessages } from '@poolse/react';
21
+
22
+ function App() {
23
+ return (
24
+ <PoolseProvider
25
+ config={{
26
+ apiUrl: 'https://api.poolse.dev',
27
+ getToken: async () => {
28
+ const res = await fetch('/api/chat-token', { method: 'POST' });
29
+ const { token } = await res.json();
30
+ return token;
31
+ },
32
+ }}
33
+ >
34
+ <Chat conversationId="00000000-0000-0000-0000-000000000000" />
35
+ </PoolseProvider>
36
+ );
37
+ }
38
+
39
+ function Chat({ conversationId }: { conversationId: string }) {
40
+ const { messages, send, loading } = useMessages(conversationId);
41
+
42
+ if (loading) return <div>Loading…</div>;
43
+ return (
44
+ <ul>
45
+ {messages.map((m) => (
46
+ <li key={m.id}>{m.body}</li>
47
+ ))}
48
+ <button onClick={() => send({ body: 'Hi' })}>Send</button>
49
+ </ul>
50
+ );
51
+ }
52
+ ```
53
+
54
+ ## `<PoolseProvider>`
55
+
56
+ ```tsx
57
+ <PoolseProvider config={config}>{children}</PoolseProvider>
58
+ ```
59
+
60
+ `config` is the same `PoolseConfig` the bare SDK accepts (see [`@poolse/sdk`](https://www.npmjs.com/package/@poolse/sdk) for the full reference).
61
+
62
+ 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:
63
+
64
+ ```tsx
65
+ // Fine — getToken is re-read through a ref each call.
66
+ <PoolseProvider config={{ apiUrl, getToken: () => session.token }}>
67
+ ```
68
+
69
+ 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.
70
+
71
+ ### `usePoolse()`
72
+
73
+ Escape hatch — returns the underlying `Poolse` instance for callers that need direct REST or realtime access. Throws if there's no provider above it.
74
+
75
+ ```ts
76
+ const chat = usePoolse();
77
+ const me = await chat.me.show();
78
+ ```
79
+
80
+ ## Hooks
81
+
82
+ 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.
83
+
84
+ ### `useMe(): { me, loading, error }`
85
+
86
+ 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.
87
+
88
+ ### `useConversation(id): { conversation, loading, error, refetch }`
89
+
90
+ Single conversation, REST-only. Settings changes (name, avatar) won't show up live on this hook — call `refetch()` after a mutation. Live message activity for the same conversation flows through `useMessages` instead. Pass `null` to skip the fetch.
91
+
92
+ ### `useConversations(): { conversations, create, unreadCounts, markConversationRead, ... }`
93
+
94
+ The user's conversation list. On mount it fetches `GET /v1/conversations` and subscribes to the user's own `user:<id>` channel for two events: `conversation:created` (fires both when this user creates a conversation and when someone else adds them to one) and `conversation:updated` (fires after every message sent in any of their conversations).
95
+
96
+ Return shape:
97
+
98
+ ```ts
99
+ const { conversations, loading, error, refetch, create, unreadCounts, markConversationRead } =
100
+ useConversations();
101
+ ```
102
+
103
+ `conversations` is the list of `Conversation` rows. `refetch()` does an explicit re-sync. `create(attrs)` calls `POST /v1/conversations` and optimistically prepends the result; when the realtime echo arrives with the same id, id-based dedup replaces the row in place so there's no double-insert.
104
+
105
+ `unreadCounts` is `Record<Uuid, number>`. Initial values come from the server's `Conversation.unread_count`. The `conversation:updated` subscription keeps them live without polling: when another member sends a message, the corresponding row's preview, timestamp, and unread badge update in place; when YOU send (`by_user_id` equals your id) the row floats to the top and unread stays at 0 (the server already advanced your read cursor in the same transaction). `markConversationRead(conversationId)` zeroes the badge locally — wire this from `ConversationView.onMarkedRead` so the sidebar clears immediately, before the server's read-receipt echo round-trips.
106
+
107
+ ### `useMessages(conversationId): { messages, send, edit, delete, loadMore, hasMore, markReadUpTo, ... }`
108
+
109
+ 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.
110
+
111
+ ```ts
112
+ const {
113
+ messages, // Message[] — newest-LAST (the wire is newest-first; reversed)
114
+ loading,
115
+ error,
116
+ hasMore,
117
+ loadMore, // () => Promise<void>
118
+ send, // (attrs: MessageCreateRequest) => Promise<Message>
119
+ edit, // (messageId: Uuid, body: string) => Promise<Message>
120
+ delete: del, // (messageId: Uuid) => Promise<void>
121
+ markReadUpTo, // (messageId: Uuid) => Promise<void>
122
+ } = useMessages(conversationId);
123
+ ```
124
+
125
+ **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.
126
+
127
+ **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.
128
+
129
+ **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.
130
+
131
+ **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).
132
+
133
+ ### `useThread(conversationId, rootMessageId): { replies, sendReply, edit, delete, loadMore, hasMore, ... }`
134
+
135
+ 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.
136
+
137
+ ```ts
138
+ const {
139
+ replies, // Message[] — oldest-first
140
+ loading,
141
+ error,
142
+ hasMore,
143
+ loadMore,
144
+ sendReply, // (attrs: Omit<MessageCreateRequest, 'reply_to_id'>) => Promise<Message>
145
+ edit,
146
+ delete: del,
147
+ } = useThread(conversationId, rootMessageId);
148
+ ```
149
+
150
+ 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`.
151
+
152
+ ### `useReactions(messageId, opts): { reactions, addReaction, removeReaction }`
153
+
154
+ Live reaction map for one message. Subscribes to `reaction:added` / `reaction:removed` filtered to the message id.
155
+
156
+ ```ts
157
+ const { reactions, addReaction, removeReaction } = useReactions(messageId, {
158
+ conversationId,
159
+ initialReactions: msg.reactions, // seed from the Message you already have
160
+ currentUserId: meId, // enables optimistic add/remove
161
+ });
162
+ ```
163
+
164
+ `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.
165
+
166
+ ### `useTyping(conversationId): { typing, signalTyping }`
167
+
168
+ ```ts
169
+ const { typing, signalTyping } = useTyping(conversationId);
170
+ // typing: Set<Uuid> of users currently typing (your own id is filtered out)
171
+ // signalTyping: client-side debounced to ≤ 1 ping / 500ms
172
+ ```
173
+
174
+ 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.
175
+
176
+ ### `usePresence(conversationId): { online }`
177
+
178
+ ```ts
179
+ const { online } = usePresence(conversationId);
180
+ // online: Set<Uuid> of users currently joined to the channel
181
+ ```
182
+
183
+ 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).
184
+
185
+ ### `useMembers(conversationId): { members, addMembers, removeMember, refetch, ... }`
186
+
187
+ 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.
188
+
189
+ ```ts
190
+ const {
191
+ members, // Membership[]
192
+ loading,
193
+ error,
194
+ refetch,
195
+ addMembers, // (externalIds: string[], { role? }?) => Promise<Membership[]>
196
+ removeMember, // (userId: Uuid) => Promise<void>
197
+ } = useMembers(conversationId);
198
+ ```
199
+
200
+ `addMembers` deduplicates by id (server returns an idempotent add). `removeMember` is optimistic with snapshot rollback.
201
+
202
+ Empty `conversationId` opts out of the fetch (returns `{ members: [], loading: false }`).
203
+
204
+ `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.
205
+
206
+ ### `useAttachmentUpload(): { queue, upload, uploadAll, cancel, remove, reset, ... }`
207
+
208
+ Queued multi-file uploads with per-item progress and cancel.
209
+
210
+ ```ts
211
+ const { queue, upload, uploadAll, cancel, remove, reset, uploading, error } = useAttachmentUpload();
212
+
213
+ // Single file
214
+ const att = await upload({
215
+ body: file,
216
+ contentType: file.type,
217
+ byteSize: file.size,
218
+ filename: file.name,
219
+ });
220
+
221
+ // Batch (parallel PUTs, returned in input order)
222
+ const atts = await uploadAll(
223
+ files.map((f) => ({ body: f, contentType: f.type, byteSize: f.size, filename: f.name })),
224
+ );
225
+ ```
226
+
227
+ 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()`.
228
+
229
+ The hook tracks a `mountedRef` so a fast unmount aborts all in-flight uploads instead of leaking promises.
230
+
231
+ ### `useAttachmentUrl(attachmentId): { url, loading, error }`
232
+
233
+ Fetches a presigned GET URL for an attachment. Pass `null` to skip.
234
+
235
+ 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).
236
+
237
+ ### `useUser(userId): { profile, loading }`
238
+
239
+ 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.
240
+
241
+ ```ts
242
+ const { profile, loading } = useUser(message.sender_id);
243
+ // profile: { displayName: string; avatarUrl: string | null } | null
244
+ ```
245
+
246
+ 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.
247
+
248
+ Pass falsy `userId` to no-op the hook (used internally by self-message bubbles, which don't need to resolve the sender).
249
+
250
+ ### `useRealtimeStatus(): RealtimeStatus`
251
+
252
+ ```ts
253
+ const status = useRealtimeStatus();
254
+ // 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'closed'
255
+ ```
256
+
257
+ For "Reconnecting…" banners without coupling to socket internals.
258
+
259
+ ## Wiring user identity
260
+
261
+ 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:
262
+
263
+ ```tsx
264
+ <PoolseProvider
265
+ config={{
266
+ apiUrl,
267
+ getToken,
268
+ userResolver: async (poolseUserId) => {
269
+ // Translate poolse's user_id to your own user record.
270
+ const u = await fetch(`/api/users/by-poolse-id/${poolseUserId}`).then((r) => r.json());
271
+ return { displayName: u.full_name, avatarUrl: u.avatar_url };
272
+ },
273
+ }}
274
+ >
275
+ ```
276
+
277
+ 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.
278
+
279
+ ## What you can rely on
280
+
281
+ - **Mount-once SDK instance.** Stable WebSocket and channel subscriptions for the life of the provider; safe to pass fresh inline callbacks on every render.
282
+ - **Auto-rejoin on reconnect.** Phoenix handles channel rejoin internally; the JWT is re-read from `getToken` each handshake.
283
+ - **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.
284
+ - **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) => ...)`.
285
+ - **Buffered early events.** `useMembers` buffers `member:read` events that race the initial member fetch.
286
+ - **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.
287
+
288
+ ## Exports
289
+
290
+ ```ts
291
+ // Provider
292
+ export { PoolseProvider, usePoolse, type PoolseProviderProps };
293
+
294
+ // Hooks
295
+ export { useMe, useConversation, useConversations, useMembers, useMessages };
296
+ export { useReactions, type ReactionMap };
297
+ export { useThread, useTyping, usePresence, useRealtimeStatus };
298
+ export { useAttachmentUpload, type UploadItem, type UploadItemStatus };
299
+ export { useAttachmentUrl, useUser, type UseUserState };
300
+
301
+ // Re-exported SDK types (so you don't have to add @poolse/sdk to your imports)
302
+ export type {
303
+ Attachment,
304
+ AttachmentStatus,
305
+ AttachmentUploadInput,
306
+ AttachmentUploadRequest,
307
+ AttachmentUploadResponse,
308
+ Conversation,
309
+ ConversationCreateRequest,
310
+ Me,
311
+ MemberRole,
312
+ Membership,
313
+ Message,
314
+ MessageCreateRequest,
315
+ MessageType,
316
+ PoolseConfig,
317
+ PoolseUserProfile,
318
+ RealtimeStatus,
319
+ Uuid,
320
+ };
321
+
322
+ // Re-exported error classes
323
+ export { ApiError, AuthError, PoolseError, NetworkError, RateLimitedError };
324
+ ```
325
+
326
+ ## Links
327
+
328
+ - Full docs — <https://poolse.dev/docs/react>
329
+ - SDK reference — [`@poolse/sdk`](https://www.npmjs.com/package/@poolse/sdk)
330
+ - UI components — [`@poolse/react-ui`](https://www.npmjs.com/package/@poolse/react-ui)
331
+ - Source — <https://github.com/poolse-hq/js-sdk>
332
+
333
+ ## License
334
+
335
+ MIT