@poolse/react 1.0.1-beta.0 → 1.0.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/dist/index.d.ts CHANGED
@@ -325,38 +325,76 @@ declare function usePresence(conversationId: string): UsePresenceState;
325
325
  */
326
326
  declare function useRealtimeStatus(): RealtimeStatus;
327
327
 
328
+ type UploadItemStatus = 'pending' | 'uploading' | 'ready' | 'error' | 'cancelled';
329
+ interface UploadItem {
330
+ /** Local-only id (random per-enqueue). Stable for keys + `cancel(localId)` / `remove(localId)`. */
331
+ localId: string;
332
+ filename: string;
333
+ contentType: string;
334
+ byteSize: number;
335
+ status: UploadItemStatus;
336
+ /** Bytes uploaded so far; 0 until the PUT actually begins. */
337
+ loaded: number;
338
+ /** Resolved attachment row once `status === 'ready'`. Null otherwise. */
339
+ attachment: Attachment | null;
340
+ /** Set when `status === 'error'`. */
341
+ error: Error | null;
342
+ }
328
343
  interface UseAttachmentUploadState {
329
- /** Last successfully uploaded attachment, or null. */
344
+ /** Every enqueued item, in enqueue order, until removed by `remove()` / `reset()`. */
345
+ queue: UploadItem[];
346
+ /**
347
+ * Last attachment that reached `ready`, for the common one-file
348
+ * "did the upload finish" check. Null after `reset()` / `remove()`
349
+ * of the most recent ready item. Multi-file callers should iterate
350
+ * `queue` instead.
351
+ */
330
352
  attachment: Attachment | null;
353
+ /** True while at least one item is `pending` or `uploading`. */
331
354
  uploading: boolean;
355
+ /** Most recent item-level error, or null. */
332
356
  error: Error | null;
333
357
  /**
334
- * Run the upload pipeline (presigned-URL request + PUT). Updates
335
- * local state with the resolved attachment on success; sets `error`
336
- * and rethrows on failure so callers can surface a toast.
358
+ * Enqueue a single upload. Resolves with the final attachment row.
359
+ * Rejects with the underlying error (item's `status` becomes
360
+ * `'error'`); aborts surface as a DOMException('AbortError') and the
361
+ * item's status becomes `'cancelled'`.
337
362
  */
338
363
  upload: (input: AttachmentUploadInput) => Promise<Attachment>;
339
- /** Reset state (clears `attachment`, `error`). */
364
+ /**
365
+ * Enqueue many at once — drag-and-drop, multi-file picker. Items run
366
+ * in parallel (the SDK is per-call independent). Resolves with the
367
+ * attachments in the same order as the input; if any item fails the
368
+ * whole promise rejects after the others settle.
369
+ */
370
+ uploadAll: (inputs: AttachmentUploadInput[]) => Promise<Attachment[]>;
371
+ /** Abort an in-flight upload. Sets the item's status to `cancelled`. No-op if not in flight. */
372
+ cancel: (localId: string) => void;
373
+ /** Drop an item from the queue. Cancels first if it's still in flight. */
374
+ remove: (localId: string) => void;
375
+ /** Drop everything + abort any in-flight uploads. */
340
376
  reset: () => void;
341
377
  }
342
378
  /**
343
- * Stateful wrapper around `chat.attachments.upload`. Provides
344
- * `uploading` + `error` flags for the common "show a spinner while
345
- * the PUT is in flight" UI, plus retention of the last attachment
346
- * so the caller can reference its id when sending a message.
379
+ * Stateful wrapper around `chat.attachments.upload`. Exposes a queue
380
+ * of in-flight / completed uploads with per-item progress + per-item
381
+ * cancel — drag-and-drop, multi-file picker, retry chips, all without
382
+ * the host app reimplementing AbortController plumbing.
347
383
  *
348
384
  * ```tsx
349
- * const { upload, uploading, attachment } = useAttachmentUpload();
350
- * const onPick = async (file: File) => {
351
- * const att = await upload({
352
- * body: file, contentType: file.type, byteSize: file.size, filename: file.name
353
- * });
385
+ * const { upload, queue, cancel, remove } = useAttachmentUpload();
386
+ * const onPick = async (files: File[]) => {
387
+ * const atts = await uploadAll(files.map(f => ({
388
+ * body: f, contentType: f.type, byteSize: f.size, filename: f.name
389
+ * })));
354
390
  * await chat.conversations.one(convId).messages.send({
355
- * body: 'attached',
356
- * custom_data: { attachment_id: att.id },
391
+ * body: '', attachment_ids: atts.map(a => a.id),
357
392
  * });
358
393
  * };
359
394
  * ```
395
+ *
396
+ * The legacy `upload(input)` → `Promise<Attachment>` shape is preserved
397
+ * so older callers keep working — they just won't render the queue UI.
360
398
  */
