@poolse/react 0.1.1 → 0.2.0-alpha.3

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/dist/index.d.cts CHANGED
@@ -88,6 +88,20 @@ interface UseConversationsState {
88
88
  error: Error | null;
89
89
  refetch: () => Promise<void>;
90
90
  create: (attrs: ConversationCreateRequest) => Promise<Conversation>;
91
+ /**
92
+ * Per-conversation unread count, keyed by conversation id. Sourced
93
+ * from `Conversation.unread_count` on the initial fetch and kept
94
+ * fresh as the caller marks conversations read (via `markRead`).
95
+ *
96
+ * Note: cross-conversation incrementing on a new inbound message
97
+ * requires subscribing to every conversation channel, which the SDK
98
+ * doesn't do by default. Counts in this map are accurate for the
99
+ * conversation the caller has open + correct at fetch time for the
100
+ * rest; refetch on a fresh tab focus for true reconciliation.
101
+ */
102
+ unreadCounts: Record<Uuid, number>;
103
+ /** Force a conversation's unread to 0 — invoked by `markReadUpTo`. */
104
+ markConversationRead: (conversationId: Uuid) => void;
91
105
  }
92
106
  /**
93
107
  * List of conversations the current End User can see.
@@ -125,11 +139,13 @@ interface UseMembersState {
125
139
  }
126
140
  /**
127
141
  * Live-ish membership list for a conversation. Initial REST fetch on
128
- * mount; mutation helpers update local state in place. No realtime
129
- * sync yet — the backend doesn't broadcast `member:joined` /
130
- * `member:left` events. For now, callers who depend on
131
- * cross-client member updates should poll with `refetch()` or
132
- * remount the hook.
142
+ * mount; mutation helpers update local state in place.
143
+ *
144
+ * Realtime: subscribes to `member:read` on the conversation channel
145
+ * and advances the matching membership's `last_read_message_id` /
146
+ * `last_read_at` so the sender's read-receipt glyph flips from "sent"
147
+ * to "read" without a refetch. `member:joined` / `member:left` aren't
148
+ * broadcast yet — for cross-client adds/removes, call `refetch()`.
133
149
  */
134
150
  declare function useMembers(conversationId: Uuid): UseMembersState;
135
151
 
@@ -143,6 +159,26 @@ interface UseMessagesState {
143
159
  loadMore: () => Promise<void>;
144
160
  /** Optimistically append + POST. Replaces the optimistic row with the server response on success. */
145
161
  send: (attrs: MessageCreateRequest) => Promise<Message>;
162
+ /**
163
+ * Edit a message you previously sent. Optimistically updates the
164
+ * local row, rolls back on server error. Server enforces
165
+ * sender-only authorization — calling this for a message you didn't
166
+ * send will throw an ApiError.
167
+ */
168
+ edit: (messageId: Uuid, body: string) => Promise<Message>;
169
+ /**
170
+ * Soft-delete a message you sent (or that you have admin authority
171
+ * over). Optimistically applies the tombstone (`deleted_at` + null
172
+ * body) locally, rolls back on error.
173
+ */
174
+ delete: (messageId: Uuid) => Promise<void>;
175
+ /**
176
+ * Advance the caller's read cursor to `messageId`. Server emits
177
+ * read-receipt updates to other members; the conversation's
178
+ * unread count drops to zero. No local state mutation — this is
179
+ * "tell the server we read up to here".
180
+ */
181
+ markReadUpTo: (messageId: Uuid) => Promise<void>;
146
182
  }
147
183
  /**
148
184
  * Live message list for `conversationId`. Combines:
@@ -225,6 +261,16 @@ interface UseThreadState {
225
261
  * `MessageCreateRequest` fields) as you would to `useMessages.send`.
226
262
  */
227
263
  sendReply: (attrs: Omit<MessageCreateRequest, 'reply_to_id'>) => Promise<Message>;
264
+ /**
265
+ * Edit a reply (or the root message). Optimistic with rollback —
266
+ * mirrors `useMessages.edit`. Server enforces sender-only authz.
267
+ */
268
+ edit: (messageId: Uuid, body: string) => Promise<Message>;
269
+ /**
270
+ * Soft-delete a reply (or the root message). Optimistic tombstone
271
+ * (`deleted_at` + null body) — mirrors `useMessages.delete`.
272
+ */
273
+ delete: (messageId: Uuid) => Promise<void>;
228
274
  }
