@dropby/vue 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,188 @@
1
+ # `@dropby/vue`
2
+
3
+ DropBy for Vue 3: a provider, composables for support chat, ideas, pending asks
4
+ and feature flags, and optional unstyled components. It supports Vue 3.4 and
5
+ later 3.x releases.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pnpm add @dropby/vue @dropby/browser
11
+ ```
12
+
13
+ `@dropby/browser` holds the options and data types, and its README explains
14
+ sessions, context and flags in full. Keep the two packages on the same version;
15
+ every release publishes them together.
16
+
17
+ ## Provider and composables
18
+
19
+ With `options`, `DropByProvider` creates the DropBy instance, starts it once
20
+ mounted, and destroys it with its scope. 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
+ ```vue
25
+ <script setup lang="ts">
26
+ import { onMounted, ref } from "vue";
27
+ import type { DropByOptions } from "@dropby/browser";
28
+ import { DropByProvider } from "@dropby/vue";
29
+ import Inbox from "./Inbox.vue";
30
+
31
+ const options: DropByOptions = {
32
+ publicKey: "YOUR_PUBLIC_KEY",
33
+ auth: async ({ refresh }) => {
34
+ const response = await fetch("/api/dropby/session", {
35
+ method: "POST",
36
+ headers: { "content-type": "application/json" },
37
+ body: JSON.stringify({ refresh }),
38
+ });
39
+ if (!response.ok) throw new Error("could not create a DropBy session");
40
+ return response.json();
41
+ },
42
+ };
43
+
44
+ const mounted = ref(false);
45
+ onMounted(() => {
46
+ mounted.value = true;
47
+ });
48
+ </script>
49
+
50
+ <template>
51
+ <DropByProvider v-if="mounted" :options="options">
52
+ <Inbox />
53
+ </DropByProvider>
54
+ </template>
55
+ ```
56
+
57
+ `Inbox.vue` lists the unread conversations. `useChatThread(id)` and
58
+ `useFeatureFlag(name)` also take a ref or a getter, and follow it when it
59
+ changes.
60
+
61
+ <!-- snippet file=Inbox.vue -->
62
+
63
+ ```vue
64
+ <script setup lang="ts">
65
+ import { onMounted } from "vue";
66
+ import { useChatThreads } from "@dropby/vue";
67
+
68
+ const { data, actions } = useChatThreads();
69
+ onMounted(() => void actions.listThreads({ filter: "unread" }));
70
+ </script>
71
+
72
+ <template>
73
+ <p>{{ data.items.length }} unread threads</p>
74
+ </template>
75
+ ```
76
+
77
+ - `useChatThreads`, `useChatThread(id)`, `useChatUnread`, `useIdeas` and
78
+ `usePendingAsks` return `data` as a read-only ref and a stable `actions`
79
+ object.
80
+ - `useDropBy()` returns the instance. `useDropByState()` returns its live
81
+ `bootstrap`, `capabilities` and `connection` refs.
82
+ - An instance passed as the provider's `dropby` prop stays yours to start and
83
+ destroy.
84
+ - The `v-if` above mounts DropBy only in the browser. Never share an instance
85
+ across server requests.
86
+ - The provider reads `options` once. When a user logs out, or another user or
87
+ account signs in, mount a new provider (give it a `:key` of the user's and
88
+ account's ids) so the old instance is destroyed.
89
+ - Use the full `pk_live_...` or `pk_test_...` value from the dashboard's API
90
+ keys page for `YOUR_PUBLIC_KEY`.
91
+
92
+ ## Feature flags
93
+
94
+ `useFeatureFlag(name)` returns one flag's value as a ref, and `useFeatureConfig()`
95
+ all of them. `<Feature>` renders its default slot when a flag is on (truthy, or
96
+ equal to `equals`), and its `fallback` slot otherwise. Before the instance
97
+ starts, and for a flag that does not exist, the value is `undefined`. How flags
98
+ are resolved, and when they change, is in the
99
+ [`@dropby/browser` README](https://www.npmjs.com/package/@dropby/browser#context-and-feature-flags).
100
+
101
+ ```vue
102
+ <script setup lang="ts">
103
+ import { useFeatureFlag } from "@dropby/vue";
104
+ import { Feature } from "@dropby/vue/components";
105
+
106
+ const format = useFeatureFlag("export_format");
107
+ </script>
108
+
109
+ <template>
110
+ <Feature name="new_exports">
111
+ <button type="button">export as {{ format === "csv" ? "csv" : "pdf" }}</button>
112
+ <template #fallback><span>exports are coming soon</span></template>
113
+ </Feature>
114
+ </template>
115
+ ```
116
+
117
+ ## Components and styling
118
+
119
+ Optional components live in `@dropby/vue/components`: `ChatThread`,
120
+ `ChatThreadList`, `ChatComposer`, `IdeaList`, `Idea`, `IdeaVoteButton`, `Ask`,
121
+ `AskForm`, `PendingAsks` and `Feature`. They render plain, accessible HTML with
122
+ state attributes such as `data-status`, `data-unread` and `data-busy`, so your
123
+ own CSS styles them; they load no theme. Each, except `Feature`, also takes a
124
+ default scoped slot when you want your own markup with its state, and the slot
125
+ props are typed:
126
+
127
+ ```vue
128
+ <script setup lang="ts">
129
+ import { ChatThreadList } from "@dropby/vue/components";
130
+
131
+ const emit = defineEmits<{ open: [threadId: `thr_${string}`] }>();
132
+ </script>
133
+
134
+ <template>
135
+ <ChatThreadList v-slot="{ threads }">
136
+ <button
137
+ v-for="thread in threads.items"
138
+ :key="thread.threadId"
139
+ type="button"
140
+ @click="emit('open', thread.threadId)"
141
+ >
142
+ {{ thread.lastMessagePreview }}
143
+ </button>
144
+ <p v-if="threads.status === 'error'">couldn't load your conversations.</p>
145
+ </ChatThreadList>
146
+ </template>
147
+ ```
148
+
149
+ The default chat components behave like the hosted widget:
150
+
151
+ - `ChatThread` marks a reply seen once its end is on screen (the page visible,
152
+ the viewer allowed). Pass `:acknowledge="false"` to do that yourself with
153
+ `actions.markSeen`. A slot body is never observed.
154
+ - `ChatThread` follows new messages inside its own scroll container: to the
155
+ newest when it opens, when the reader sends, and when a message arrives while
156
+ they are at the end. It scrolls only its own scroll container (itself or the
157
+ nearest ancestor with `overflow-y: auto`), never the document.
158
+ - `ChatThreadList` rows become buttons with `@select="(threadId) => ..."`;
159
+ `selected-id` marks the open one with `aria-current`.
160
+ - Failed loads, sends and downloads show as `role="alert"` messages in plain
161
+ words. A slot's state carries the `DropByError` behind a failed load or send.
162
+ - `ChatComposer` keeps a failed message in the box. Sending it again unchanged
163
+ cannot post it twice.
164
+
165
+ ## Optional attachments
166
+
167
+ Import `useChatAttachments` only when the composer needs uploads, so other
168
+ pages never load the upload code:
169
+
170
+ ```vue
171
+ <script setup lang="ts">
172
+ import { ChatComposer } from "@dropby/vue/components";
173
+ import { useChatAttachments } from "@dropby/vue/components/chat-attachments";
174
+
175
+ defineProps<{ threadId: `thr_${string}` }>();
176
+ const attachments = useChatAttachments();
177
+ </script>
178
+
179
+ <template>
180
+ <ChatComposer :thread-id="threadId" :attachments="attachments" />
181
+ </template>
182
+ ```
183
+
184
+ The composable returns `drafts`, `add`, `remove`, `clear`, `references`,
185
+ `pending`, `notice` and `accept`. `ChatComposer` waits for uploads in progress
186
+ and sends only the ready ones. Disposing the component's scope releases the
187
+ composable's previews and subscriptions; destroying the instance cancels its
188
+ 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,20 @@
1
+ function uuid() {
2
+ const bytes = globalThis.crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
3
+ const time = Date.now();
4
+ for (let index = 0; index < 6; index++) bytes[index] = Math.floor(time / 2 ** (8 * (5 - index))) % 256;
5
+ bytes[6] = bytes[6] & 15 | 112;
6
+ bytes[8] = bytes[8] & 63 | 128;
7
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
8
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
9
+ }
10
+ const chatAttachmentContentTypes = [
11
+ "image/png",
12
+ "image/jpeg",
13
+ "image/webp",
14
+ "image/gif",
15
+ "application/pdf",
16
+ "text/plain",
17
+ "text/csv"
18
+ ];
19
+ const chatAttachmentMaxByteSize = 10485760;
20
+ export { chatAttachmentMaxByteSize as n, uuid as r, chatAttachmentContentTypes as t };
@@ -0,0 +1,10 @@
1
+ import { AllowedComponentProps, ComponentCustomProps, VNodeProps } from "vue";
2
+ /**
3
+ * The type of each component in this package: the props it accepts and the slots
4
+ * it renders, which is what a template or `h()` checks.
5
+ */
6
+ type PublicComponent<Props, Slots = object> = new () => {
7
+ $props: AllowedComponentProps & ComponentCustomProps & VNodeProps & Props;
8
+ $slots: Slots;
9
+ };
10
+ export { PublicComponent as t };
@@ -0,0 +1,33 @@
1
+ import { computed, inject, onMounted, onScopeDispose, shallowRef } from "vue";
2
+ const dropByKey = Symbol("dropby-core");
3
+ function useStore(store, selector, isEqual = Object.is) {
4
+ const snapshot = shallowRef(store.getSnapshot());
5
+ let unsubscribe;
6
+ onMounted(() => {
7
+ const sync = () => {
8
+ snapshot.value = store.getSnapshot();
9
+ };
10
+ sync();
11
+ unsubscribe = store.subscribe(sync);
12
+ });
13
+ onScopeDispose(() => unsubscribe?.());
14
+ return computed((previous) => {
15
+ const next = selector(snapshot.value);
16
+ return previous !== void 0 && isEqual(previous, next) ? previous : next;
17
+ });
18
+ }
19
+ const identity = (value) => value;
20
+ function useDropBy() {
21
+ const dropby = inject(dropByKey, null);
22
+ if (dropby === null) throw new Error("DropBy composables must be used within a <DropByProvider>.");
23
+ return dropby;
24
+ }
25
+ function useDropByState() {
26
+ const core = useDropBy();
27
+ return {
28
+ connection: useStore(core.connection, identity),
29
+ capabilities: useStore(core.capabilities, identity),
30
+ bootstrap: useStore(core.bootstrap, identity)
31
+ };
32
+ }
33
+ export { dropByKey as i, useDropByState as n, useStore as r, useDropBy as t };
@@ -0,0 +1,68 @@
1
+ import { r as useStore, t as useDropBy } from "./use-drop-by-4B6MMMx1.js";
2
+ import { onMounted, onScopeDispose, toValue, watch } from "vue";
3
+ function useChatThreads() {
4
+ const core = useDropBy();
5
+ return {
6
+ data: useStore(core.chat, (state) => state.threads),
7
+ actions: core.chat
8
+ };
9
+ }
10
+ function useChatThread(id) {
11
+ const chat = useDropBy().chat;
12
+ let release;
13
+ onMounted(() => {
14
+ watch(() => toValue(id), (current) => {
15
+ release?.();
16
+ release = chat.observeThread(current);
17
+ }, { immediate: true });
18
+ });
19
+ onScopeDispose(() => release?.());
20
+ return {
21
+ data: useStore(chat, (state) => state.threadsById[toValue(id)]),
22
+ actions: {
23
+ open: () => chat.openThread(toValue(id)),
24
+ markSeen: (request) => chat.markThreadSeen(toValue(id), request),
25
+ loadOlderMessages: () => chat.loadOlderMessages(toValue(id)),
26
+ loadLatestMessages: () => chat.loadLatestMessages(toValue(id)),
27
+ sendMessage: (input, options) => chat.sendMessage(toValue(id), input, options),
28
+ getAttachmentDownload: (attachmentId) => chat.getAttachmentDownload(attachmentId)
29
+ },
30
+ olderMessages: useStore(chat, (state) => state.olderMessagesById[toValue(id)]),
31
+ latestMessages: useStore(chat, (state) => state.latestMessagesById[toValue(id)])
32
+ };
33
+ }
34
+ function useChatUnread() {
35
+ const core = useDropBy();
36
+ return {
37
+ data: useStore(core.chat, (state) => state.unread),
38
+ actions: core.chat
39
+ };
40
+ }
41
+ function useIdeas(selector, isEqual) {
42
+ const core = useDropBy();
43
+ if (selector) return {
44
+ data: useStore(core.ideas, selector, isEqual),
45
+ actions: core.ideas
46
+ };
47
+ return {
48
+ data: useStore(core.ideas, (state) => state.ideas),
49
+ actions: core.ideas
50
+ };
51
+ }
52
+ function usePendingAsks() {
53
+ const core = useDropBy();
54
+ return {
55
+ data: useStore(core.asks, (state) => state.pending),
56
+ actions: core.asks
57
+ };
58
+ }
59
+ const EMPTY_CONFIG = Object.freeze({});
60
+ function useFeatureConfig() {
61
+ const core = useDropBy();
62
+ return useStore(core.bootstrap, (state) => state.data?.config ?? EMPTY_CONFIG);
63
+ }
64
+ function useFeatureFlag(key) {
65
+ const core = useDropBy();
66
+ return useStore(core.bootstrap, (state) => state.data?.config?.[toValue(key)]);
67
+ }
68
+ 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. The toolkit is reactive; unmounting the component releases the previews,
6
+ * and destroying the instance cancels the uploads.
7
+ */
8
+ export declare function useChatAttachments(): ChatAttachmentsToolkit;
9
+ export type { ChatAttachmentDraft, ChatAttachmentsToolkit };
@@ -0,0 +1,87 @@
1
+ import { t as useDropBy } from "../../chunks/use-drop-by-4B6MMMx1.js";
2
+ import { r as uuid, t as chatAttachmentContentTypes } from "../../chunks/chat-attachments-BeJa4tEd.js";
3
+ import { computed, onScopeDispose, reactive, ref, shallowRef } from "vue";
4
+ import { createChatAttachment } from "@dropby/browser/attachments";
5
+ const accept = chatAttachmentContentTypes.join(",");
6
+ function useChatAttachments() {
7
+ const core = useDropBy();
8
+ const entries = /* @__PURE__ */ new Map();
9
+ const unsubscribes = /* @__PURE__ */ new Map();
10
+ const drafts = shallowRef([]);
11
+ const notice = ref(null);
12
+ function snapshot() {
13
+ const next = [];
14
+ for (const entry of entries.values()) {
15
+ const state = entry.upload.getSnapshot();
16
+ next.push({
17
+ localId: entry.localId,
18
+ fileName: state.fileName,
19
+ contentType: state.contentType,
20
+ byteSize: state.byteSize,
21
+ kind: state.kind,
22
+ previewUrl: entry.previewUrl,
23
+ status: state.status,
24
+ attachmentId: state.attachmentId,
25
+ error: state.error
26
+ });
27
+ }
28
+ drafts.value = next;
29
+ }
30
+ function add(files) {
31
+ const incoming = Array.from(files);
32
+ if (incoming.length === 0) return;
33
+ const availableSlots = 4 - entries.size;
34
+ if (availableSlots <= 0) {
35
+ notice.value = `you can attach up to 4 files.`;
36
+ return;
37
+ }
38
+ const selected = incoming.slice(0, availableSlots);
39
+ notice.value = selected.length < incoming.length ? `only 4 attachments can be sent at once.` : null;
40
+ for (const file of selected) {
41
+ const localId = uuid();
42
+ const upload = createChatAttachment(core, file);
43
+ const previewUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : null;
44
+ entries.set(localId, {
45
+ localId,
46
+ upload,
47
+ previewUrl
48
+ });
49
+ unsubscribes.set(localId, upload.subscribe(snapshot));
50
+ upload.result.catch(() => void 0);
51
+ }
52
+ snapshot();
53
+ }
54
+ function remove(localId) {
55
+ const entry = entries.get(localId);
56
+ if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl);
57
+ unsubscribes.get(localId)?.();
58
+ unsubscribes.delete(localId);
59
+ entries.delete(localId);
60
+ notice.value = null;
61
+ snapshot();
62
+ }
63
+ function clear() {
64
+ for (const localId of [...entries.keys()]) remove(localId);
65
+ }
66
+ function references() {
67
+ return drafts.value.flatMap((draft) => draft.status === "ready" && draft.attachmentId ? [{ attachmentId: draft.attachmentId }] : []);
68
+ }
69
+ const pending = computed(() => drafts.value.some((draft) => draft.status === "checking" || draft.status === "uploading"));
70
+ onScopeDispose(() => {
71
+ for (const unsubscribe of unsubscribes.values()) unsubscribe();
72
+ for (const entry of entries.values()) if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
73
+ unsubscribes.clear();
74
+ entries.clear();
75
+ });
76
+ return reactive({
77
+ drafts,
78
+ add,
79
+ remove,
80
+ clear,
81
+ references,
82
+ pending,
83
+ notice,
84
+ accept
85
+ });
86
+ }
87
+ export { useChatAttachments };