361
399
  declare function useAttachmentUpload(): UseAttachmentUploadState;
362
400
 
@@ -402,4 +440,4 @@ interface UseUserState {
402
440
  */
403
441
  declare function useUser(userId: Uuid | null | undefined): UseUserState;
404
442
 
405
- export { PoolseProvider, type PoolseProviderProps, type ReactionMap, type UseUserState, useAttachmentUpload, useAttachmentUrl, useConversation, useConversations, useMe, useMembers, useMessages, usePoolse, usePresence, useReactions, useRealtimeStatus, useThread, useTyping, useUser };
443
+ export { PoolseProvider, type PoolseProviderProps, type ReactionMap, type UploadItem, type UploadItemStatus, type UseUserState, useAttachmentUpload, useAttachmentUrl, useConversation, useConversations, useMe, useMembers, useMessages, usePoolse, usePresence, useReactions, useRealtimeStatus, useThread, useTyping, useUser };
package/dist/index.js CHANGED
@@ -31,7 +31,11 @@ function PoolseProvider({ config, children }) {
31
31
  ...initial.generateIdempotencyKey ? { generateIdempotencyKey: () => configRef.current.generateIdempotencyKey() } : {},
32
32
  ...initial.wsUrl !== void 0 ? { wsUrl: initial.wsUrl } : {},
33
33
  ...initial.socketPath !== void 0 ? { socketPath: initial.socketPath } : {},
34
- ...initial.onSocketError ? { onSocketError: (err) => configRef.current.onSocketError(err) } : {}
34
+ ...initial.onSocketError ? { onSocketError: (err) => configRef.current.onSocketError(err) } : {},
35
+ // Customer's user metadata resolver — name + avatar lookup.
36
+ // Read through the ref so the customer can swap the closure
37
+ // (e.g. switch tenant context) without re-mounting.
38
+ ...initial.userResolver ? { userResolver: (id) => configRef.current.userResolver(id) } : {}
35
39
  });
36
40
  }, []);