229
275
  /**
230
276
  * Thread view rooted at `rootMessageId`. Loads replies oldest-first
package/dist/index.d.ts CHANGED
@@ -88,6 +88,20 @@ interface UseConversationsState {
88
88
  error: Error | null;
89
89
  refetch: () => Promise<void>;
90
90
  create: (attrs: ConversationCreateRequest) => Promise<Conversation>;
91
+ /**
92
+ * Per-conversation unread count, keyed by conversation id. Sourced
93
+ * from `Conversation.unread_count` on the initial fetch and kept
94
+ * fresh as the caller marks conversations read (via `markRead`).
95
+ *
96
+ * Note: cross-conversation incrementing on a new inbound message
97
+ * requires subscribing to every conversation channel, which the SDK
98
+ * doesn't do by default. Counts in this map are accurate for the
99
+ * conversation the caller has open + correct at fetch time for the
100
+ * rest; refetch on a fresh tab focus for true reconciliation.
101
+ */
102
+ unreadCounts: Record<Uuid, number>;
103
+ /** Force a conversation's unread to 0 — invoked by `markReadUpTo`. */
104
+ markConversationRead: (conversationId: Uuid) => void;
91
105
  }
92
106
  /**
93
107
  * List of conversations the current End User can see.
@@ -125,11 +139,13 @@ interface UseMembersState {
125
139
  }
126
140
  /**
127
141
  * Live-ish membership list for a conversation. Initial REST fetch on
128
- * mount; mutation helpers update local state in place. No realtime
129
- * sync yet — the backend doesn't broadcast `member:joined` /
130
- * `member:left` events. For now, callers who depend on
131
- * cross-client member updates should poll with `refetch()` or
132
- * remount the hook.
142
+ * mount; mutation helpers update local state in place.
143
+ *
144
+ * Realtime: subscribes to `member:read` on the conversation channel
145
+ * and advances the matching membership's `last_read_message_id` /
146
+ * `last_read_at` so the sender's read-receipt glyph flips from "sent"
147
+ * to "read" without a refetch. `member:joined` / `member:left` aren't
148
+ * broadcast yet — for cross-client adds/removes, call `refetch()`.
133
149
  */
134
150
  declare function useMembers(conversationId: Uuid): UseMembersState;
135
151
 
@@ -143,6 +159,26 @@ interface UseMessagesState {
143
159
  loadMore: () => Promise<void>;
144
160
  /** Optimistically append + POST. Replaces the optimistic row with the server response on success. */
145
161
  send: (attrs: MessageCreateRequest) => Promise<Message>;
162
+ /**
163
+ * Edit a message you previously sent. Optimistically updates the
164
+ * local row, rolls back on server error. Server enforces
165
+ * sender-only authorization — calling this for a message you didn't
166
+ * send will throw an ApiError.
167
+ */
168
+ edit: (messageId: Uuid, body: string) => Promise<Message>;
169
+ /**
170
+ * Soft-delete a message you sent (or that you have admin authority
171
+ * over). Optimistically applies the tombstone (`deleted_at` + null
172
+ * body) locally, rolls back on error.
173
+ */
174
+ delete: (messageId: Uuid) => Promise<void>;
175
+ /**
176
+ * Advance the caller's read cursor to `messageId`. Server emits
177
+ * read-receipt updates to other members; the conversation's
178
+ * unread count drops to zero. No local state mutation — this is
179
+ * "tell the server we read up to here".
180
+ */
181
+ markReadUpTo: (messageId: Uuid) => Promise<void>;
146
182
  }
147
183
  /**
148
184
  * Live message list for `conversationId`. Combines:
@@ -225,6 +261,16 @@ interface UseThreadState {
225
261
  * `MessageCreateRequest` fields) as you would to `useMessages.send`.
226
262
  */
227
263
  sendReply: (attrs: Omit<MessageCreateRequest, 'reply_to_id'>) => Promise<Message>;
264
+ /**
265
+ * Edit a reply (or the root message). Optimistic with rollback —
266
+ * mirrors `useMessages.edit`. Server enforces sender-only authz.
267
+ */
268
+ edit: (messageId: Uuid, body: string) => Promise<Message>;
269
+ /**
270
+ * Soft-delete a reply (or the root message). Optimistic tombstone
271
+ * (`deleted_at` + null body) — mirrors `useMessages.delete`.
272
+ */
273
+ delete: (messageId: Uuid) => Promise<void>;
228
274
  }
