@poolse/react 0.1.0 → 0.2.0-alpha.2

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,45 @@ 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
+ ## [0.2.0] — 2026-06-01
8
+
9
+ ### Added
10
+
11
+ - `useMessages.edit(messageId, body)` — optimistic edit with rollback
12
+ on server error. Server enforces sender-only authorization.
13
+ - `useMessages.delete(messageId)` — optimistic soft-delete (sets
14
+ `deleted_at` + null body locally, rolls back on error).
15
+ - `useMessages.markReadUpTo(messageId)` — wraps
16
+ `chat.conversations.one(id).messages.markRead`; advances the
17
+ caller's read cursor and triggers server-side read-receipt
18
+ broadcasts to other members.
19
+ - `useConversations` now exposes `unreadCounts: Record<convId, number>`
20
+ plus `markConversationRead(convId)` for sidebar-badge UIs.
21
+ - `useThread.edit(messageId, body)` / `useThread.delete(messageId)`
22
+ for inline reply editing inside `<ThreadView>`.
23
+
24
+ ### Changed
25
+
26
+ - `useMembers(conversationId)` now treats an empty string id as
27
+ "skip fetch" (returns `{ members: [], loading: false }`) so
28
+ callers can conditionally enable member loading without wrapping
29
+ in a `enabled ?` guard.
30
+ - `useMembers` subscribes to the new `member:read` realtime event
31
+ and advances the matching membership's `last_read_message_id` /
32
+ `last_read_at` in place. The sender's check-double glyph now
33
+ flips in real time without a refetch.
34
+ - Provider auto-resolves the SDK's new default `apiUrl` when none
35
+ is passed in config.
36
+
37
+ ## [0.1.1] — 2026-06-01
38
+
39
+ ### Fixed
40
+
41
+ - `useMessages.send()` now stamps the optimistic temp message's
42
+ `sender_id` with the current user's id (via `useMe` internally).
43
+ Without this, self-sent messages briefly rendered on the "other"
44
+ side of the chat until the realtime echo replaced them.
45
+
7
46
  ## [0.1.0] — 2026-06-01
8
47
 
9
48
  First publishable release.
package/dist/index.cjs CHANGED
@@ -19,7 +19,9 @@ function PoolseProvider({ config, children }) {
19
19
  socketPath: initial.socketPath
20
20
  };
21
21
  return new sdk.Poolse({
22
- apiUrl: initial.apiUrl,
22
+ // apiUrl is optional in PoolseConfig — only forward when set
23
+ // so the SDK can fall back to its hosted-endpoint default.
24
+ ...initial.apiUrl !== void 0 ? { apiUrl: initial.apiUrl } : {},
23
25
  // Always read the latest callbacks from the ref — they're free
24
26
  // to change between renders.
25
27
  getToken: () => configRef.current.getToken(),
@@ -162,7 +164,21 @@ function useConversations() {
162
164
  });
163
165
  return off;
164
166
  }, [chat, me]);
165
- return { conversations, loading, error, refetch, create };
167
+ const markConversationRead = react.useCallback((conversationId) => {
168
+ setConversations(
169
+ (prev) => prev.map((c) => c.id === conversationId ? { ...c, unread_count: 0 } : c)
170
+ );
171
+ }, []);
172
+ const unreadCounts = react.useMemo(() => {
173
+ const out = {};
174
+ for (const c of conversations) {
175
+ if (typeof c.unread_count === "number" && c.unread_count > 0) {
176
+ out[c.id] = c.unread_count;
177
+ }
178
+ }
179
+ return out;
180
+ }, [conversations]);
181
+ return { conversations, loading, error, refetch, create, unreadCounts, markConversationRead };
166
182
  }