37
41
  if (process.env.NODE_ENV !== "production") {
@@ -223,7 +227,6 @@ function useMembers(conversationId) {
223
227
  hasFetchedRef.current = true;
224
228
  } catch (err) {
225
229
  if (err?.name === "AbortError") return;
226
- console.error("[poolse] useMembers fetch failed:", err);
227
230
  setError(err instanceof Error ? err : new Error(String(err)));
228
231
  } finally {
229
232
  setLoading(false);
@@ -749,6 +752,7 @@ function usePresence(conversationId) {
749
752
  const [online, setOnline] = useState(/* @__PURE__ */ new Set());
750
753
  useEffect(() => {
751
754
  setOnline(/* @__PURE__ */ new Set());
755
+ if (!conversationId) return;
752
756
  const conv = chat.realtime.conversation(conversationId);
753
757
  const applyState = (state) => {
754
758
  setOnline(new Set(Object.keys(state)));
@@ -781,45 +785,107 @@ function useRealtimeStatus() {
781
785
  }
782
786
  function useAttachmentUpload() {
783
787
  const chat = usePoolse();
784
- const [attachment, setAttachment] = useState(null);
785
- const [uploading, setUploading] = useState(false);
786
- const [error, setError] = useState(null);
787
- const inFlightRef = useRef(null);
788
- const upload = useCallback(
789
- async (input) => {
790
- inFlightRef.current?.abort();
788
+ const [queue, setQueue] = useState([]);
789
+ const inFlightRef = useRef(/* @__PURE__ */ new Map());
790
+ const mountedRef = useRef(true);
791
+ useEffect(() => {
792
+ mountedRef.current = true;
793
+ return () => {
794
+ mountedRef.current = false;
795
+ for (const v of inFlightRef.current.values()) v.controller.abort();
796
+ inFlightRef.current.clear();
797
+ };
798
+ }, []);
799
+ const updateItem = useCallback((localId, patch) => {
800
+ if (!mountedRef.current) return;
801
+ setQueue((q) => q.map((it) => it.localId === localId ? { ...it, ...patch } : it));
802
+ }, []);
803
+ const runUpload = useCallback(
804
+ async (localId, input) => {
791
805
  const controller = new AbortController();
792
- inFlightRef.current = controller;
793
- setUploading(true);
794
- setError(null);
806
+ inFlightRef.current.set(localId, { controller });
807
+ updateItem(localId, { status: "uploading", loaded: 0 });
795
808
  try {
796
- const att = await chat.attachments.upload(input, { signal: controller.signal });
797
- if (inFlightRef.current === controller) {
798
- setAttachment(att);
799
- }
809
+ const att = await chat.attachments.upload(input, {
810
+ signal: controller.signal,
811
+ onProgress: (e) => updateItem(localId, { loaded: e.loaded })
812
+ });
813
+ updateItem(localId, {
814
+ status: "ready",
815
+ loaded: input.byteSize,
816
+ attachment: att,
817
+ error: null
818
+ });
800
819
  return att;
801
820
  } catch (err) {
802
- if (err?.name === "AbortError") throw err;
821
+ if (err?.name === "AbortError") {
822
+ updateItem(localId, { status: "cancelled" });
823
+ throw err;
824
+ }
803
825
  const e = err instanceof Error ? err : new Error(String(err));
804
- setError(e);
826
+ updateItem(localId, { status: "error", error: e });
805
827
  throw e;
806
828
  } finally {
807
- if (inFlightRef.current === controller) {
808
- setUploading(false);
809
- inFlightRef.current = null;
810
- }
829
+ inFlightRef.current.delete(localId);
811
830
  }
812
831
  },
813
- [chat]
832
+ [chat, updateItem]
833
+ );
834
+ const enqueue = useCallback((input) => {
835
+ const localId = generateLocalId();
836
+ const item = {
837
+ localId,
838
+ filename: input.filename ?? "file",
839
+ contentType: input.contentType,
840
+ byteSize: input.byteSize,
841
+ status: "pending",
842
+ loaded: 0,
843
+ attachment: null,
844
+ error: null
845
+ };
846
+ setQueue((q) => [...q, item]);
847
+ return item;
848
+ }, []);
849
+ const upload = useCallback(
850
+ (input) => {
851
+ const item = enqueue(input);
852
+ return runUpload(item.localId, input);
853
+ },
854
+ [enqueue, runUpload]
855
+ );
856
+ const uploadAll = useCallback(
857
+ (inputs) => {
858
+ const items = inputs.map((inp) => ({ item: enqueue(inp), input: inp }));
859
+ return Promise.all(items.map(({ item, input }) => runUpload(item.localId, input)));
860
+ },
861
+ [enqueue, runUpload]
862
+ );
863
+ const cancel = useCallback((localId) => {
864
+ const inf = inFlightRef.current.get(localId);
865
+ if (inf) inf.controller.abort();
866
+ }, []);
867
+ const remove = useCallback(
868
+ (localId) => {
869
+ const inf = inFlightRef.current.get(localId);
870
+ if (inf) inf.controller.abort();
871
+ setQueue((q) => q.filter((it) => it.localId !== localId));
872
+ },
873
+ []
814
874
  );
815
875
  const reset = useCallback(() => {
816
- inFlightRef.current?.abort();
817
- inFlightRef.current = null;
818
- setAttachment(null);
819
- setError(null);
820
- setUploading(false);
876
+ for (const v of inFlightRef.current.values()) v.controller.abort();
877
+ inFlightRef.current.clear();
878
+ setQueue([]);
821
879
  }, []);
822
- return { attachment, uploading, error, upload, reset };
880
+ const uploading = queue.some((it) => it.status === "pending" || it.status === "uploading");
881
+ const error = queue.reduce((acc, it) => it.error ?? acc, null);
882
+ const attachment = queue.reduce((acc, it) => it.attachment ?? acc, null);
883
+ return { queue, attachment, uploading, error, upload, uploadAll, cancel, remove, reset };
884
+ }
885
+ function generateLocalId() {
886
+ const c = globalThis.crypto;
887
+ if (c && typeof c.randomUUID === "function") return c.randomUUID();
888
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
823
889
  }
824
890
  function useAttachmentUrl(attachmentId) {
825
891
  const chat = usePoolse();
@@ -872,12 +938,12 @@ function useUser(userId) {
872
938
  const cached = chat.users.peek(userId);
873
939
  if (cached === void 0) {
874
940
  setState({ profile: null, loading: true });
941
+ void chat.users.get(userId);
875
942
  } else {
876
943
  setState({ profile: cached, loading: false });
877
944
  }
878
945
  };
879
946
  const off = chat.users.subscribe(userId, apply);
880
- void chat.users.get(userId);
881
947
  apply();
882
948
  return off;
883
949
  }, [chat, userId]);