@poolse/react 1.0.6 → 2.0.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,44 @@ 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
+ ## [2.0.0] — 2026-06-03
8
+
9
+ Lockstep release with `@poolse/sdk@2.0.0` and `@poolse/react-ui@2.0.0`.
10
+
11
+ ### Breaking
12
+
13
+ - **`useUser(externalId)`** — was `useUser(userId)`. The hook now
14
+ consumes the tenant's own user id. Pass the value from
15
+ `message.sender_external_id`, `membership.external_id`, or any
16
+ other external_id-shaped field on the wire.
17
+ - **`useTyping(conversationId).typing`** now returns `Set<externalId>`
18
+ (was `Set<userId>`). Same for `usePresence(conversationId).online`.
19
+ - **`useMembers(conversationId).removeMember(externalId)`** — was
20
+ `removeMember(userId)`. The hook translates external_id → user_id
21
+ internally; the DELETE endpoint still takes user_id but you don't
22
+ see it.
23
+
24
+ ### Migration
25
+
26
+ See `MIGRATING.md` at the repo root.
27
+
28
+ ## [1.1.0] — 2026-06-02
29
+
30
+ Lockstep release with `@poolse/sdk@1.1.0` and `@poolse/react-ui@1.1.0`.
31
+ No API changes in `@poolse/react` itself — version bumped to keep the
32
+ three packages in sync, so customers can pin one version across the
33
+ whole client surface.
34
+
35
+ ### Changed
36
+
37
+ - README rewritten with a full per-hook reference: signatures, return
38
+ shapes, the realtime channels each hook subscribes to, the optimistic
39
+ send + thread routing strategy in `useMessages`, the buffered-reads
40
+ race fix in `useMembers`, the cache + dedup semantics in `useUser`,
41
+ and the synchronous-snapshot rollback pattern used by edits and
42
+ optimistic removes.
43
+ - Repo-wide prettier formatting fixes (whitespace only).
44
+
7
45
  ## [1.0.0-beta.0] — 2026-06-01
8
46
 
9
47
  First beta of the stable API. Promotes the full 0.2.x hook surface
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # `@poolse/react`
2
2
 
3
- Headless React hooks for **[poolse](https://poolse.dev)** — realtime chat infrastructure.
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).
4
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)**.
5
+ > **⚠️ 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.
6
6
 
7
7
  ## Install
8
8
 
@@ -10,6 +10,8 @@ Provider + hooks for `useMe`, `useConversations`, `useMessages`, `useTyping`, `u
10
10
  npm install @poolse/react @poolse/sdk
11
11
  ```
12
12
 
13
+ `react@>=18` is a peer dependency. React 19 works.
14
+
13
15
  ## Quick start
14
16
 
15
17
  ```tsx
@@ -19,7 +21,7 @@ function App() {
19
21
  return (
20
22
  <PoolseProvider
21
23
  config={{
22
- apiUrl: 'https://chat.example.com',
24
+ apiUrl: 'https://api.poolse.dev',
23
25
  getToken: async () => {
24
26
  const res = await fetch('/api/chat-token', { method: 'POST' });
25
27
  const { token } = await res.json();
@@ -27,7 +29,7 @@ function App() {
27
29
  },
28
30
  }}
29
31
  >
30
- <Chat conversationId="<conversation-id>" />
32
+ <Chat conversationId="00000000-0000-0000-0000-000000000000" />
31
33
  </PoolseProvider>
32
34
  );
33
35
  }
@@ -41,63 +43,295 @@ function Chat({ conversationId }: { conversationId: string }) {
41
43
  {messages.map((m) => (
42
44
  <li key={m.id}>{m.body}</li>
43
45
  ))}
44
- <button onClick={() => send({ body: 'Hi!' })}>Send hi</button>
46
+ <button onClick={() => send({ body: 'Hi' })}>Send</button>
45
47
  </ul>
46
48
  );
47
49
  }
48
50
  ```
49
51
 
50
- ## Hooks at a glance
52
+ ## `<PoolseProvider>`
51
53
 
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) |
54
+ ```tsx
55
+ <PoolseProvider config={config}>{children}</PoolseProvider>
56
+ ```
68
57
 
