@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 +21 -0
- package/README.md +188 -0
- package/dist/chunks/chat-attachment-types-BG8VFQj5.d.ts +56 -0
- package/dist/chunks/chat-attachments-BeJa4tEd.js +20 -0
- package/dist/chunks/public-component-A2rcQYAZ.d.ts +10 -0
- package/dist/chunks/use-drop-by-4B6MMMx1.js +33 -0
- package/dist/chunks/use-feature-BQHA7pdn.js +68 -0
- package/dist/components/chat-attachments/index.d.ts +9 -0
- package/dist/components/chat-attachments/index.js +87 -0
- package/dist/components/index.d.ts +402 -0
- package/dist/components/index.js +1125 -0
- package/dist/index.d.ts +123 -0
- package/dist/index.js +33 -0
- package/package.json +53 -0
|
@@ -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 };
|