@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 +21 -0
- package/README.md +220 -0
- package/dist/chunks/chat-attachment-types-BG8VFQj5.d.ts +56 -0
- package/dist/chunks/chat-attachments-uP-PsyQf.js +21 -0
- package/dist/chunks/use-dropby-BW4iKpiA.js +54 -0
- package/dist/chunks/use-feature-gByQaSp1.js +78 -0
- package/dist/components/chat-attachments/index.d.ts +9 -0
- package/dist/components/chat-attachments/index.js +88 -0
- package/dist/components/index.d.ts +290 -0
- package/dist/components/index.js +1095 -0
- package/dist/index.d.ts +124 -0
- package/dist/index.js +47 -0
- package/package.json +59 -0
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 };
|