229
275
  /**
230
276
  * Thread view rooted at `rootMessageId`. Loads replies oldest-first
package/dist/index.js CHANGED
@@ -18,7 +18,9 @@ function PoolseProvider({ config, children }) {
18
18
  socketPath: initial.socketPath
19
19
  };
20
20
  return new Poolse({
21
- apiUrl: initial.apiUrl,
21
+ // apiUrl is optional in PoolseConfig — only forward when set
22
+ // so the SDK can fall back to its hosted-endpoint default.
23
+ ...initial.apiUrl !== void 0 ? { apiUrl: initial.apiUrl } : {},
22
24
  // Always read the latest callbacks from the ref — they're free
23
25
  // to change between renders.
24
26
  getToken: () => configRef.current.getToken(),
@@ -161,7 +163,21 @@ function useConversations() {
161
163
  });
162
164
  return off;
163
165
  }, [chat, me]);
164
- return { conversations, loading, error, refetch, create };
166
+ const markConversationRead = useCallback((conversationId) => {
167
+ setConversations(
168
+ (prev) => prev.map((c) => c.id === conversationId ? { ...c, unread_count: 0 } : c)
169
+ );
170
+ }, []);
171
+ const unreadCounts = useMemo(() => {
172
+ const out = {};
173
+ for (const c of conversations) {
174
+ if (typeof c.unread_count === "number" && c.unread_count > 0) {
175
+ out[c.id] = c.unread_count;
176
+ }
177
+ }
178
+ return out;
179
+ }, [conversations]);
180
+ return { conversations, loading, error, refetch, create, unreadCounts, markConversationRead };
165
181
  }
166
182
  function prepend(prev, conv) {
167
183
  const idx = prev.findIndex((c) => c.id === conv.id);
@@ -179,6 +195,12 @@ function useMembers(conversationId) {
179
195
  membersRef.current = members;
180
196
  const fetcher = useCallback(
181
197
  async (signal) => {
198
+ if (!conversationId) {
199
+ setLoading(false);
200
+ setMembers([]);
201
+ setError(null);
202
+ return;
203
+ }
182
204
  setLoading(true);
183
205
  setError(null);
184
206
  try {
@@ -198,6 +220,22 @@ function useMembers(conversationId) {
198
220
  void fetcher(controller.signal);
199
221
  return () => controller.abort();
200
222
  }, [fetcher]);
223
+ useEffect(() => {
224
+ if (!conversationId) return;
225
+ const channel = chat.realtime.conversation(conversationId);
226
+ const off = channel.onMemberRead((evt) => {
227
+ setMembers(
228
+ (prev) => prev.map(
229
+ (m) => m.user_id === evt.user_id ? {
230
+ ...m,
231
+ last_read_message_id: evt.last_read_message_id,
232
+ last_read_at: evt.last_read_at
233
+ } : m
234
+ )
235
+ );
236
+ });
237
+ return () => off();
238
+ }, [chat, conversationId]);
201
239
  const refetch = useCallback(() => fetcher(), [fetcher]);
202
240
  const addMembers = useCallback(
203
241
  async (externalIds, opts = {}) => {
@@ -306,7 +344,7 @@ function useMessages(conversationId) {
306
344
  // the realtime echo replaced it with the canonical row.
307
345
  sender_id: meIdRef.current,
308
346
  type: attrs.type ?? "text",
309
- body: attrs.body,
347
+ body: attrs.body ?? null,
310
348
  reply_to_id: attrs.reply_to_id ?? null,
311
349
  thread_root_id: null,
312
350
  mentions: attrs.mentions ?? [],
@@ -331,7 +369,69 @@ function useMessages(conversationId) {
331
369
  },
332
370
  [chat, conversationId]
333
371
  );
334
- return { messages, loading, error, hasMore, loadMore, send };
372
+ const edit = useCallback(
373
+ async (messageId, body) => {
374
+ let previous;
375
+ setMessages(
376
+ (prev) => prev.map((m) => {
377
+ if (m.id !== messageId) return m;
378
+ previous = m;
379
+ return { ...m, body, edited_at: (/* @__PURE__ */ new Date()).toISOString() };
380
+ })
381
+ );
382
+ try {
383
+ const updated = await chat.messages.one(messageId).update({ body });
384
+ setMessages((prev) => prev.map((m) => m.id === messageId ? updated : m));
385
+ return updated;
386
+ } catch (err) {
387
+ if (previous) {
388
+ const restore = previous;
389
+ setMessages((prev) => prev.map((m) => m.id === messageId ? restore : m));
390
+ }
391
+ throw err;
392
+ }
393
+ },
394
+ [chat]
395
+ );
396
+ const deleteMessage = useCallback(
397
+ async (messageId) => {
398
+ let previous;
399
+ setMessages(
400
+ (prev) => prev.map((m) => {
401
+ if (m.id !== messageId) return m;
402
+ previous = m;
403
+ return { ...m, body: null, deleted_at: (/* @__PURE__ */ new Date()).toISOString() };
404
+ })
405
+ );
406
+ try {
407
+ await chat.messages.one(messageId).delete();
408
+ } catch (err) {
409
+ if (previous) {
410
+ const restore = previous;
411
+ setMessages((prev) => prev.map((m) => m.id === messageId ? restore : m));
412
+ }
413
+ throw err;
414
+ }
415
+ },
416
+ [chat]
417
+ );
418
+ const markReadUpTo = useCallback(
419
+ async (messageId) => {
420
+ await chat.conversations.one(conversationId).messages.markRead(messageId);
421
+ },
422
+ [chat, conversationId]
423
+ );
424
+ return {
425
+ messages,
426
+ loading,
427
+ error,
428
+ hasMore,
429
+ loadMore,
430
+ send,
431
+ edit,
432
+ delete: deleteMessage,
433
+ markReadUpTo
434
+ };
335
435
  }
