@dropby/react 0.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Simpllyf Software
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,220 @@
1
+ # `@dropby/react`
2
+
3
+ DropBy for React: a provider, hooks for support chat, ideas, pending asks and
4
+ feature flags, and optional unstyled components. It supports React 18 and 19;
5
+ `react-dom` is needed only when your app renders through it.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pnpm add @dropby/react @dropby/browser
11
+ ```
12
+
13
+ `@dropby/browser` holds the options and data types used below, and its README
14
+ explains sessions, context and flags in full. Keep the two packages on the same
15
+ version; every release publishes them together.
16
+
17
+ ## Provider and hooks
18
+
19
+ With `options`, `DropByProvider` creates the DropBy instance, starts it once
20
+ mounted, and destroys it when unmounted. Your `auth` callback calls your own
21
+ backend, which knows who is signed in and creates the session; see
22
+ [`@dropby/server`](https://www.npmjs.com/package/@dropby/server).
23
+
24
+ ```tsx
25
+ import type { DropByOptions } from "@dropby/browser";
26
+ import { DropByProvider, useChatThreads } from "@dropby/react";
27
+ import { useEffect } from "react";
28
+
29
+ const options: DropByOptions = {
30
+ publicKey: "YOUR_PUBLIC_KEY",
31
+ auth: async ({ refresh }) => {
32
+ const response = await fetch("/api/dropby/session", {
33
+ method: "POST",
34
+ headers: { "content-type": "application/json" },
35
+ body: JSON.stringify({ refresh }),
36
+ });
37
+ if (!response.ok) throw new Error("could not create a DropBy session");
38
+ return response.json();
39
+ },
40
+ };
41
+
42
+ export function Inbox() {
43
+ const { data, actions } = useChatThreads();
44
+
45
+ useEffect(() => {
46
+ void actions.listThreads({ filter: "unread" });
47
+ }, [actions]);
48
+
49
+ return <p>{data.items.length} unread threads</p>;
50
+ }
51
+
52
+ export function App() {
53
+ return (
54
+ <DropByProvider options={options}>
55
+ <Inbox />
56
+ </DropByProvider>
57
+ );
58
+ }
59
+ ```
60
+
61
+ - `useChatThreads`, `useChatThread(id)`, `useChatUnread`, `useIdeas` and
62
+ `usePendingAsks` return `data` and `actions`. `actions` keeps its identity
63
+ across renders. All but `useChatUnread` take a selector, such as
64
+ `useChatThreads((state) => state.unread)`, to re-render only when that part
65
+ changes.
66
+ - `useDropBy()` returns the instance. `useDropByState()` returns its live
67
+ `bootstrap`, `capabilities` and `connection` states.
68
+ - An instance passed as `<DropByProvider dropby={…}>` stays yours to start and
69
+ destroy.
70
+ - The provider reads `options` once. When a user logs out, or another user or
71
+ account signs in, mount a new provider (give it a `key` of the user's and
72
+ account's ids) so the old instance is destroyed.
73
+ - Use the full `pk_live_...` or `pk_test_...` value from the dashboard's API
74
+ keys page for `YOUR_PUBLIC_KEY`.
75
+
76
+ ### Next.js App Router
77
+
78
+ The package's modules are client modules (`"use client"`), so server components
79
+ can import them. `DropByProvider` takes an `auth` function, which cannot cross
80
+ from a server component, so create it in a client component of your own:
81
+
82
+ <!-- snippet file=dropby-provider.tsx -->
83
+
84
+ ```tsx
85
+ "use client";
86
+ import type { ReactNode } from "react";
87
+ import type { DropByOptions } from "@dropby/browser";
88
+ import { DropByProvider } from "@dropby/react";
89
+
90
+ const options: DropByOptions = {
91
+ publicKey: "YOUR_PUBLIC_KEY",
92
+ auth: async ({ refresh }) => {
93
+ const response = await fetch("/api/dropby/session", {
94
+ method: "POST",
95
+ headers: { "content-type": "application/json" },
96
+ body: JSON.stringify({ refresh }),
97
+ });
98
+ if (!response.ok) throw new Error("could not create a DropBy session");
99
+ return response.json();
100
+ },
101
+ };
102
+
103
+ export function DropByClientProvider({
104
+ identity,
105
+ children,
106
+ }: {
107
+ identity: { userId: string; accountId: string | null };
108
+ children: ReactNode;
109
+ }) {
110
+ // Another user or account gets a new instance.
111
+ return (
112
+ <DropByProvider key={`${identity.userId}:${identity.accountId ?? ""}`} options={options}>
113
+ {children}
114
+ </DropByProvider>
115
+ );
116
+ }
117
+ ```
118
+
119
+ Then render it where signed-in pages begin, for example in `app/layout.tsx`. On
120
+ the server the hooks return their initial idle state; the instance starts in
121
+ the browser.
122
+
123
+ <!-- snippet: declare function signedIn(): Promise<{ userId: string; accountId: string | null } | null>; -->
124
+
125
+ ```tsx
126
+ import type { ReactNode } from "react";
127
+ import { DropByClientProvider } from "./dropby-provider";
128
+
129
+ export default async function RootLayout({ children }: { children: ReactNode }) {
130
+ const identity = await signedIn(); // from your own auth
131
+ return (
132
+ <html lang="en">
133
+ <body>
134
+ {identity ? (
135
+ <DropByClientProvider identity={identity}>{children}</DropByClientProvider>
136
+ ) : (
137
+ children
138
+ )}
139
+ </body>
140
+ </html>
141
+ );
142
+ }
143
+ ```
144
+
145
+ ## Feature flags
146
+
147
+ `useFeatureFlag(name)` returns one flag's value, and re-renders only when that
148
+ value changes. `useFeatureConfig()` returns them all. `<Feature>` renders its
149
+ children when a flag is on (truthy, or equal to `equals`), and `fallback`
150
+ otherwise. Before the instance starts, and for a flag that does not exist, the
151
+ value is `undefined`. How flags are resolved, and when they change, is in the
152
+ [`@dropby/browser` README](https://www.npmjs.com/package/@dropby/browser#context-and-feature-flags).
153
+
154
+ ```tsx
155
+ import { useFeatureFlag } from "@dropby/react";
156
+ import { Feature } from "@dropby/react/components";
157
+
158
+ export function ExportButton() {
159
+ const format = useFeatureFlag("export_format");
160
+ return <button type="button">export as {format === "csv" ? "csv" : "pdf"}</button>;
161
+ }
162
+
163
+ export function Toolbar() {
164
+ return (
165
+ <Feature name="new_exports" fallback={<span>exports are coming soon</span>}>
166
+ <ExportButton />
167
+ </Feature>
168
+ );
169
+ }
170
+ ```
171
+
172
+ ## Components and styling
173
+
174
+ Optional components live in `@dropby/react/components`: `ChatThread`,
175
+ `ChatThreadList`, `ChatComposer`, `IdeaList`, `Idea`, `IdeaVoteButton`, `Ask`,
176
+ `AskForm`, `PendingAsks` and `Feature`. They render plain, accessible HTML with
177
+ state attributes such as `data-status`, `data-unread` and `data-busy`, so your
178
+ own CSS styles them; they load no theme. Each, except `Feature`, also takes a
179
+ render function as `children` when you want your own markup with its state.
180
+
181
+ `ChatThread` and `Idea` share their names with data types in
182
+ `@dropby/browser`; import one under another name
183
+ (`import type { Idea as IdeaData }`) when you need both.
184
+
185
+ The chat components behave like the hosted widget:
186
+
187
+ - `ChatThread` marks a reply seen once its end is on screen (the page visible,
188
+ the viewer allowed). Pass `acknowledge={false}` to do that yourself with
189
+ `actions.markSeen`. A render-function body is never observed.
190
+ - `ChatThread` follows new messages inside its own scroll container: to the
191
+ newest when it opens, when the reader sends, and when a message arrives while
192
+ they are at the end. It scrolls only its own scroll container (itself or the
193
+ nearest ancestor with `overflow-y: auto`), never the document.
194
+ - `ChatThreadList` rows become buttons with `onSelect={(threadId) => ...}`;
195
+ `selectedId` marks the open one with `aria-current`.
196
+ - Failed loads, sends and downloads show as `role="alert"` messages in plain
197
+ words. A render function's state carries the `DropByError` behind a failed
198
+ load or send.
199
+ - `ChatComposer` keeps a failed message in the box. Sending it again unchanged
200
+ cannot post it twice.
201
+
202
+ ## Optional attachments
203
+
204
+ Import `useChatAttachments` only when the composer needs uploads, so other
205
+ pages never load the upload code:
206
+
207
+ ```tsx
208
+ import { ChatComposer } from "@dropby/react/components";
209
+ import { useChatAttachments } from "@dropby/react/components/chat-attachments";
210
+
211
+ function Composer({ threadId }: { threadId: `thr_${string}` }) {
212
+ const attachments = useChatAttachments();
213
+ return <ChatComposer threadId={threadId} attachments={attachments} />;
214
+ }
215
+ ```
216
+
217
+ The hook returns `drafts`, `add`, `remove`, `clear`, `references`, `pending`,
218
+ `notice` and `accept`. `ChatComposer` waits for uploads in progress and sends
219
+ only the ready ones. Unmounting the component releases the hook's previews and
220
+ subscriptions; destroying the instance cancels its uploads.
@@ -0,0 +1,56 @@
1
+ import { ChatAttachmentContentType, ChatAttachmentKind, DropById } from "@dropby/browser";
2
+ /** A file picked for the next message: its upload's state, and a preview for images. */
3
+ interface ChatAttachmentDraft {
4
+ /** This draft's own id, stable from the moment it is picked; pass it to `remove`. */
5
+ localId: string;
6
+ /** The file's name, trimmed, or `attachment` when it has none. */
7
+ fileName: string;
8
+ /** The type the file was accepted as; null until it is checked. */
9
+ contentType: ChatAttachmentContentType | null;
10
+ /** The file's size in bytes. */
11
+ byteSize: number;
12
+ /** Whether it shows as an image or a file; null until it is checked. */
13
+ kind: ChatAttachmentKind | null;
14
+ /** A local URL to preview an image; null for other files. */
15
+ previewUrl: string | null;
16
+ /** Where the upload is: checking the file, uploading, ready to send, or failed. */
17
+ status: "checking" | "uploading" | "ready" | "error";
18
+ /** The attachment's id once it is ready to send; null before. */
19
+ attachmentId: DropById<"att_"> | null;
20
+ /** Why the upload failed, such as `too_large`; null unless `status` is `error`. */
21
+ error: {
22
+ code: string;
23
+ message: string;
24
+ } | null;
25
+ }
26
+ /**
27
+ * Attachments for a composer, from `useChatAttachments`: the files picked, and
28
+ * how to add, remove and send them. Pass it to `ChatComposer` as `attachments`.
29
+ */
30
+ interface ChatAttachmentsToolkit {
31
+ /** The files picked, in order. */
32
+ drafts: ChatAttachmentDraft[];
33
+ /**
34
+ * Starts uploading files, up to four per message; files past that are left out
35
+ * and `notice` says so.
36
+ */
37
+ add: (files: FileList | File[]) => void;
38
+ /** Drops one file from the message. */
39
+ remove: (localId: string) => void;
40
+ /** Drops every file. */
41
+ clear: () => void;
42
+ /** The ready uploads, to send with a message. */
43
+ references: () => Array<{
44
+ attachmentId: DropById<"att_">;
45
+ }>;
46
+ /** Whether a file is still being checked or uploaded. */
47
+ pending: boolean;
48
+ /**
49
+ * Why files were left out of the last `add`; null otherwise. Cleared by the next
50
+ * `add` that takes every file, or by any `remove` or `clear`.
51
+ */
52
+ notice: string | null;
53
+ /** The content types a file input should accept. */
54
+ accept: string;
55
+ }
56
+ export { ChatAttachmentsToolkit as n, ChatAttachmentDraft as t };
@@ -0,0 +1,21 @@
1
+ "use client";
2
+ function uuid() {
3
+ const bytes = globalThis.crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
4
+ const time = Date.now();
5
+ for (let index = 0; index < 6; index++) bytes[index] = Math.floor(time / 2 ** (8 * (5 - index))) % 256;
6
+ bytes[6] = bytes[6] & 15 | 112;
7
+ bytes[8] = bytes[8] & 63 | 128;
8
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
9
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
10
+ }
11
+ const chatAttachmentContentTypes = [
12
+ "image/png",
13
+ "image/jpeg",
14
+ "image/webp",
15
+ "image/gif",
16
+ "application/pdf",
17
+ "text/plain",
18
+ "text/csv"
19
+ ];
20
+ const chatAttachmentMaxByteSize = 10485760;
21
+ export { chatAttachmentMaxByteSize as n, uuid as r, chatAttachmentContentTypes as t };
@@ -0,0 +1,54 @@
1
+ "use client";
2
+ import { createContext, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
3
+ import { initialSnapshots } from "@dropby/browser";
4
+ const DropByContext = createContext(null);
5
+ function useStoreWithSelector(store, serverSnapshot, selector, isEqual) {
6
+ const subscribe = useMemo(() => (listener) => store.subscribe(listener), [store]);
7
+ const committed = useRef(null);
8
+ const [getSelection, getServerSelection] = useMemo(() => {
9
+ let last = null;
10
+ const select = (snapshot) => {
11
+ if (last && Object.is(last.snapshot, snapshot)) return last.selection;
12
+ const next = selector(snapshot);
13
+ const previous = last ? { value: last.selection } : committed.current;
14
+ const selection = previous && isEqual?.(previous.value, next) ? previous.value : next;
15
+ last = {
16
+ snapshot,
17
+ selection
18
+ };
19
+ return selection;
20
+ };
21
+ return [() => select(store.getSnapshot()), () => select(serverSnapshot)];
22
+ }, [
23
+ store,
24
+ serverSnapshot,
25
+ selector,
26
+ isEqual
27
+ ]);
28
+ const value = useSyncExternalStore(subscribe, getSelection, getServerSelection);
29
+ useEffect(() => {
30
+ committed.current = { value };
31
+ }, [value]);
32
+ return value;
33
+ }
34
+ const idleBootstrap = initialSnapshots.bootstrap;
35
+ const idleCapabilities = initialSnapshots.capabilities;
36
+ const idleConnection = initialSnapshots.connection;
37
+ const idleChat = initialSnapshots.chat;
38
+ const idleIdeas = initialSnapshots.ideas;
39
+ const idleAsks = initialSnapshots.asks;
40
+ const identity = (value) => value;
41
+ function useDropBy() {
42
+ const core = useContext(DropByContext);
43
+ if (core === null) throw new Error("useDropBy and DropBy hooks must be used within a <DropByProvider>.");
44
+ return core;
45
+ }
46
+ function useDropByState() {
47
+ const core = useDropBy();
48
+ return {
49
+ connection: useStoreWithSelector(core.connection, idleConnection, identity),
50
+ capabilities: useStoreWithSelector(core.capabilities, idleCapabilities, identity),
51
+ bootstrap: useStoreWithSelector(core.bootstrap, idleBootstrap, identity)
52
+ };
53
+ }
54
+ export { idleChat as a, DropByContext as c, idleBootstrap as i, useDropByState as n, idleIdeas as o, idleAsks as r, useStoreWithSelector as s, useDropBy as t };
@@ -0,0 +1,78 @@
1
+ "use client";
2
+ import { a as idleChat, i as idleBootstrap, o as idleIdeas, r as idleAsks, s as useStoreWithSelector, t as useDropBy } from "./use-dropby-BW4iKpiA.js";
3
+ import { useEffect, useMemo } from "react";
4
+ const selectThreads = (state) => state.threads;
5
+ function useChatThreads(selector, isEqual) {
6
+ const core = useDropBy();
7
+ const select = selector ?? selectThreads;
8
+ const equal = selector ? isEqual : void 0;
9
+ return {
10
+ data: useStoreWithSelector(core.chat, idleChat, select, equal),
11
+ actions: core.chat
12
+ };
13
+ }
14
+ function useChatThread(id, selector, isEqual) {
15
+ const chat = useDropBy().chat;
16
+ useEffect(() => chat.observeThread(id), [chat, id]);
17
+ const select = useMemo(() => selector ?? ((state) => state.threadsById[id]), [selector, id]);
18
+ const data = useStoreWithSelector(chat, idleChat, select, selector ? isEqual : void 0);
19
+ const selectOlder = useMemo(() => (state) => state.olderMessagesById[id], [id]);
20
+ const olderMessages = useStoreWithSelector(chat, idleChat, selectOlder);
21
+ const selectLatest = useMemo(() => (state) => state.latestMessagesById[id], [id]);
22
+ const latestMessages = useStoreWithSelector(chat, idleChat, selectLatest);
23
+ return {
24
+ data,
25
+ actions: useMemo(() => ({
26
+ open: () => chat.openThread(id),
27
+ markSeen: (request) => chat.markThreadSeen(id, request),
28
+ loadOlderMessages: () => chat.loadOlderMessages(id),
29
+ loadLatestMessages: () => chat.loadLatestMessages(id),
30
+ sendMessage: (input, options) => chat.sendMessage(id, input, options),
31
+ getAttachmentDownload: (attachmentId) => chat.getAttachmentDownload(attachmentId)
32
+ }), [chat, id]),
33
+ olderMessages,
34
+ latestMessages
35
+ };
36
+ }
37
+ const selectUnread = (state) => state.unread;
38
+ function useChatUnread() {
39
+ const core = useDropBy();
40
+ return {
41
+ data: useStoreWithSelector(core.chat, idleChat, selectUnread),
42
+ actions: core.chat
43
+ };
44
+ }
45
+ const selectIdeas = (state) => state.ideas;
46
+ function useIdeas(selector, isEqual) {
47
+ const core = useDropBy();
48
+ const select = selector ?? selectIdeas;
49
+ const equal = selector ? isEqual : void 0;
50
+ return {
51
+ data: useStoreWithSelector(core.ideas, idleIdeas, select, equal),
52
+ actions: core.ideas
53
+ };
54
+ }
55
+ const selectPending = (state) => state.pending;
56
+ function usePendingAsks(selector, isEqual) {
57
+ const core = useDropBy();
58
+ const select = selector ?? selectPending;
59
+ const equal = selector ? isEqual : void 0;
60
+ return {
61
+ data: useStoreWithSelector(core.asks, idleAsks, select, equal),
62
+ actions: core.asks
63
+ };
64
+ }
65
+ const EMPTY_CONFIG = Object.freeze({});
66
+ function selectConfig(state) {
67
+ return state.data?.config ?? EMPTY_CONFIG;
68
+ }
69
+ function useFeatureConfig() {
70
+ const core = useDropBy();
71
+ return useStoreWithSelector(core.bootstrap, idleBootstrap, selectConfig);
72
+ }
73
+ function useFeatureFlag(key) {
74
+ const core = useDropBy();
75
+ const selector = useMemo(() => (state) => state.data?.config?.[key], [key]);
76
+ return useStoreWithSelector(core.bootstrap, idleBootstrap, selector);
77
+ }
78
+ export { useChatThread as a, useIdeas as i, useFeatureFlag as n, useChatThreads as o, usePendingAsks as r, useChatUnread as s, useFeatureConfig as t };
@@ -0,0 +1,9 @@
1
+ import { n as ChatAttachmentsToolkit, t as ChatAttachmentDraft } from "../../chunks/chat-attachment-types-BG8VFQj5.js";
2
+ /**
3
+ * Attachments for a `ChatComposer`: add files, see each upload and its preview,
4
+ * remove one, and hand the ready ones to the next message. Up to four files per
5
+ * message. Unmounting releases the previews; destroying the instance cancels the
6
+ * uploads.
7
+ */
8
+ export declare function useChatAttachments(): ChatAttachmentsToolkit;
9
+ export type { ChatAttachmentDraft, ChatAttachmentsToolkit };
@@ -0,0 +1,88 @@
1
+ "use client";
2
+ import { t as useDropBy } from "../../chunks/use-dropby-BW4iKpiA.js";
3
+ import { r as uuid, t as chatAttachmentContentTypes } from "../../chunks/chat-attachments-uP-PsyQf.js";
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
+ import { createChatAttachment } from "@dropby/browser/attachments";
6
+ const accept = chatAttachmentContentTypes.join(",");
7
+ function useChatAttachments() {
8
+ const core = useDropBy();
9
+ const entries = useRef(/* @__PURE__ */ new Map());
10
+ const unsubscribes = useRef(/* @__PURE__ */ new Map());
11
+ const [drafts, setDrafts] = useState([]);
12
+ const [notice, setNotice] = useState(null);
13
+ const snapshot = useCallback(() => {
14
+ const next = [];
15
+ for (const entry of entries.current.values()) {
16
+ const state = entry.upload.getSnapshot();
17
+ next.push({
18
+ localId: entry.localId,
19
+ fileName: state.fileName,
20
+ contentType: state.contentType,
21
+ byteSize: state.byteSize,
22
+ kind: state.kind,
23
+ previewUrl: entry.previewUrl,
24
+ status: state.status,
25
+ attachmentId: state.attachmentId,
26
+ error: state.error
27
+ });
28
+ }
29
+ setDrafts(next);
30
+ }, []);
31
+ const add = useCallback((files) => {
32
+ const incoming = Array.from(files);
33
+ if (incoming.length === 0) return;
34
+ const availableSlots = 4 - entries.current.size;
35
+ if (availableSlots <= 0) {
36
+ setNotice(`you can attach up to 4 files.`);
37
+ return;
38
+ }
39
+ const selected = incoming.slice(0, availableSlots);
40
+ setNotice(selected.length < incoming.length ? `only 4 attachments can be sent at once.` : null);
41
+ for (const file of selected) {
42
+ const localId = uuid();
43
+ const upload = createChatAttachment(core, file);
44
+ const previewUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : null;
45
+ entries.current.set(localId, {
46
+ localId,
47
+ upload,
48
+ previewUrl
49
+ });
50
+ unsubscribes.current.set(localId, upload.subscribe(snapshot));
51
+ upload.result.catch(() => void 0);
52
+ }
53
+ snapshot();
54
+ }, [core, snapshot]);
55
+ const remove = useCallback((localId) => {
56
+ const entry = entries.current.get(localId);
57
+ if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl);
58
+ unsubscribes.current.get(localId)?.();
59
+ unsubscribes.current.delete(localId);
60
+ entries.current.delete(localId);
61
+ setNotice(null);
62
+ snapshot();
63
+ }, [snapshot]);
64
+ const clear = useCallback(() => {
65
+ for (const localId of [...entries.current.keys()]) remove(localId);
66
+ }, [remove]);
67
+ useEffect(() => {
68
+ const subs = unsubscribes.current;
69
+ const live = entries.current;
70
+ return () => {
71
+ for (const unsubscribe of subs.values()) unsubscribe();
72
+ for (const entry of live.values()) if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
73
+ subs.clear();
74
+ live.clear();
75
+ };
76
+ }, []);
77
+ return {
78
+ drafts,
79
+ add,
80
+ remove,
81
+ clear,
82
+ references: useCallback(() => drafts.flatMap((draft) => draft.status === "ready" && draft.attachmentId ? [{ attachmentId: draft.attachmentId }] : []), [drafts]),
83
+ pending: useMemo(() => drafts.some((draft) => draft.status === "checking" || draft.status === "uploading"), [drafts]),
84
+ notice,
85
+ accept
86
+ };
87
+ }
88
+ export { useChatAttachments };