69
- ## Identity resolution (`userResolver`)
58
+ `config` is the same `PoolseConfig` the bare SDK accepts (see [`@poolse/sdk`](https://www.npmjs.com/package/@poolse/sdk) for the full reference).
70
59
 
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:
60
+ 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:
61
+
62
+ ```tsx
63
+ // Fine — getToken is re-read through a ref each call.
64
+ <PoolseProvider config={{ apiUrl, getToken: () => session.token }}>
65
+ ```
66
+
67
+ 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.
68
+
69
+ ### `usePoolse()`
70
+
71
+ Escape hatch — returns the underlying `Poolse` instance for callers that need direct REST or realtime access. Throws if there's no provider above it.
72
+
73
+ ```ts
74
+ const chat = usePoolse();
75
+ const me = await chat.me.show();
76
+ ```
77
+
78
+ ## Hooks
79
+
80
+ 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.
81
+
82
+ ### `useMe(): { me, loading, error }`
83
+
84
+ 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.
85
+
86
+ ### `useConversation(id): { conversation, loading, error, refetch }`
87
+
88
+ 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.
89
+
90
+ ### `useConversations(): { conversations, create, unreadCounts, markConversationRead, ... }`
91
+
92
+ 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.
93
+
94
+ ```ts
95
+ const {
96
+ conversations, // Conversation[]
97
+ loading,
98
+ error,
99
+ refetch, // () => Promise<void>
100
+ create, // (attrs: ConversationCreateRequest) => Promise<Conversation>
101
+ unreadCounts, // Record<Uuid, number>
102
+ markConversationRead, // (conversationId: Uuid) => void — zeroes the badge locally
103
+ } = useConversations();
104
+ ```
105
+
106
+ `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.
107
+
108
+ 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.
109
+
110
+ ### `useMessages(conversationId): { messages, send, edit, delete, loadMore, hasMore, markReadUpTo, ... }`
111
+
112
+ 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.
113
+
114
+ ```ts
115
+ const {
116
+ messages, // Message[] — newest-LAST (the wire is newest-first; reversed)
117
+ loading,
118
+ error,
119
+ hasMore,
120
+ loadMore, // () => Promise<void>
121
+ send, // (attrs: MessageCreateRequest) => Promise<Message>
122
+ edit, // (messageId: Uuid, body: string) => Promise<Message>
123
+ delete: del, // (messageId: Uuid) => Promise<void>
124
+ markReadUpTo, // (messageId: Uuid) => Promise<void>
125
+ } = useMessages(conversationId);
126
+ ```
127
+
128
+ **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.
129
+
130
+ **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.
131
+
132
+ **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.
133
+
134
+ **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).
135
+
136
+ ### `useThread(conversationId, rootMessageId): { replies, sendReply, edit, delete, loadMore, hasMore, ... }`
137
+
138
+ 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.
139
+
140
+ ```ts
141
+ const {
142
+ replies, // Message[] — oldest-first
143
+ loading,
144
+ error,
145
+ hasMore,
146
+ loadMore,
147
+ sendReply, // (attrs: Omit<MessageCreateRequest, 'reply_to_id'>) => Promise<Message>
148
+ edit,
149
+ delete: del,
150
+ } = useThread(conversationId, rootMessageId);
151
+ ```
152
+
153
+ 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`.
154
+
155
+ ### `useReactions(messageId, opts): { reactions, addReaction, removeReaction }`
156
+
157
+ Live reaction map for one message. Subscribes to `reaction:added` / `reaction:removed` filtered to the message id.
158
+
159
+ ```ts
160
+ const { reactions, addReaction, removeReaction } = useReactions(messageId, {
161
+ conversationId,
162
+ initialReactions: msg.reactions, // seed from the Message you already have
163
+ currentUserId: meId, // enables optimistic add/remove
164
+ });
165
+ ```
166
+
167
+ `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.
168
+
169
+ ### `useTyping(conversationId): { typing, signalTyping }`
170
+
171
+ ```ts
172
+ const { typing, signalTyping } = useTyping(conversationId);
173
+ // typing: Set<Uuid> of users currently typing (your own id is filtered out)
174
+ // signalTyping: client-side debounced to ≤ 1 ping / 500ms
175
+ ```
176
+
177
+ 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.
178
+
179
+ ### `usePresence(conversationId): { online }`
180
+
181
+ ```ts
182
+ const { online } = usePresence(conversationId);
183
+ // online: Set<Uuid> of users currently joined to the channel
184
+ ```
185
+
186
+ 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).
187
+
188
+ ### `useMembers(conversationId): { members, addMembers, removeMember, refetch, ... }`
189
+
190
+ 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.
191
+
192
+ ```ts
193
+ const {
194
+ members, // Membership[]
195
+ loading,
196
+ error,
197
+ refetch,
198
+ addMembers, // (externalIds: string[], { role? }?) => Promise<Membership[]>
199
+ removeMember, // (userId: Uuid) => Promise<void>
200
+ } = useMembers(conversationId);
201
+ ```
202
+
203
+ `addMembers` deduplicates by id (server returns an idempotent add). `removeMember` is optimistic with snapshot rollback.
204
+
205
+ Empty `conversationId` opts out of the fetch (returns `{ members: [], loading: false }`).
206
+
207
+ `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.
208
+
209
+ ### `useAttachmentUpload(): { queue, upload, uploadAll, cancel, remove, reset, ... }`
210
+
211
+ Queued multi-file uploads with per-item progress and cancel.
212
+
213
+ ```ts
214
+ const { queue, upload, uploadAll, cancel, remove, reset, uploading, error } = useAttachmentUpload();
215
+
216
+ // Single file
217
+ const att = await upload({
218
+ body: file,
219
+ contentType: file.type,
220
+ byteSize: file.size,
221
+ filename: file.name,
222
+ });
223
+
224
+ // Batch (parallel PUTs, returned in input order)
225
+ const atts = await uploadAll(
226
+ files.map((f) => ({ body: f, contentType: f.type, byteSize: f.size, filename: f.name })),
227
+ );
228
+ ```
229
+
230
+ 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()`.
231
+
232
+ The hook tracks a `mountedRef` so a fast unmount aborts all in-flight uploads instead of leaking promises.
233
+
234
+ ### `useAttachmentUrl(attachmentId): { url, loading, error }`
235
+
236
+ Fetches a presigned GET URL for an attachment. Pass `null` to skip.
237
+
238
+ 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).
239
+
240
+ ### `useUser(userId): { profile, loading }`
241
+
242
+ 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.
243
+
244
+ ```ts
245
+ const { profile, loading } = useUser(message.sender_id);
246
+ // profile: { displayName: string; avatarUrl: string | null } | null
247
+ ```
248
+
249
+ 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.
250
+
251
+ Pass falsy `userId` to no-op the hook (used internally by self-message bubbles, which don't need to resolve the sender).
252
+
253
+ ### `useRealtimeStatus(): RealtimeStatus`
254
+
255
+ ```ts
256
+ const status = useRealtimeStatus();
257
+ // 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'closed'
258
+ ```
259
+
260
+ For "Reconnecting…" banners without coupling to socket internals.
261
+
262
+ ## Wiring user identity
263
+
264
+ 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
265
 
73
266
  ```tsx
74
267
  <PoolseProvider
75
268
  config={{
76
269
  apiUrl,
77
270
  getToken,
78
- userResolver: async (userId) => {
79
- const u = await fetch(`/api/users/by-poolse-id/${userId}`).then((r) => r.json());
271
+ userResolver: async (poolseUserId) => {
272
+ // Translate poolse's user_id to your own user record.
273
+ const u = await fetch(`/api/users/by-poolse-id/${poolseUserId}`).then((r) => r.json());
80
274
  return { displayName: u.full_name, avatarUrl: u.avatar_url };
81
275
  },
82
276
  }}
83
277
  >
84
- …
85
- </PoolseProvider>
86
278
  ```
87
279
 
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.
280
+ 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.
281
+
282
+ ## What you can rely on
89
283
 
90
- ## What the provider gives you
284
+ - **Mount-once SDK instance.** Stable WebSocket and channel subscriptions for the life of the provider; safe to pass fresh inline callbacks on every render.
285
+ - **Auto-rejoin on reconnect.** Phoenix handles channel rejoin internally; the JWT is re-read from `getToken` each handshake.
286
+ - **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.
287
+ - **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) => ...)`.
288
+ - **Buffered early events.** `useMembers` buffers `member:read` events that race the initial member fetch.
289
+ - **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
290
 
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.
291
+ ## Exports
292
+
293
+ ```ts
294
+ // Provider
295
+ export { PoolseProvider, usePoolse, type PoolseProviderProps };
296
+
297
+ // Hooks
298
+ export { useMe, useConversation, useConversations, useMembers, useMessages };
299
+ export { useReactions, type ReactionMap };
300
+ export { useThread, useTyping, usePresence, useRealtimeStatus };
301
+ export { useAttachmentUpload, type UploadItem, type UploadItemStatus };
302
+ export { useAttachmentUrl, useUser, type UseUserState };
303
+
304
+ // Re-exported SDK types (so you don't have to add @poolse/sdk to your imports)
305
+ export type {
306
+ Attachment,
307
+ AttachmentStatus,
308
+ AttachmentUploadInput,
309
+ AttachmentUploadRequest,
310
+ AttachmentUploadResponse,
311
+ Conversation,
312
+ ConversationCreateRequest,
313
+ Me,
314
+ MemberRole,
315
+ Membership,
316
+ Message,
317
+ MessageCreateRequest,
318
+ MessageType,
319
+ PoolseConfig,
320
+ PoolseUserProfile,
321
+ RealtimeStatus,
322
+ Uuid,
323
+ };
324
+
325
+ // Re-exported error classes
326
+ export { ApiError, AuthError, PoolseError, NetworkError, RateLimitedError };
327
+ ```
95
328
 
96
- ## Documentation
329
+ ## Links
97
330
 
98
- - Full hook reference + recipes — <https://poolse.dev/docs/react>
331
+ - Full docs — <https://poolse.dev/docs/react>
332
+ - SDK reference — [`@poolse/sdk`](https://www.npmjs.com/package/@poolse/sdk)
333
+ - UI components — [`@poolse/react-ui`](https://www.npmjs.com/package/@poolse/react-ui)
99
334
  - Source — <https://github.com/poolse-hq/js-sdk>
100
- - Issues — <https://github.com/poolse-hq/js-sdk/issues>
101
335
 
102
336
  ## License
103
337
 
package/dist/index.cjs CHANGED
@@ -269,11 +269,14 @@ function useMembers(conversationId) {
269
269
  [chat, conversationId]
270
270
  );
271
271
  const removeMember = react.useCallback(
272
- async (userId) => {
272
+ async (externalId) => {
273
273
  const snapshot = membersRef.current;
274
- setMembers((prev) => prev.filter((m) => m.user_id !== userId));
274
+ const target = snapshot.find((m) => m.external_id === externalId);
275
+ if (!target) return;
276
+ const targetUserId = target.user_id;
277
+ setMembers((prev) => prev.filter((m) => m.external_id !== externalId));
275
278
  try {
276
- await chat.conversations.one(conversationId).removeMember(userId);
279
+ await chat.conversations.one(conversationId).removeMember(targetUserId);
277
280
  } catch (err) {
278
281
  setMembers(snapshot);
279
282
  throw err;
@@ -294,6 +297,8 @@ function useMessages(conversationId) {
294
297
  const oldestSequenceRef = react.useRef(null);
295
298
  const meIdRef = react.useRef(me?.id ?? null);
296
299
  meIdRef.current = me?.id ?? null;
300
+ const meExternalIdRef = react.useRef(me?.external_id ?? null);
301
+ meExternalIdRef.current = me?.external_id ?? null;
297
302
  react.useEffect(() => {
298
303
  let cancelled = false;
299
304
  setLoading(true);
@@ -378,6 +383,7 @@ function useMessages(conversationId) {
378
383
  // here would briefly flash the row on the "other" side until
379
384
  // the realtime echo replaced it with the canonical row.
380
385
  sender_id: meIdRef.current,
386
+ sender_external_id: meExternalIdRef.current,
381
387
  type: attrs.type ?? "text",
382
388
  body: attrs.body ?? null,
383
389
  reply_to_id: attrs.reply_to_id ?? null,
@@ -615,6 +621,7 @@ function useThread(conversationId, rootMessageId) {
615
621
  tenant_id: "",
616
622
  conversation_id: conversationId,
617
623
  sender_id: null,
624
+ sender_external_id: null,
618
625
  type: attrs.type ?? "text",
619
626
  body: attrs.body ?? null,
620
627
  reply_to_id: rootMessageId,
@@ -712,26 +719,30 @@ function useTyping(conversationId) {
712
719
  const { me } = useMe();
713
720
  const [typing, setTyping] = react.useState(/* @__PURE__ */ new Set());
714
721
  const lastPingRef = react.useRef(0);
715
- const meIdRef = react.useRef(me?.id ?? null);
716
- meIdRef.current = me?.id ?? null;
722
+ const meExtRef = react.useRef(me?.external_id ?? null);
723
+ meExtRef.current = me?.external_id ?? null;
717
724
  react.useEffect(() => {
718
725
  setTyping(/* @__PURE__ */ new Set());
719
726
  const conv = chat.realtime.conversation(conversationId);
720
727
  const offStart = conv.onTypingStart((evt) => {
721
- if (meIdRef.current && evt.user_id === meIdRef.current) return;
728
+ const ext = evt.external_id;
729
+ if (!ext) return;
730
+ if (meExtRef.current && ext === meExtRef.current) return;
722
731
  setTyping((prev) => {
723
- if (prev.has(evt.user_id)) return prev;
732
+ if (prev.has(ext)) return prev;
724
733
  const next = new Set(prev);
725
- next.add(evt.user_id);
734
+ next.add(ext);
726
735
  return next;
727
736
  });
728
737
  });
729
738
  const offStop = conv.onTypingStop((evt) => {
730
- if (meIdRef.current && evt.user_id === meIdRef.current) return;
739
+ const ext = evt.external_id;
740
+ if (!ext) return;
741
+ if (meExtRef.current && ext === meExtRef.current) return;
731
742
  setTyping((prev) => {
732
- if (!prev.has(evt.user_id)) return prev;
743
+ if (!prev.has(ext)) return prev;
733
744
  const next = new Set(prev);
734
- next.delete(evt.user_id);
745
+ next.delete(ext);
735
746
  return next;
736
747
  });
737
748
  });
@@ -748,6 +759,14 @@ function useTyping(conversationId) {
748
759
  }, [chat, conversationId]);
749
760
  return { typing, signalTyping };
750
761
  }
762
+ function externalIdsFrom(state) {
763
+ const out = [];
764
+ for (const presence of Object.values(state)) {
765
+ const meta = presence?.metas?.[0];
766
+ if (meta?.external_id) out.push(meta.external_id);
767
+ }
768
+ return out;
769
+ }
751
770
  function usePresence(conversationId) {
752
771
  const chat = usePoolse();
753
772
  const [online, setOnline] = react.useState(/* @__PURE__ */ new Set());
@@ -756,13 +775,13 @@ function usePresence(conversationId) {
756
775
  if (!conversationId) return;
757
776
  const conv = chat.realtime.conversation(conversationId);
758
777
  const applyState = (state) => {
759
- setOnline(new Set(Object.keys(state)));
778
+ setOnline(new Set(externalIdsFrom(state)));
760
779
  };
761
780
  const applyDiff = (diff) => {
762
781
  setOnline((prev) => {
763
782
  const next = new Set(prev);
764
- if (diff.joins) Object.keys(diff.joins).forEach((id) => next.add(id));
765
- if (diff.leaves) Object.keys(diff.leaves).forEach((id) => next.delete(id));
783
+ if (diff.joins) externalIdsFrom(diff.joins).forEach((ext) => next.add(ext));
784
+ if (diff.leaves) externalIdsFrom(diff.leaves).forEach((ext) => next.delete(ext));
766
785
  return next;
767
786
  });
768
787
  };
@@ -865,14 +884,11 @@ function useAttachmentUpload() {
865
884
  const inf = inFlightRef.current.get(localId);
866
885
  if (inf) inf.controller.abort();
867
886
  }, []);
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
- );
887
+ const remove = react.useCallback((localId) => {
888
+ const inf = inFlightRef.current.get(localId);
889
+ if (inf) inf.controller.abort();
890
+ setQueue((q) => q.filter((it) => it.localId !== localId));
891
+ }, []);
876
892
  const reset = react.useCallback(() => {
877
893
  for (const v of inFlightRef.current.values()) v.controller.abort();
878
894
  inFlightRef.current.clear();
@@ -922,32 +938,32 @@ function useAttachmentUrl(attachmentId) {
922
938
  return { url, loading, error };
923
939
  }
924
940
  var EMPTY = { profile: null, loading: false };
925
- function useUser(userId) {
941
+ function useUser(externalId) {
926
942
  const chat = usePoolse();
927
943
  const [state, setState] = react.useState(() => {
928
- if (!userId) return EMPTY;
929
- const cached = chat.users.peek(userId);
944
+ if (!externalId) return EMPTY;
945
+ const cached = chat.users.peek(externalId);
930
946
  if (cached === void 0) return { profile: null, loading: true };
931
947
  return { profile: cached, loading: false };
932
948
  });
933
949
  react.useEffect(() => {
934
- if (!userId) {
950
+ if (!externalId) {
935
951
  setState(EMPTY);
936
952
  return;
937
953
  }
938
954
  const apply = () => {
939
- const cached = chat.users.peek(userId);
955
+ const cached = chat.users.peek(externalId);
940
956
  if (cached === void 0) {
941
957
  setState({ profile: null, loading: true });
942
- void chat.users.get(userId);
958
+ void chat.users.get(externalId);
943
959
  } else {
944
960
  setState({ profile: cached, loading: false });
945
961
  }
946
962
  };
947
- const off = chat.users.subscribe(userId, apply);
963
+ const off = chat.users.subscribe(externalId, apply);
948
964
  apply();
949
965
  return off;
950
- }, [chat, userId]);
966
+ }, [chat, externalId]);
951
967
  return state;
952
968
  }
953
969