@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.
@@ -0,0 +1,1125 @@
1
+ import { t as useDropBy } from "../chunks/use-drop-by-4B6MMMx1.js";
2
+ import { a as useChatThread, i as useIdeas, n as useFeatureFlag, o as useChatThreads, r as usePendingAsks } from "../chunks/use-feature-BQHA7pdn.js";
3
+ import { n as chatAttachmentMaxByteSize, r as uuid } from "../chunks/chat-attachments-BeJa4tEd.js";
4
+ import { computed, defineComponent, h, nextTick, onBeforeUnmount, onMounted, onScopeDispose, onUpdated, reactive, ref, shallowRef, toValue, watch, watchEffect } from "vue";
5
+ import { DropByError, isVotableIdea, validateAskAnswers } from "@dropby/browser";
6
+ import { fetchChatAttachmentFile } from "@dropby/browser/attachments";
7
+ function asDropByError(caught) {
8
+ return caught instanceof DropByError ? caught : new DropByError("request_failed", caught instanceof Error ? caught.message : "DropBy request failed", { cause: caught });
9
+ }
10
+ function paginate(fields, size) {
11
+ const limit = size && size > 0 ? size : fields.length;
12
+ if (limit >= fields.length) return [fields];
13
+ const pages = [];
14
+ for (let i = 0; i < fields.length; i += limit) pages.push(fields.slice(i, i + limit));
15
+ return pages;
16
+ }
17
+ function toRequest(answers) {
18
+ return { answers: Object.entries(answers).map(([fieldId, value]) => ({
19
+ fieldId,
20
+ value
21
+ })) };
22
+ }
23
+ function issuesFor(fields, answers) {
24
+ const map = {};
25
+ for (const issue of validateAskAnswers({ fields }, toRequest(answers)).issues) map[issue.fieldId] = issue.message;
26
+ return map;
27
+ }
28
+ const AskForm = defineComponent({
29
+ name: "AskForm",
30
+ slots: Object,
31
+ props: {
32
+ ask: {
33
+ type: Object,
34
+ required: true
35
+ },
36
+ fieldsPerStep: {
37
+ type: Number,
38
+ default: void 0
39
+ }
40
+ },
41
+ setup(props, { slots }) {
42
+ const { actions } = usePendingAsks();
43
+ const answers = ref({});
44
+ const step = ref(0);
45
+ const submitting = ref(false);
46
+ const issues = ref({});
47
+ const failure = shallowRef(null);
48
+ const pages = computed(() => paginate(props.ask.fields, props.fieldsPerStep));
49
+ const stepCount = computed(() => pages.value.length);
50
+ const safeStep = computed(() => Math.min(step.value, stepCount.value - 1));
51
+ const fields = computed(() => pages.value[safeStep.value] ?? []);
52
+ const isFirst = computed(() => safeStep.value === 0);
53
+ const isLast = computed(() => safeStep.value === stepCount.value - 1);
54
+ const stepped = computed(() => stepCount.value > 1);
55
+ function setAnswer(fieldId, value) {
56
+ answers.value = {
57
+ ...answers.value,
58
+ [fieldId]: value
59
+ };
60
+ if (fieldId in issues.value) {
61
+ const next = { ...issues.value };
62
+ delete next[fieldId];
63
+ issues.value = next;
64
+ }
65
+ }
66
+ function back() {
67
+ issues.value = {};
68
+ step.value = Math.max(0, step.value - 1);
69
+ }
70
+ function next() {
71
+ const stepIssues = issuesFor(fields.value, answers.value);
72
+ if (Object.keys(stepIssues).length > 0) {
73
+ issues.value = stepIssues;
74
+ return;
75
+ }
76
+ issues.value = {};
77
+ step.value = Math.min(stepCount.value - 1, step.value + 1);
78
+ }
79
+ async function submit() {
80
+ const allIssues = issuesFor(props.ask.fields, answers.value);
81
+ if (Object.keys(allIssues).length > 0) {
82
+ issues.value = allIssues;
83
+ const firstBadStep = pages.value.findIndex((page) => page.some((field) => allIssues[field.fieldId]));
84
+ if (firstBadStep >= 0) step.value = firstBadStep;
85
+ return;
86
+ }
87
+ await attempt("submit", () => actions.submit(props.ask.askId, toRequest(answers.value)));
88
+ }
89
+ async function dismiss() {
90
+ await attempt("dismiss", () => actions.dismiss(props.ask.askId));
91
+ }
92
+ async function attempt(action, run) {
93
+ submitting.value = true;
94
+ failure.value = null;
95
+ try {
96
+ await run();
97
+ } catch (caught) {
98
+ failure.value = {
99
+ action,
100
+ error: asDropByError(caught)
101
+ };
102
+ } finally {
103
+ submitting.value = false;
104
+ }
105
+ }
106
+ function handleSubmit(event) {
107
+ event.preventDefault();
108
+ if (isLast.value) submit();
109
+ else next();
110
+ }
111
+ return () => {
112
+ const state = {
113
+ ask: props.ask,
114
+ fields: fields.value,
115
+ answers: answers.value,
116
+ setAnswer,
117
+ step: safeStep.value,
118
+ stepCount: stepCount.value,
119
+ isFirst: isFirst.value,
120
+ isLast: isLast.value,
121
+ back,
122
+ next,
123
+ submit,
124
+ dismiss,
125
+ submitting: submitting.value,
126
+ issues: issues.value,
127
+ error: failure.value?.error ?? null
128
+ };
129
+ if (slots.default) return h("form", { onSubmit: handleSubmit }, slots.default(state));
130
+ return h("form", {
131
+ "data-step": stepped.value ? safeStep.value + 1 : void 0,
132
+ "data-step-count": stepped.value ? stepCount.value : void 0,
133
+ onSubmit: handleSubmit
134
+ }, [
135
+ ...fields.value.map((field) => renderFieldControl(field, answers.value[field.fieldId], issues.value[field.fieldId], (value) => setAnswer(field.fieldId, value))),
136
+ failure.value ? h("p", { role: "alert" }, failure.value.action === "submit" ? "couldn't send your answer. try again." : "couldn't dismiss this. try again.") : null,
137
+ h("div", [
138
+ !isFirst.value ? h("button", {
139
+ type: "button",
140
+ onClick: back
141
+ }, "back") : null,
142
+ h("button", {
143
+ type: "submit",
144
+ "aria-busy": submitting.value ? "true" : void 0,
145
+ "data-busy": submitting.value ? "" : void 0,
146
+ disabled: submitting.value
147
+ }, isLast.value ? "submit" : "next"),
148
+ props.ask.dismissible ? h("button", {
149
+ type: "button",
150
+ onClick: () => void dismiss(),
151
+ disabled: submitting.value
152
+ }, "dismiss") : null
153
+ ])
154
+ ]);
155
+ };
156
+ }
157
+ });
158
+ function renderFieldControl(field, value, issue, onChange) {
159
+ return h("div", {
160
+ role: "group",
161
+ "aria-label": field.label,
162
+ "aria-invalid": issue ? "true" : void 0,
163
+ "data-field-type": field.type
164
+ }, [
165
+ h("span", `${field.label}${field.required ? " *" : ""}`),
166
+ renderFieldInput(field, value, onChange),
167
+ issue ? h("span", { role: "alert" }, issue) : null
168
+ ]);
169
+ }
170
+ function renderFieldInput(field, value, onChange) {
171
+ if (field.type === "short_text" || field.type === "long_text") {
172
+ const text = typeof value === "string" ? value : "";
173
+ const onInput = (event) => onChange(event.target.value);
174
+ const shared = {
175
+ "aria-label": field.label,
176
+ maxlength: field.maxLength,
177
+ value: text,
178
+ onInput
179
+ };
180
+ return field.type === "long_text" ? h("textarea", shared) : h("input", {
181
+ type: "text",
182
+ ...shared
183
+ });
184
+ }
185
+ if (field.type === "rating") {
186
+ const ratings = [];
187
+ for (let n = field.min; n <= field.max; n += 1) ratings.push(n);
188
+ return ratings.map((n) => h("label", { key: n }, [h("input", {
189
+ type: "radio",
190
+ name: field.fieldId,
191
+ checked: value === n,
192
+ onChange: () => onChange(n)
193
+ }), n]));
194
+ }
195
+ const selected = Array.isArray(value) ? value : [];
196
+ const multiple = field.type === "multi_choice";
197
+ const atMax = multiple && selected.length >= field.maxSelections;
198
+ return field.options.map((option) => {
199
+ const checked = selected.includes(option.optionId);
200
+ return h("label", { key: option.optionId }, [h("input", {
201
+ type: multiple ? "checkbox" : "radio",
202
+ name: field.fieldId,
203
+ checked,
204
+ disabled: multiple ? !checked && atMax : false,
205
+ onChange: () => !multiple ? onChange([option.optionId]) : onChange(checked ? selected.filter((id) => id !== option.optionId) : [...selected, option.optionId])
206
+ }), option.label]);
207
+ });
208
+ }
209
+ const Ask = defineComponent({
210
+ name: "Ask",
211
+ slots: Object,
212
+ props: {
213
+ ask: {
214
+ type: Object,
215
+ required: true
216
+ },
217
+ fieldsPerStep: {
218
+ type: Number,
219
+ default: void 0
220
+ }
221
+ },
222
+ setup(props, { slots }) {
223
+ return () => {
224
+ if (slots.default) return h("div", { "data-pending": "" }, slots.default({ ask: props.ask }));
225
+ return h("div", { "data-pending": "" }, [h("strong", props.ask.title), h(AskForm, {
226
+ ask: props.ask,
227
+ ...props.fieldsPerStep !== void 0 ? { fieldsPerStep: props.fieldsPerStep } : {}
228
+ })]);
229
+ };
230
+ }
231
+ });
232
+ function scrollPort(root) {
233
+ const doc = root.ownerDocument, win = doc.defaultView;
234
+ for (let element = root; element; element = element.parentElement) if (win && element.scrollHeight > element.clientHeight && /(auto|scroll|overlay)/.test(win.getComputedStyle(element).overflowY)) return {
235
+ element,
236
+ target: element,
237
+ top: () => element.getBoundingClientRect().top,
238
+ bottom: () => element.getBoundingClientRect().bottom
239
+ };
240
+ return {
241
+ element: doc.scrollingElement ?? doc.documentElement,
242
+ target: win ?? doc,
243
+ top: () => 0,
244
+ bottom: () => win?.innerHeight ?? doc.documentElement.clientHeight
245
+ };
246
+ }
247
+ function messages(root) {
248
+ return [...root.querySelectorAll("[data-message-id]")];
249
+ }
250
+ function watchChatScrollPosition(root) {
251
+ const port = scrollPort(root);
252
+ let anchorId, offset = 0;
253
+ const capture = () => {
254
+ const anchor = messages(root).find((message) => message.getBoundingClientRect().bottom > port.top());
255
+ anchorId = anchor?.dataset.messageId;
256
+ offset = anchor ? anchor.getBoundingClientRect().top - port.top() : 0;
257
+ };
258
+ capture();
259
+ port.target.addEventListener("scroll", capture, { passive: true });
260
+ const stop = () => port.target.removeEventListener("scroll", capture);
261
+ return {
262
+ stop,
263
+ restore: () => {
264
+ stop();
265
+ const retained = messages(root), anchor = retained.find((message) => message.dataset.messageId === anchorId);
266
+ if (anchor) port.element.scrollTop += anchor.getBoundingClientRect().top - port.top() - offset;
267
+ else if (anchorId && retained.length) scrollChatToLatest(root);
268
+ }
269
+ };
270
+ }
271
+ function scrollChatToLatest(root) {
272
+ const last = messages(root).at(-1);
273
+ if (!last) return;
274
+ const port = scrollPort(root);
275
+ if (port.element === root) {
276
+ root.scrollTop = root.scrollHeight;
277
+ return;
278
+ }
279
+ port.element.scrollTop += last.getBoundingClientRect().bottom - port.bottom();
280
+ }
281
+ function watchChatNavigationFocus(root, selector, signal) {
282
+ const doc = root.ownerDocument;
283
+ let focused = !!("activeElement" in root ? root.activeElement : doc.activeElement)?.matches(selector);
284
+ const capture = (event) => {
285
+ const element = event.composedPath()[0];
286
+ focused = element instanceof Element && root.contains(element) && element.matches(selector);
287
+ };
288
+ doc.addEventListener("focusin", capture, {
289
+ capture: true,
290
+ ...signal ? { signal } : {}
291
+ });
292
+ const stop = () => doc.removeEventListener("focusin", capture, true);
293
+ return {
294
+ stop,
295
+ restore(fallback) {
296
+ stop();
297
+ if (focused) (root.querySelector(selector) ?? fallback)?.focus({ preventScroll: true });
298
+ }
299
+ };
300
+ }
301
+ const followSlack = 24;
302
+ var ChatFollower = class {
303
+ root;
304
+ signal;
305
+ behind;
306
+ port = null;
307
+ stopListening = null;
308
+ end = null;
309
+ following = true;
310
+ triedLatestAt = null;
311
+ constructor(root, signal, behind = () => {}) {
312
+ this.root = root;
313
+ this.signal = signal;
314
+ this.behind = behind;
315
+ signal.addEventListener("abort", () => this.stopListening?.(), { once: true });
316
+ }
317
+ settle(state) {
318
+ if (this.signal.aborted) return;
319
+ const port = ownScrollContainer(this.root);
320
+ if (port !== this.port) this.listen(port);
321
+ const opened = this.end === null && state.end !== null;
322
+ const shownEnd = this.end;
323
+ const arrived = state.end !== null && this.end !== null && state.end > this.end;
324
+ if (port && (opened || arrived && (this.following || state.lastMine))) scrollForwardTo(port, messages(this.root).at(-1));
325
+ if (state.end !== null) this.end = Math.max(this.end ?? state.end, state.end);
326
+ this.measure();
327
+ const count = state.count;
328
+ if (port && this.following && !opened && state.end !== null && shownEnd !== null && state.end >= shownEnd && count !== null && count > state.end && this.triedLatestAt !== count) {
329
+ this.triedLatestAt = count;
330
+ this.behind();
331
+ }
332
+ }
333
+ listen(port) {
334
+ this.stopListening?.();
335
+ this.stopListening = null;
336
+ this.port = port;
337
+ if (!port) return;
338
+ const measure = () => this.measure();
339
+ port.addEventListener("scroll", measure, { passive: true });
340
+ this.stopListening = () => port.removeEventListener("scroll", measure);
341
+ }
342
+ measure() {
343
+ const last = messages(this.root).at(-1);
344
+ this.following = !this.port || !last || last.getBoundingClientRect().bottom - this.port.getBoundingClientRect().bottom <= followSlack;
345
+ }
346
+ };
347
+ function ownScrollContainer(root) {
348
+ const doc = root.ownerDocument;
349
+ const win = doc.defaultView;
350
+ for (let element = root; element; element = element.parentElement) {
351
+ if (element === doc.body || element === doc.documentElement) return null;
352
+ if (win && /(auto|scroll|overlay)/.test(win.getComputedStyle(element).overflowY)) return element;
353
+ }
354
+ return null;
355
+ }
356
+ function scrollForwardTo(port, message) {
357
+ if (!message) return;
358
+ const beyond = message.getBoundingClientRect().bottom - port.getBoundingClientRect().bottom;
359
+ if (beyond > 0) port.scrollTop += beyond;
360
+ }
361
+ const readEnd = 24;
362
+ var ChatSeenTracker = class {
363
+ options;
364
+ observer = null;
365
+ observed = /* @__PURE__ */ new Map();
366
+ intersecting = /* @__PURE__ */ new Set();
367
+ renderGeneration = 0;
368
+ interactionGeneration = 0;
369
+ failedGeneration = -1;
370
+ inFlight = false;
371
+ frame = 0;
372
+ constructor(options) {
373
+ this.options = options;
374
+ const { root, signal } = options;
375
+ const interaction = () => this.interact();
376
+ for (const type of [
377
+ "pointerdown",
378
+ "wheel",
379
+ "keydown"
380
+ ]) root.addEventListener(type, interaction, {
381
+ signal,
382
+ passive: true
383
+ });
384
+ document.addEventListener("visibilitychange", () => {
385
+ if (document.visibilityState === "visible") this.interact();
386
+ }, { signal });
387
+ const scroll = () => this.recheckSoon();
388
+ for (const target of /* @__PURE__ */ new Set([
389
+ root,
390
+ root.getRootNode(),
391
+ document
392
+ ])) target.addEventListener("scroll", scroll, {
393
+ signal,
394
+ capture: true,
395
+ passive: true
396
+ });
397
+ signal.addEventListener("abort", () => this.disconnect(), { once: true });
398
+ }
399
+ interact() {
400
+ this.interactionGeneration++;
401
+ this.dispatch();
402
+ }
403
+ disconnect() {
404
+ this.renderGeneration++;
405
+ this.observer?.disconnect();
406
+ this.observer = null;
407
+ this.observed.clear();
408
+ this.intersecting.clear();
409
+ if (this.frame) cancelAnimationFrame(this.frame);
410
+ this.frame = 0;
411
+ }
412
+ observe() {
413
+ this.disconnect();
414
+ if (this.options.signal.aborted || !this.options.permitted() || typeof IntersectionObserver === "undefined") return;
415
+ const generation = this.renderGeneration;
416
+ this.observer = new IntersectionObserver((entries) => {
417
+ if (generation !== this.renderGeneration || !this.options.permitted()) return;
418
+ for (const entry of entries) if (entry.isIntersecting) this.intersecting.add(entry.target);
419
+ else this.intersecting.delete(entry.target);
420
+ this.dispatch();
421
+ }, { threshold: [0, 1] });
422
+ for (const element of this.options.root.querySelectorAll("[data-read-message-id]")) {
423
+ const id = element.dataset.readMessageId;
424
+ if (id) {
425
+ this.observed.set(id, element);
426
+ this.observer.observe(element);
427
+ }
428
+ }
429
+ }
430
+ recheckSoon() {
431
+ if (this.frame || this.observed.size === 0) return;
432
+ this.frame = requestAnimationFrame(() => {
433
+ this.frame = 0;
434
+ this.dispatch();
435
+ });
436
+ }
437
+ candidate() {
438
+ const thread = this.options.thread();
439
+ if (this.options.signal.aborted || !this.options.permitted() || document.visibilityState !== "visible" || !thread || thread.userSeen.unreadTeamMessageCount === 0) return null;
440
+ const { lastSeenTeamMessageId, unreadTeamMessageCount } = thread.userSeen;
441
+ const seenIndex = thread.messages.findIndex((message) => message.messageId === lastSeenTeamMessageId);
442
+ let unread = !thread.hasNewerMessages ? unreadTeamMessageCount : seenIndex === -1 ? 0 : Infinity;
443
+ for (let index = thread.messages.length - 1; index > seenIndex && unread > 0; index--) {
444
+ const message = thread.messages[index];
445
+ if (!message || message.authorType !== "team") continue;
446
+ unread--;
447
+ const element = this.observed.get(message.messageId);
448
+ if (element && this.intersecting.has(element) && this.options.root.contains(element) && endOnScreen(element)) return message.messageId;
449
+ }
450
+ return null;
451
+ }
452
+ dispatch() {
453
+ if (this.inFlight || this.failedGeneration === this.interactionGeneration) return;
454
+ const messageId = this.candidate();
455
+ const threadId = this.options.thread()?.threadId;
456
+ if (!messageId || !threadId) return;
457
+ this.inFlight = true;
458
+ this.options.markSeen(threadId, messageId).then(() => {
459
+ if (this.candidate() === messageId) this.failedGeneration = this.interactionGeneration;
460
+ }).catch(() => {
461
+ this.failedGeneration = this.interactionGeneration;
462
+ }).finally(() => {
463
+ this.inFlight = false;
464
+ this.dispatch();
465
+ });
466
+ }
467
+ };
468
+ function endOnScreen(element) {
469
+ if (!element.isConnected) return false;
470
+ const rect = element.getBoundingClientRect();
471
+ if (rect.width <= 0 || rect.height <= 0) return false;
472
+ let left = 0, top = 0, right = window.innerWidth, bottom = window.innerHeight;
473
+ let current = element;
474
+ while (current) {
475
+ const style = getComputedStyle(current);
476
+ if (current.hasAttribute("hidden") || style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || Number.parseFloat(style.opacity) === 0) return false;
477
+ if (current !== element) {
478
+ const clip = current.getBoundingClientRect();
479
+ if (/hidden|clip|scroll|auto/.test(style.overflowX)) {
480
+ left = Math.max(left, clip.left);
481
+ right = Math.min(right, clip.right);
482
+ }
483
+ if (/hidden|clip|scroll|auto/.test(style.overflowY)) {
484
+ top = Math.max(top, clip.top);
485
+ bottom = Math.min(bottom, clip.bottom);
486
+ }
487
+ }
488
+ const tree = current.getRootNode();
489
+ current = current.parentElement ?? (tree instanceof ShadowRoot ? tree.host : null);
490
+ }
491
+ const endTop = rect.bottom - Math.min(readEnd, rect.height);
492
+ return rect.left >= left && rect.right <= right && endTop >= top && rect.bottom <= bottom;
493
+ }
494
+ function saveFile(file, fileName, container = document.body) {
495
+ const href = URL.createObjectURL(file);
496
+ const link = document.createElement("a");
497
+ link.href = href;
498
+ link.download = fileName;
499
+ link.target = "_self";
500
+ container.append(link);
501
+ link.click();
502
+ link.remove();
503
+ setTimeout(() => URL.revokeObjectURL(href), 6e4);
504
+ }
505
+ function attachmentFailureCopy(code) {
506
+ return `couldn't attach. ${code === "invalid_file_name" ? "that file name can't be used." : code === "unsupported_type" ? "that file type isn't supported." : code === "too_large" ? `that file is larger than ${chatAttachmentMaxByteSize / 1024 / 1024} MB.` : code === "classification_failed" || code === "type_mismatch" || code === "read_failed" || code === "size_mismatch" ? "that file couldn't be read." : "try again."}`;
507
+ }
508
+ function sameAttempt(kept, text, attachmentIds) {
509
+ return kept.text === text && kept.attachmentIds.length === attachmentIds.length && kept.attachmentIds.every((id, index) => id === attachmentIds[index]);
510
+ }
511
+ const ChatComposer = defineComponent({
512
+ name: "ChatComposer",
513
+ slots: Object,
514
+ props: {
515
+ threadId: {
516
+ type: String,
517
+ required: true
518
+ },
519
+ attachments: {
520
+ type: Object,
521
+ default: void 0
522
+ }
523
+ },
524
+ setup(props, { slots }) {
525
+ const { actions } = useChatThread(() => props.threadId);
526
+ const draft = ref("");
527
+ const sending = ref(false);
528
+ const error = shallowRef(null);
529
+ const attempts = /* @__PURE__ */ new Map();
530
+ function setDraft(value) {
531
+ draft.value = value;
532
+ }
533
+ async function send() {
534
+ const text = draft.value.trim();
535
+ const attachments = props.attachments;
536
+ if (!text || sending.value || (attachments?.pending ?? false)) return;
537
+ sending.value = true;
538
+ error.value = null;
539
+ try {
540
+ const references = attachments?.references() ?? [];
541
+ const attachmentIds = references.map((reference) => reference.attachmentId);
542
+ const threadId = props.threadId;
543
+ const kept = attempts.get(threadId);
544
+ const key = kept && sameAttempt(kept, text, attachmentIds) ? kept.key : uuid();
545
+ attempts.set(threadId, {
546
+ key,
547
+ text,
548
+ attachmentIds
549
+ });
550
+ await actions.sendMessage(references.length > 0 ? {
551
+ text,
552
+ attachments: references
553
+ } : { text }, { idempotencyKey: key });
554
+ attempts.delete(threadId);
555
+ draft.value = "";
556
+ attachments?.clear();
557
+ } catch (caught) {
558
+ error.value = asDropByError(caught);
559
+ } finally {
560
+ sending.value = false;
561
+ }
562
+ }
563
+ function handleSubmit(event) {
564
+ event.preventDefault();
565
+ send();
566
+ }
567
+ return () => {
568
+ const attachments = props.attachments;
569
+ const state = {
570
+ draft: draft.value,
571
+ setDraft,
572
+ send,
573
+ sending: sending.value,
574
+ error: error.value,
575
+ attachments
576
+ };
577
+ if (slots.default) return h("form", { onSubmit: handleSubmit }, slots.default(state));
578
+ const sendDisabled = sending.value || draft.value.trim().length === 0 || (attachments?.pending ?? false);
579
+ return h("form", { onSubmit: handleSubmit }, [
580
+ h("textarea", {
581
+ "aria-label": "message",
582
+ value: draft.value,
583
+ onInput: (event) => setDraft(event.target.value)
584
+ }),
585
+ attachments ? renderAttachments(attachments) : null,
586
+ error.value ? h("p", { role: "alert" }, "couldn't send. your message is still here.") : null,
587
+ h("button", {
588
+ type: "submit",
589
+ "aria-busy": sending.value ? "true" : void 0,
590
+ "data-busy": sending.value ? "" : void 0,
591
+ disabled: sendDisabled
592
+ }, "send")
593
+ ]);
594
+ };
595
+ }
596
+ });
597
+ function renderAttachments(attachments) {
598
+ return h("div", { "data-attachments": "" }, [
599
+ h("input", {
600
+ type: "file",
601
+ multiple: true,
602
+ accept: attachments.accept,
603
+ "aria-label": "attach files",
604
+ onChange: (event) => {
605
+ const input = event.target;
606
+ if (input.files && input.files.length > 0) attachments.add(input.files);
607
+ input.value = "";
608
+ }
609
+ }),
610
+ attachments.notice ? h("p", { role: "alert" }, attachments.notice) : null,
611
+ attachments.drafts.length > 0 ? h("ul", { "aria-label": "attachments" }, attachments.drafts.map((item) => h("li", {
612
+ key: item.localId,
613
+ "data-status": item.status,
614
+ "data-attachment-kind": item.kind
615
+ }, [
616
+ item.previewUrl ? h("img", {
617
+ src: item.previewUrl,
618
+ alt: "",
619
+ width: 48,
620
+ height: 48
621
+ }) : null,
622
+ h("span", item.fileName),
623
+ item.error ? h("span", { role: "alert" }, attachmentFailureCopy(item.error.code)) : null,
624
+ h("button", {
625
+ type: "button",
626
+ "aria-label": `remove ${item.fileName}`,
627
+ onClick: () => attachments.remove(item.localId)
628
+ }, "remove")
629
+ ]))) : null
630
+ ]);
631
+ }
632
+ function useChatNavigation(latestSucceeded) {
633
+ const root = ref(null);
634
+ let generation = 0, position = null, focus = null;
635
+ onScopeDispose(() => {
636
+ generation++;
637
+ position?.stop();
638
+ focus?.stop();
639
+ position = null;
640
+ });
641
+ async function navigate(kind, run) {
642
+ const element = root.value;
643
+ if (!element) return;
644
+ const ticket = ++generation;
645
+ position?.stop();
646
+ focus?.stop();
647
+ const captured = watchChatScrollPosition(element);
648
+ position = captured;
649
+ const capturedFocus = watchChatNavigationFocus(element, `[data-history-action="${kind}"]`);
650
+ focus = capturedFocus;
651
+ try {
652
+ await run();
653
+ } finally {
654
+ await nextTick();
655
+ if (ticket === generation && root.value === element) {
656
+ if (kind === "latest" && latestSucceeded()) {
657
+ captured.stop();
658
+ scrollChatToLatest(element);
659
+ } else captured.restore();
660
+ position = null;
661
+ capturedFocus.restore(element);
662
+ focus = null;
663
+ }
664
+ }
665
+ }
666
+ return {
667
+ root,
668
+ navigate
669
+ };
670
+ }
671
+ function mayAcknowledge(core) {
672
+ const capabilities = core.capabilities.getSnapshot();
673
+ return capabilities.chat.canRead && !capabilities.actionDisabled("chat.thread:seen");
674
+ }
675
+ function useChatSeen(core, root, id, enabled) {
676
+ let stop = null;
677
+ let tracker = null;
678
+ function start() {
679
+ const element = root.value;
680
+ if (stop || !enabled() || !element) return;
681
+ const lifetime = new AbortController();
682
+ const current = new ChatSeenTracker({
683
+ root: element,
684
+ signal: lifetime.signal,
685
+ thread: () => core.chat.getSnapshot().threadsById[id()]?.data ?? null,
686
+ permitted: () => mayAcknowledge(core),
687
+ markSeen: (threadId, messageId) => core.chat.markThreadSeen(threadId, { seenThroughMessageId: messageId })
688
+ });
689
+ let permitted = mayAcknowledge(core);
690
+ const stopCapabilities = core.capabilities.subscribe(() => {
691
+ const now = mayAcknowledge(core);
692
+ if (now && !permitted) {
693
+ current.observe();
694
+ current.interact();
695
+ }
696
+ permitted = now;
697
+ });
698
+ const live = () => ["open", "polling"].includes(core.connection.getSnapshot().status);
699
+ let connected = live();
700
+ const stopConnection = core.connection.subscribe(() => {
701
+ const now = live();
702
+ if (now && !connected) current.interact();
703
+ connected = now;
704
+ });
705
+ tracker = current;
706
+ current.observe();
707
+ stop = () => {
708
+ lifetime.abort();
709
+ stopCapabilities();
710
+ stopConnection();
711
+ tracker = null;
712
+ stop = null;
713
+ };
714
+ }
715
+ onMounted(start);
716
+ onUpdated(() => tracker?.observe());
717
+ watch(enabled, (on) => on ? start() : stop?.());
718
+ onBeforeUnmount(() => stop?.());
719
+ }
720
+ function useChatFollow(root, thread, enabled, loadLatest) {
721
+ const lifetime = new AbortController();
722
+ let follower = null;
723
+ const newest = () => {
724
+ const current = thread();
725
+ return {
726
+ end: current?.messages.at(-1)?.sequence ?? null,
727
+ lastMine: current?.messages.at(-1)?.authorType === "user",
728
+ count: current?.messageCount ?? null
729
+ };
730
+ };
731
+ onMounted(() => {
732
+ if (!enabled() || !root.value) return;
733
+ follower = new ChatFollower(root.value, lifetime.signal, loadLatest);
734
+ follower.settle(newest());
735
+ });
736
+ watch([() => newest().end, () => newest().count], () => follower?.settle(newest()), { flush: "post" });
737
+ onBeforeUnmount(() => lifetime.abort());
738
+ }
739
+ const ChatThread = defineComponent({
740
+ name: "ChatThread",
741
+ slots: Object,
742
+ props: {
743
+ id: {
744
+ type: String,
745
+ required: true
746
+ },
747
+ manualLoad: {
748
+ type: Boolean,
749
+ default: false
750
+ },
751
+ acknowledge: {
752
+ type: Boolean,
753
+ default: true
754
+ }
755
+ },
756
+ setup(props, { slots }) {
757
+ const core = useDropBy();
758
+ const { data, actions, olderMessages, latestMessages } = useChatThread(() => props.id);
759
+ const navigation = useChatNavigation(() => latestMessages.value?.status === "success");
760
+ useChatSeen(core, navigation.root, () => props.id, () => props.acknowledge && !slots.default);
761
+ useChatFollow(navigation.root, () => data.value?.data, () => !slots.default, () => void actions.loadLatestMessages());
762
+ onMounted(() => {
763
+ watchEffect(() => {
764
+ if (!props.manualLoad && (data.value?.status ?? "idle") === "idle") actions.open();
765
+ });
766
+ });
767
+ return () => {
768
+ const resource = data.value;
769
+ if (slots.default) {
770
+ const state = {
771
+ thread: resource,
772
+ actions,
773
+ olderMessages: olderMessages.value,
774
+ latestMessages: latestMessages.value
775
+ };
776
+ return slots.default(state);
777
+ }
778
+ const status = resource?.status ?? "idle";
779
+ const thread = resource?.data;
780
+ return h("div", {
781
+ "data-status": status,
782
+ tabindex: -1,
783
+ ref: navigation.root
784
+ }, [
785
+ thread?.hasOlderMessages ? h("button", {
786
+ type: "button",
787
+ "data-history-action": "older",
788
+ disabled: olderMessages.value?.status === "loading" || latestMessages.value?.status === "loading",
789
+ onClick: () => void navigation.navigate("older", () => actions.loadOlderMessages())
790
+ }, olderMessages.value?.status === "loading" ? "loading older messages" : "load older messages") : null,
791
+ status === "error" && !thread ? h("p", { role: "alert" }, ["couldn't open this conversation. ", h("button", {
792
+ type: "button",
793
+ onClick: () => void actions.open()
794
+ }, "try again")]) : null,
795
+ olderMessages.value?.error ? h("p", { role: "alert" }, "couldn't load older messages. try again.") : null,
796
+ h("ul", { "aria-live": "polite" }, (thread?.messages ?? []).map((message) => h("li", {
797
+ key: message.messageId,
798
+ "data-message-id": message.messageId,
799
+ "data-read-message-id": message.authorType === "team" ? message.messageId : void 0,
800
+ "data-author": message.authorType
801
+ }, [message.text, message.attachments.length > 0 ? h("ul", {}, message.attachments.map((attachment) => h(AttachmentDownload, {
802
+ key: attachment.attachmentId,
803
+ attachment,
804
+ fetchFile: (id) => fetchChatAttachmentFile(core, id)
805
+ }))) : null]))),
806
+ thread && (thread.hasNewerMessages || latestMessages.value?.status === "loading" || latestMessages.value?.error) ? h("button", {
807
+ type: "button",
808
+ "data-history-action": "latest",
809
+ disabled: latestMessages.value?.status === "loading",
810
+ onClick: () => void navigation.navigate("latest", () => actions.loadLatestMessages())
811
+ }, latestMessages.value?.status === "loading" ? "loading latest messages" : "back to latest") : null,
812
+ latestMessages.value?.error ? h("p", { role: "alert" }, "couldn't load latest messages. try again.") : null,
813
+ h(ChatComposer, { threadId: props.id })
814
+ ]);
815
+ };
816
+ }
817
+ });
818
+ const AttachmentDownload = defineComponent({
819
+ name: "AttachmentDownload",
820
+ props: {
821
+ attachment: {
822
+ type: Object,
823
+ required: true
824
+ },
825
+ fetchFile: {
826
+ type: Function,
827
+ required: true
828
+ }
829
+ },
830
+ setup(props) {
831
+ const downloading = ref(false);
832
+ const error = ref(null);
833
+ async function download() {
834
+ downloading.value = true;
835
+ error.value = null;
836
+ try {
837
+ saveFile(await props.fetchFile(props.attachment.attachmentId), props.attachment.fileName);
838
+ } catch (caught) {
839
+ error.value = caught instanceof Error ? caught : new Error(String(caught));
840
+ } finally {
841
+ downloading.value = false;
842
+ }
843
+ }
844
+ return () => h("li", {}, [h("button", {
845
+ type: "button",
846
+ "aria-label": `download ${props.attachment.fileName}`,
847
+ "aria-busy": downloading.value ? "true" : void 0,
848
+ "data-attachment-kind": props.attachment.kind,
849
+ "data-busy": downloading.value ? "" : void 0,
850
+ "data-error": error.value ? "" : void 0,
851
+ disabled: downloading.value,
852
+ onClick: () => void download()
853
+ }, props.attachment.fileName), error.value ? h("span", { role: "alert" }, `couldn't download ${props.attachment.fileName}. try again.`) : null]);
854
+ }
855
+ });
856
+ const ChatThreadList = defineComponent({
857
+ name: "ChatThreadList",
858
+ slots: Object,
859
+ props: {
860
+ manualLoad: {
861
+ type: Boolean,
862
+ default: false
863
+ },
864
+ selectedId: {
865
+ type: String,
866
+ default: null
867
+ },
868
+ onSelect: { type: Function }
869
+ },
870
+ setup(props, { slots }) {
871
+ const { data, actions } = useChatThreads();
872
+ onMounted(() => {
873
+ watchEffect(() => {
874
+ if (!props.manualLoad && data.value.status === "idle") actions.listThreads();
875
+ });
876
+ });
877
+ return () => {
878
+ const collection = data.value;
879
+ if (slots.default) {
880
+ const state = {
881
+ threads: collection,
882
+ actions
883
+ };
884
+ return slots.default(state);
885
+ }
886
+ const empty = collection.status === "success" && collection.items.length === 0;
887
+ const select = props.onSelect;
888
+ return h("ul", {
889
+ "aria-live": "polite",
890
+ "data-status": collection.status,
891
+ "data-empty": empty ? "" : void 0
892
+ }, [
893
+ h("li", [
894
+ h("button", {
895
+ type: "button",
896
+ disabled: collection.status === "loading",
897
+ onClick: () => void actions.listThreads({ filter: collection.filter })
898
+ }, "refresh"),
899
+ h("button", {
900
+ type: "button",
901
+ "aria-pressed": collection.filter === "unread",
902
+ onClick: () => void actions.listThreads({ filter: collection.filter === "unread" ? "all" : "unread" })
903
+ }, "unread"),
904
+ collection.status === "error" ? h("span", { role: "alert" }, "couldn't load conversations. try again.") : null
905
+ ]),
906
+ ...collection.items.map((thread) => {
907
+ const unread = thread.userSeen.unreadTeamMessageCount;
908
+ const summary = `${thread.lastMessagePreview || "conversation"}${unread > 0 ? ` · ${unread} unread replies` : ""}`;
909
+ return h("li", {
910
+ key: thread.threadId,
911
+ "data-status": thread.status,
912
+ "data-unread": unread > 0 ? "" : void 0
913
+ }, select ? h("button", {
914
+ type: "button",
915
+ "aria-current": thread.threadId === props.selectedId ? "true" : void 0,
916
+ onClick: () => select(thread.threadId)
917
+ }, summary) : summary);
918
+ }),
919
+ collection.hasMore || collection.status === "success" && collection.error ? h("li", { "data-load-more": "" }, [collection.status === "success" && collection.error ? h("span", { role: "alert" }, "couldn't load more conversations. try again.") : null, h("button", {
920
+ type: "button",
921
+ disabled: collection.loadingMore,
922
+ onClick: () => void actions.loadMoreThreads()
923
+ }, collection.loadingMore ? "loading more conversations" : "show more conversations")]) : null
924
+ ]);
925
+ };
926
+ }
927
+ });
928
+ const Feature = defineComponent({
929
+ name: "Feature",
930
+ slots: Object,
931
+ inheritAttrs: false,
932
+ props: {
933
+ name: {
934
+ type: String,
935
+ required: true
936
+ },
937
+ equals: {
938
+ type: [
939
+ Boolean,
940
+ String,
941
+ Number
942
+ ],
943
+ default: void 0
944
+ }
945
+ },
946
+ setup(props, { slots }) {
947
+ const value = useFeatureFlag(() => props.name);
948
+ return () => {
949
+ return (props.equals === void 0 ? Boolean(value.value) : value.value === props.equals) ? slots.default?.() : slots.fallback?.();
950
+ };
951
+ }
952
+ });
953
+ function useIdeaVote(idea) {
954
+ const { actions } = useIdeas();
955
+ const voting = ref(false);
956
+ const error = shallowRef(null);
957
+ const current = computed(() => toValue(idea));
958
+ const voted = computed(() => current.value.viewerHasVoted);
959
+ async function run(action) {
960
+ voting.value = true;
961
+ error.value = null;
962
+ try {
963
+ await action();
964
+ } catch (caught) {
965
+ error.value = asDropByError(caught);
966
+ } finally {
967
+ voting.value = false;
968
+ }
969
+ }
970
+ return reactive({
971
+ idea: current,
972
+ voted,
973
+ votable: computed(() => isVotableIdea(current.value)),
974
+ voting,
975
+ error,
976
+ vote: () => run(() => actions.vote(current.value.ideaId)),
977
+ unvote: () => run(() => actions.unvote(current.value.ideaId)),
978
+ toggle: () => run(() => voted.value ? actions.unvote(current.value.ideaId) : actions.vote(current.value.ideaId))
979
+ });
980
+ }
981
+ const IdeaVoteButton = defineComponent({
982
+ name: "IdeaVoteButton",
983
+ slots: Object,
984
+ inheritAttrs: false,
985
+ props: { idea: {
986
+ type: Object,
987
+ required: true
988
+ } },
989
+ setup(props, { slots, attrs }) {
990
+ const state = useIdeaVote(() => props.idea);
991
+ function handleClick(event) {
992
+ for (const listener of [attrs.onClick].flat()) if (typeof listener === "function") listener(event);
993
+ if (event.defaultPrevented) return;
994
+ state.toggle();
995
+ }
996
+ return () => {
997
+ const passthrough = { ...attrs };
998
+ delete passthrough.onClick;
999
+ delete passthrough.disabled;
1000
+ const disabled = attrs.disabled ?? (state.voting || !state.votable);
1001
+ const content = slots.default ? slots.default(state) : state.idea.voteCount;
1002
+ return h("button", {
1003
+ type: "button",
1004
+ "aria-pressed": state.voted ? "true" : "false",
1005
+ "aria-busy": state.voting ? "true" : void 0,
1006
+ "data-voted": state.voted ? "" : void 0,
1007
+ "data-busy": state.voting ? "" : void 0,
1008
+ ...passthrough,
1009
+ disabled,
1010
+ onClick: handleClick
1011
+ }, content);
1012
+ };
1013
+ }
1014
+ });
1015
+ const Idea = defineComponent({
1016
+ name: "Idea",
1017
+ slots: Object,
1018
+ props: { idea: {
1019
+ type: Object,
1020
+ required: true
1021
+ } },
1022
+ setup(props, { slots }) {
1023
+ return () => {
1024
+ const voted = props.idea.viewerHasVoted ? "" : void 0;
1025
+ if (slots.default) return h("div", { "data-voted": voted }, slots.default({ idea: props.idea }));
1026
+ return h("div", { "data-voted": voted }, [h(IdeaVoteButton, { idea: props.idea }), h("div", [h("strong", props.idea.title), props.idea.description ? h("p", props.idea.description) : null])]);
1027
+ };
1028
+ }
1029
+ });
1030
+ const IdeaList = defineComponent({
1031
+ name: "IdeaList",
1032
+ slots: Object,
1033
+ props: {
1034
+ view: {
1035
+ type: String,
1036
+ default: void 0
1037
+ },
1038
+ manualLoad: {
1039
+ type: Boolean,
1040
+ default: false
1041
+ }
1042
+ },
1043
+ setup(props, { slots }) {
1044
+ const { data, actions } = useIdeas();
1045
+ onMounted(() => {
1046
+ watchEffect(() => {
1047
+ if (!props.manualLoad && data.value.status === "idle") actions.list({ view: props.view });
1048
+ });
1049
+ });
1050
+ return () => {
1051
+ const collection = data.value;
1052
+ if (slots.default) {
1053
+ const state = {
1054
+ ideas: collection,
1055
+ actions
1056
+ };
1057
+ return slots.default(state);
1058
+ }
1059
+ const empty = collection.status === "success" && collection.items.length === 0;
1060
+ const items = collection.items.map((idea) => h("li", { key: idea.ideaId }, h(Idea, { idea })));
1061
+ if (collection.hasMore) items.push(h("li", {
1062
+ key: "load-more",
1063
+ "data-load-more": ""
1064
+ }, [h("button", {
1065
+ type: "button",
1066
+ disabled: collection.loadingMore,
1067
+ onClick: () => void actions.loadMore()
1068
+ }, collection.loadingMore ? "loading more ideas" : "show more ideas")]));
1069
+ return h("ul", {
1070
+ "aria-live": "polite",
1071
+ "data-status": collection.status,
1072
+ "data-empty": empty ? "" : void 0
1073
+ }, items);
1074
+ };
1075
+ }
1076
+ });
1077
+ const PendingAsks = defineComponent({
1078
+ name: "PendingAsks",
1079
+ slots: Object,
1080
+ props: {
1081
+ fieldsPerStep: {
1082
+ type: Number,
1083
+ default: void 0
1084
+ },
1085
+ manualLoad: {
1086
+ type: Boolean,
1087
+ default: false
1088
+ }
1089
+ },
1090
+ setup(props, { slots }) {
1091
+ const { data, actions } = usePendingAsks();
1092
+ onMounted(() => {
1093
+ watchEffect(() => {
1094
+ if (!props.manualLoad && data.value.status === "idle") actions.listPending();
1095
+ });
1096
+ });
1097
+ return () => {
1098
+ const collection = data.value;
1099
+ if (slots.default) {
1100
+ const state = {
1101
+ asks: collection,
1102
+ actions
1103
+ };
1104
+ return slots.default(state);
1105
+ }
1106
+ const empty = collection.status === "success" && collection.items.length === 0;
1107
+ return h("ul", {
1108
+ "aria-live": "polite",
1109
+ "data-status": collection.status,
1110
+ "data-empty": empty ? "" : void 0
1111
+ }, [...collection.items.map((ask) => h("li", { key: ask.askId }, h(Ask, {
1112
+ ask,
1113
+ ...props.fieldsPerStep !== void 0 ? { fieldsPerStep: props.fieldsPerStep } : {}
1114
+ }))), collection.status === "error" ? h("li", [h("span", { role: "status" }, "couldn’t load asks."), h("button", {
1115
+ type: "button",
1116
+ onClick: () => void actions.listPending()
1117
+ }, "retry")]) : collection.hasMore || collection.error ? h("li", { "data-load-more": "" }, [collection.error ? h("span", { role: "status" }, "couldn’t load more asks.") : null, h("button", {
1118
+ type: "button",
1119
+ disabled: collection.loadingMore,
1120
+ onClick: () => void actions.loadMorePending()
1121
+ }, collection.loadingMore ? "loading more asks" : "show more asks")]) : null]);
1122
+ };
1123
+ }
1124
+ });
1125
+ export { Ask, AskForm, ChatComposer, ChatThread, ChatThreadList, Feature, Idea, IdeaList, IdeaVoteButton, PendingAsks };