336
436
  function upsertById(prev, msg) {
337
437
  const idx = prev.findIndex((m) => m.id === msg.id);
@@ -475,7 +575,7 @@ function useThread(conversationId, rootMessageId) {
475
575
  conversation_id: conversationId,
476
576
  sender_id: null,
477
577
  type: attrs.type ?? "text",
478
- body: attrs.body,
578
+ body: attrs.body ?? null,
479
579
  reply_to_id: rootMessageId,
480
580
  thread_root_id: rootMessageId,
481
581
  mentions: attrs.mentions ?? [],
@@ -502,7 +602,62 @@ function useThread(conversationId, rootMessageId) {
502
602
  },
503
603
  [chat, conversationId, rootMessageId]
504
604
  );
505
- return { replies, loading, error, hasMore, loadMore, sendReply };
605
+ const edit = useCallback(
606
+ async (messageId, body) => {
607
+ let previous;
608
+ setReplies(
609
+ (prev) => prev.map((m) => {
610
+ if (m.id !== messageId) return m;
611
+ previous = m;
612
+ return { ...m, body, edited_at: (/* @__PURE__ */ new Date()).toISOString() };
613
+ })
614
+ );
615
+ try {
616
+ const updated = await chat.messages.one(messageId).update({ body });
617
+ setReplies((prev) => prev.map((m) => m.id === messageId ? updated : m));
618
+ return updated;
619
+ } catch (err) {
620
+ if (previous) {
621
+ const restore = previous;
622
+ setReplies((prev) => prev.map((m) => m.id === messageId ? restore : m));
623
+ }
624
+ throw err;
625
+ }
626
+ },
627
+ [chat]
628
+ );
629
+ const deleteReply = useCallback(
630
+ async (messageId) => {
631
+ let previous;
632
+ setReplies(
633
+ (prev) => prev.map((m) => {
634
+ if (m.id !== messageId) return m;
635
+ previous = m;
636
+ return { ...m, body: null, deleted_at: (/* @__PURE__ */ new Date()).toISOString() };
637
+ })
638
+ );
639
+ try {
640
+ await chat.messages.one(messageId).delete();
641
+ } catch (err) {
642
+ if (previous) {
643
+ const restore = previous;
644
+ setReplies((prev) => prev.map((m) => m.id === messageId ? restore : m));
645
+ }
646
+ throw err;
647
+ }
648
+ },
649
+ [chat]
650
+ );
651
+ return {
652
+ replies,
653
+ loading,
654
+ error,
655
+ hasMore,
656
+ loadMore,
657
+ sendReply,
658
+ edit,
659
+ delete: deleteReply
660
+ };
506
661
  }
507
662
  function upsertById2(prev, msg) {
508
663
  const idx = prev.findIndex((m) => m.id === msg.id);