167
183
  function prepend(prev, conv) {
168
184
  const idx = prev.findIndex((c) => c.id === conv.id);
@@ -180,6 +196,12 @@ function useMembers(conversationId) {
180
196
  membersRef.current = members;
181
197
  const fetcher = react.useCallback(
182
198
  async (signal) => {
199
+ if (!conversationId) {
200
+ setLoading(false);
201
+ setMembers([]);
202
+ setError(null);
203
+ return;
204
+ }
183
205
  setLoading(true);
184
206
  setError(null);
185
207
  try {
@@ -199,6 +221,22 @@ function useMembers(conversationId) {
199
221
  void fetcher(controller.signal);
200
222
  return () => controller.abort();
201
223
  }, [fetcher]);
224
+ react.useEffect(() => {
225
+ if (!conversationId) return;
226
+ const channel = chat.realtime.conversation(conversationId);
227
+ const off = channel.onMemberRead((evt) => {
228
+ setMembers(
229
+ (prev) => prev.map(
230
+ (m) => m.user_id === evt.user_id ? {
231
+ ...m,
232
+ last_read_message_id: evt.last_read_message_id,
233
+ last_read_at: evt.last_read_at
234
+ } : m
235
+ )
236
+ );
237
+ });
238
+ return () => off();
239
+ }, [chat, conversationId]);
202
240
  const refetch = react.useCallback(() => fetcher(), [fetcher]);
203
241
  const addMembers = react.useCallback(
204
242
  async (externalIds, opts = {}) => {
@@ -231,11 +269,14 @@ function useMembers(conversationId) {
231
269
  var PAGE_SIZE = 50;
232
270
  function useMessages(conversationId) {
233
271
  const chat = usePoolse();
272
+ const { me } = useMe();
234
273
  const [messages, setMessages] = react.useState([]);
235
274
  const [loading, setLoading] = react.useState(true);
236
275
  const [error, setError] = react.useState(null);
237
276
  const [hasMore, setHasMore] = react.useState(true);
238
277
  const oldestSequenceRef = react.useRef(null);
278
+ const meIdRef = react.useRef(me?.id ?? null);
279
+ meIdRef.current = me?.id ?? null;
239
280
  react.useEffect(() => {
240
281
  let cancelled = false;
241
282
  setLoading(true);
@@ -298,9 +339,13 @@ function useMessages(conversationId) {
298
339
  id,
299
340
  tenant_id: "",
300
341
  conversation_id: conversationId,
301
- sender_id: null,
342
+ // Stamp the current user's id so the optimistic row renders
343
+ // on the right (self) side from the moment it appears. Null
344
+ // here would briefly flash the row on the "other" side until
345
+ // the realtime echo replaced it with the canonical row.
346
+ sender_id: meIdRef.current,
302
347
  type: attrs.type ?? "text",
303
- body: attrs.body,
348
+ body: attrs.body ?? null,
304
349
  reply_to_id: attrs.reply_to_id ?? null,
305
350
  thread_root_id: null,
306
351
  mentions: attrs.mentions ?? [],
@@ -325,7 +370,69 @@ function useMessages(conversationId) {
325
370
  },
326
371
  [chat, conversationId]
327
372
  );
328
- return { messages, loading, error, hasMore, loadMore, send };
373
+ const edit = react.useCallback(
374
+ async (messageId, body) => {
375
+ let previous;
376
+ setMessages(
377
+ (prev) => prev.map((m) => {
378
+ if (m.id !== messageId) return m;
379
+ previous = m;
380
+ return { ...m, body, edited_at: (/* @__PURE__ */ new Date()).toISOString() };
381
+ })
382
+ );
383
+ try {
384
+ const updated = await chat.messages.one(messageId).update({ body });
385
+ setMessages((prev) => prev.map((m) => m.id === messageId ? updated : m));
386
+ return updated;
387
+ } catch (err) {
388
+ if (previous) {
389
+ const restore = previous;
390
+ setMessages((prev) => prev.map((m) => m.id === messageId ? restore : m));
391
+ }
392
+ throw err;
393
+ }
394
+ },
395
+ [chat]
396
+ );
397
+ const deleteMessage = react.useCallback(
398
+ async (messageId) => {
399
+ let previous;
400
+ setMessages(
401
+ (prev) => prev.map((m) => {
402
+ if (m.id !== messageId) return m;
403
+ previous = m;
404
+ return { ...m, body: null, deleted_at: (/* @__PURE__ */ new Date()).toISOString() };
405
+ })
406
+ );
407
+ try {
408
+ await chat.messages.one(messageId).delete();
409
+ } catch (err) {
410
+ if (previous) {
411
+ const restore = previous;
412
+ setMessages((prev) => prev.map((m) => m.id === messageId ? restore : m));
413
+ }
414
+ throw err;
415
+ }
416
+ },
417
+ [chat]
418
+ );
419
+ const markReadUpTo = react.useCallback(
420
+ async (messageId) => {
421
+ await chat.conversations.one(conversationId).messages.markRead(messageId);
422
+ },
423
+ [chat, conversationId]
424
+ );
425
+ return {
426
+ messages,
427
+ loading,
428
+ error,
429
+ hasMore,
430
+ loadMore,
431
+ send,
432
+ edit,
433
+ delete: deleteMessage,
434
+ markReadUpTo
435
+ };
329
436
  }
330
437
  function upsertById(prev, msg) {
331
438
  const idx = prev.findIndex((m) => m.id === msg.id);
@@ -469,7 +576,7 @@ function useThread(conversationId, rootMessageId) {
469
576
  conversation_id: conversationId,
470
577
  sender_id: null,
471
578
  type: attrs.type ?? "text",
472
- body: attrs.body,
579
+ body: attrs.body ?? null,
473
580
  reply_to_id: rootMessageId,
474
581
  thread_root_id: rootMessageId,
475
582
  mentions: attrs.mentions ?? [],
@@ -496,7 +603,62 @@ function useThread(conversationId, rootMessageId) {
496
603
  },
497
604
  [chat, conversationId, rootMessageId]
498
605
  );
499
- return { replies, loading, error, hasMore, loadMore, sendReply };
606
+ const edit = react.useCallback(
607
+ async (messageId, body) => {
608
+ let previous;
609
+ setReplies(
610
+ (prev) => prev.map((m) => {
611
+ if (m.id !== messageId) return m;
612
+ previous = m;
613
+ return { ...m, body, edited_at: (/* @__PURE__ */ new Date()).toISOString() };
614
+ })
615
+ );
616
+ try {
617
+ const updated = await chat.messages.one(messageId).update({ body });
618
+ setReplies((prev) => prev.map((m) => m.id === messageId ? updated : m));
619
+ return updated;
620
+ } catch (err) {
621
+ if (previous) {
622
+ const restore = previous;
623
+ setReplies((prev) => prev.map((m) => m.id === messageId ? restore : m));
624
+ }
625
+ throw err;
626
+ }
627
+ },
628
+ [chat]
629
+ );
630
+ const deleteReply = react.useCallback(
631
+ async (messageId) => {
632
+ let previous;
633
+ setReplies(
634
+ (prev) => prev.map((m) => {
635
+ if (m.id !== messageId) return m;
636
+ previous = m;
637
+ return { ...m, body: null, deleted_at: (/* @__PURE__ */ new Date()).toISOString() };
638
+ })
639
+ );
640
+ try {
641
+ await chat.messages.one(messageId).delete();
642
+ } catch (err) {
643
+ if (previous) {
644
+ const restore = previous;
645
+ setReplies((prev) => prev.map((m) => m.id === messageId ? restore : m));
646
+ }
647
+ throw err;
648
+ }
649
+ },
650
+ [chat]
651
+ );
652
+ return {
653
+ replies,
654
+ loading,
655
+ error,
656
+ hasMore,
657
+ loadMore,
658
+ sendReply,
659
+ edit,
660
+ delete: deleteReply
661
+ };
500
662
  }
501
663
  function upsertById2(prev, msg) {
502
664
  const idx = prev.findIndex((m) => m.id === msg.id);