@polymorfa/react 0.1.0-dev.20260922094209
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 +5 -0
- package/README.md +303 -0
- package/dist/call-button.d.ts +29 -0
- package/dist/call-button.d.ts.map +1 -0
- package/dist/call-button.js +54 -0
- package/dist/calls.d.ts +201 -0
- package/dist/calls.d.ts.map +1 -0
- package/dist/calls.js +944 -0
- package/dist/components.d.ts +109 -0
- package/dist/components.d.ts.map +1 -0
- package/dist/components.js +843 -0
- package/dist/context.d.ts +47 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +82 -0
- package/dist/dropin.d.ts +100 -0
- package/dist/dropin.d.ts.map +1 -0
- package/dist/dropin.js +298 -0
- package/dist/hooks.d.ts +30 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +71 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/package.json +45 -0
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { VoiceNoteRecorder, localAttachmentFromFile, } from "@polymorfa/browser";
|
|
3
|
+
import { CHAT_ICONS, EMOJI_CATEGORY_ICONS, EMOJI_PICKER_CATEGORIES, applyQuickReply, emojiInCategory, filterQuickReplies, findQuickReplyQuery, formatElapsed, insertText, placePopover, quickReplyLabel, readRecentEmoji, recentEmojiEntries, recordRecentEmoji, searchEmoji, ENGLISH_MESSAGES, appearanceToCssVariables, formatDayLabel, formatFileSize, formatMessageTime, injectComponentStyles, isImageAttachment, layoutMessages, safeAttachmentUrl, slotClassName, themeClassName, } from "@polymorfa/ui";
|
|
4
|
+
import { memo, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
|
|
5
|
+
import { usePolymorfa } from "./context.js";
|
|
6
|
+
import { useController, safeSubscribe, useOwnedController, useResolvedController, } from "./hooks.js";
|
|
7
|
+
// Runs before paint in the browser, and quietly on the server.
|
|
8
|
+
const useIsomorphicLayoutEffect = typeof document === "undefined" ? useEffect : useLayoutEffect;
|
|
9
|
+
function slotStyle(styles) {
|
|
10
|
+
if (styles === undefined)
|
|
11
|
+
return undefined;
|
|
12
|
+
const style = {};
|
|
13
|
+
for (const [name, value] of Object.entries(styles))
|
|
14
|
+
style[name.startsWith("--")
|
|
15
|
+
? name
|
|
16
|
+
: name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = value;
|
|
17
|
+
return style;
|
|
18
|
+
}
|
|
19
|
+
function createSlots(appearance, classNames) {
|
|
20
|
+
const styles = new Map();
|
|
21
|
+
return (slot, base) => {
|
|
22
|
+
if (!styles.has(slot))
|
|
23
|
+
styles.set(slot, slotStyle(appearance.elements[slot]?.styles));
|
|
24
|
+
const style = styles.get(slot);
|
|
25
|
+
return {
|
|
26
|
+
className: slotClassName(appearance, slot, base, classNames),
|
|
27
|
+
...(style === undefined ? {} : { style }),
|
|
28
|
+
"data-slot": slot,
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Slot resolver that keeps its identity while the inputs are unchanged. */
|
|
33
|
+
export function useSlots(classNames) {
|
|
34
|
+
const { appearance } = usePolymorfa();
|
|
35
|
+
const key = classNames === undefined ? "" : JSON.stringify(classNames);
|
|
36
|
+
// `key` stands in for `classNames`, which callers usually pass inline.
|
|
37
|
+
return useMemo(() => createSlots(appearance, classNames), [appearance, key]);
|
|
38
|
+
}
|
|
39
|
+
function useStyles(appearance) {
|
|
40
|
+
const unstyled = appearance.unstyled === true;
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!unstyled)
|
|
43
|
+
injectComponentStyles();
|
|
44
|
+
}, [unstyled]);
|
|
45
|
+
}
|
|
46
|
+
/** Root props: theme classes, direction, CSS variables, and the slot. */
|
|
47
|
+
export function useShell(slots, slot, base, className) {
|
|
48
|
+
const configuration = usePolymorfa();
|
|
49
|
+
const { appearance, locale } = configuration;
|
|
50
|
+
useStyles(appearance);
|
|
51
|
+
const variables = useMemo(() => ({
|
|
52
|
+
...appearanceToCssVariables(appearance),
|
|
53
|
+
"--pmfa-drawer-width": appearance.layout.drawerWidth,
|
|
54
|
+
}), [appearance]);
|
|
55
|
+
const slotProps = slots(slot, base);
|
|
56
|
+
return {
|
|
57
|
+
dir: appearance.layout.direction === "auto"
|
|
58
|
+
? locale.direction
|
|
59
|
+
: appearance.layout.direction,
|
|
60
|
+
className: [
|
|
61
|
+
themeClassName(appearance.theme),
|
|
62
|
+
slotProps.className,
|
|
63
|
+
className,
|
|
64
|
+
]
|
|
65
|
+
.filter((value) => value !== undefined && value !== "")
|
|
66
|
+
.join(" "),
|
|
67
|
+
style: slotProps.style === undefined
|
|
68
|
+
? variables
|
|
69
|
+
: { ...variables, ...slotProps.style },
|
|
70
|
+
"data-slot": slot,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function text(configuration, key, values = {}) {
|
|
74
|
+
const template = configuration.locale.messages[key] ?? ENGLISH_MESSAGES[key];
|
|
75
|
+
return template.replace(/\{(\w+)\}/g, (_, name) => values[name] ?? "");
|
|
76
|
+
}
|
|
77
|
+
export function Icon({ name, className = "pmfa-icon", }) {
|
|
78
|
+
return (_jsx("svg", { className: className, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: CHAT_ICONS[name] }) }));
|
|
79
|
+
}
|
|
80
|
+
/** Subscribe to a controller that may be absent. */
|
|
81
|
+
const NO_SUBSCRIPTION = () => () => undefined;
|
|
82
|
+
const NO_SNAPSHOT = () => undefined;
|
|
83
|
+
function useOptionalController(controller) {
|
|
84
|
+
return useSyncExternalStore(controller === undefined ? NO_SUBSCRIPTION : safeSubscribe(controller), controller?.getSnapshot ?? NO_SNAPSHOT, controller?.getSnapshot ?? NO_SNAPSHOT);
|
|
85
|
+
}
|
|
86
|
+
/** A value's latest version, for stable callbacks. */
|
|
87
|
+
function useLatest(value) {
|
|
88
|
+
const ref = useRef(value);
|
|
89
|
+
useIsomorphicLayoutEffect(() => {
|
|
90
|
+
ref.current = value;
|
|
91
|
+
});
|
|
92
|
+
return ref;
|
|
93
|
+
}
|
|
94
|
+
function authorName(configuration, message) {
|
|
95
|
+
return text(configuration, message?.direction === "outbound" ? "chat.you" : "chat.contact");
|
|
96
|
+
}
|
|
97
|
+
function snippet(configuration, message) {
|
|
98
|
+
return message.text.trim() !== ""
|
|
99
|
+
? message.text
|
|
100
|
+
: (message.attachments?.[0]?.name ??
|
|
101
|
+
text(configuration, "chat.attachment"));
|
|
102
|
+
}
|
|
103
|
+
function AttachmentView({ attachment, configuration, slots, }) {
|
|
104
|
+
// Unsafe schemes such as `javascript:` never reach `href` or `src`.
|
|
105
|
+
const url = safeAttachmentUrl(attachment.url);
|
|
106
|
+
const source = safeAttachmentUrl(attachment.previewUrl) ?? url;
|
|
107
|
+
if (isImageAttachment(attachment) && source !== undefined) {
|
|
108
|
+
const image = (_jsx("img", { src: source, alt: attachment.name, loading: "lazy", decoding: "async" }));
|
|
109
|
+
return url === undefined ? (_jsx("div", { ...slots("attachment", "pmfa-att pmfa-att-media"), children: image })) : (_jsx("a", { ...slots("attachment", "pmfa-att pmfa-att-media"), href: url, target: "_blank", rel: "noopener noreferrer", children: image }));
|
|
110
|
+
}
|
|
111
|
+
const body = (_jsxs(_Fragment, { children: [_jsx("span", { className: "pmfa-att-icon", children: _jsx(Icon, { name: "file" }) }), _jsxs("span", { className: "pmfa-att-body", children: [_jsx("span", { className: "pmfa-att-name", children: attachment.name }), _jsx("span", { className: "pmfa-att-size", children: formatFileSize(attachment.size, configuration.locale.code) })] })] }));
|
|
112
|
+
return url === undefined ? (_jsx("div", { ...slots("attachment", "pmfa-att pmfa-att-file"), children: body })) : (_jsx("a", { ...slots("attachment", "pmfa-att pmfa-att-file"), href: url, target: "_blank", rel: "noopener noreferrer", title: text(configuration, "chat.openAttachment", {
|
|
113
|
+
name: attachment.name,
|
|
114
|
+
}), children: body }));
|
|
115
|
+
}
|
|
116
|
+
const MessageItem = memo(function MessageItem({ message, quoted, groupStart, groupEnd, configuration, slots, canReply, canRetry, onReply, onRetry, onJump, renderMessage, renderAttachment, }) {
|
|
117
|
+
const outbound = message.direction === "outbound";
|
|
118
|
+
const time = formatMessageTime(message.createdAt, configuration.locale.code);
|
|
119
|
+
const status = message.status === "failed"
|
|
120
|
+
? text(configuration, "chat.failed")
|
|
121
|
+
: message.status === "pending"
|
|
122
|
+
? text(configuration, "chat.sending")
|
|
123
|
+
: outbound
|
|
124
|
+
? text(configuration, "chat.sent")
|
|
125
|
+
: undefined;
|
|
126
|
+
const retry = canRetry &&
|
|
127
|
+
outbound &&
|
|
128
|
+
message.status === "failed" &&
|
|
129
|
+
message.clientId !== undefined;
|
|
130
|
+
const custom = renderMessage?.(message);
|
|
131
|
+
return (_jsxs("li", { ...slots("message", [
|
|
132
|
+
"pmfa-msg",
|
|
133
|
+
`pmfa-msg-${outbound ? "out" : "in"}`,
|
|
134
|
+
`pmfa-msg-${message.status}`,
|
|
135
|
+
groupStart ? "pmfa-msg-start" : "",
|
|
136
|
+
groupEnd ? "pmfa-msg-end" : "",
|
|
137
|
+
]
|
|
138
|
+
.filter(Boolean)
|
|
139
|
+
.join(" ")), "data-message-id": message.id, tabIndex: -1, children: [quoted !== undefined && (_jsxs("button", { type: "button", ...slots("replyQuote", "pmfa-quote"), onClick: () => onJump(quoted.id), children: [_jsx("span", { className: "pmfa-sr", children: text(configuration, "chat.jumpToReply") }), _jsx("span", { className: "pmfa-quote-name", children: authorName(configuration, quoted) }), _jsx("span", { className: "pmfa-quote-text", children: snippet(configuration, quoted) })] })), _jsxs("div", { className: "pmfa-row", children: [_jsx("div", { ...slots("bubble", "pmfa-bubble"), children: custom ?? (_jsxs(_Fragment, { children: [(message.attachments?.length ?? 0) > 0 && (_jsx("div", { className: "pmfa-atts", children: message.attachments?.map((attachment) => (_jsx(AttachmentItem, { attachment: attachment, message: message, configuration: configuration, slots: slots, renderAttachment: renderAttachment }, attachment.id))) })), message.text !== "" && (_jsx("span", { className: "pmfa-text", children: message.text }))] })) }), (canReply || retry) && (_jsxs("div", { ...slots("messageActions", "pmfa-actions-msg"), role: "group", "aria-label": text(configuration, "chat.messageActions"), children: [retry && (_jsx("button", { type: "button", ...slots("retryButton", "pmfa-btn pmfa-btn-ghost pmfa-btn-icon pmfa-action-retry"), "aria-label": text(configuration, "chat.retry"), title: text(configuration, "chat.retry"), onClick: () => onRetry(message), children: _jsx(Icon, { name: "retry" }) })), canReply && (_jsx("button", { type: "button", ...slots("replyButton", "pmfa-btn pmfa-btn-ghost pmfa-btn-icon"), "aria-label": text(configuration, "chat.reply"), title: text(configuration, "chat.reply"), onClick: () => onReply(message), children: _jsx(Icon, { name: "reply" }) }))] }))] }), _jsxs("span", { ...slots("messageMeta", "pmfa-meta"), children: [time !== undefined && (_jsx("time", { dateTime: new Date(message.createdAt).toISOString(), children: time })), outbound && (_jsx(Icon, { name: message.status, className: "pmfa-icon pmfa-status" })), status !== undefined &&
|
|
140
|
+
(message.status === "failed" ? (_jsx("span", { children: status })) : (_jsx("span", { className: "pmfa-sr", children: status })))] }), message.error !== undefined && (_jsx("span", { className: "pmfa-msg-error", role: "alert", children: message.error }))] }));
|
|
141
|
+
});
|
|
142
|
+
function AttachmentItem({ attachment, message, configuration, slots, renderAttachment, }) {
|
|
143
|
+
return (renderAttachment?.(attachment, message) ?? (_jsx(AttachmentView, { attachment: attachment, configuration: configuration, slots: slots })));
|
|
144
|
+
}
|
|
145
|
+
/** Keeps the newest message in view while the reader is already at the end. */
|
|
146
|
+
function useStickToBottom(dependency) {
|
|
147
|
+
const ref = useRef(null);
|
|
148
|
+
const pinned = useRef(true);
|
|
149
|
+
useIsomorphicLayoutEffect(() => {
|
|
150
|
+
const node = ref.current;
|
|
151
|
+
if (node !== null && pinned.current)
|
|
152
|
+
node.scrollTop = node.scrollHeight;
|
|
153
|
+
}, [dependency]);
|
|
154
|
+
const onScroll = useCallback(() => {
|
|
155
|
+
const node = ref.current;
|
|
156
|
+
if (node !== null)
|
|
157
|
+
pinned.current =
|
|
158
|
+
node.scrollHeight - node.scrollTop - node.clientHeight < 32;
|
|
159
|
+
}, []);
|
|
160
|
+
return { ref, onScroll };
|
|
161
|
+
}
|
|
162
|
+
function prefersReducedMotion() {
|
|
163
|
+
return (typeof matchMedia === "function" &&
|
|
164
|
+
matchMedia("(prefers-reduced-motion: reduce)").matches);
|
|
165
|
+
}
|
|
166
|
+
function ConversationLog({ controller, rootProps, slots, renderMessage, renderAttachment, onReply, }) {
|
|
167
|
+
const snapshot = useController(controller);
|
|
168
|
+
const configuration = usePolymorfa();
|
|
169
|
+
const scroll = useStickToBottom(snapshot.messages);
|
|
170
|
+
const entries = useMemo(() => layoutMessages(snapshot.messages), [snapshot.messages]);
|
|
171
|
+
const byId = useMemo(() => new Map(snapshot.messages.map((message) => [message.id, message])), [snapshot.messages]);
|
|
172
|
+
const latestReply = useLatest(onReply);
|
|
173
|
+
const reply = useCallback((message) => latestReply.current?.(message), [latestReply]);
|
|
174
|
+
const retry = useCallback((message) => {
|
|
175
|
+
if (message.clientId !== undefined)
|
|
176
|
+
void controller.retry(message.clientId).catch(() => undefined);
|
|
177
|
+
}, [controller]);
|
|
178
|
+
const jump = useCallback((id) => {
|
|
179
|
+
const target = scroll.ref.current?.querySelector(`[data-message-id="${id.replace(/["\\]/g, "\\$&")}"]`);
|
|
180
|
+
if (target === null || target === undefined)
|
|
181
|
+
return;
|
|
182
|
+
target.scrollIntoView?.({
|
|
183
|
+
block: "center",
|
|
184
|
+
behavior: prefersReducedMotion() ? "auto" : "smooth",
|
|
185
|
+
});
|
|
186
|
+
target.focus({ preventScroll: true });
|
|
187
|
+
target.classList.remove("pmfa-msg-flash");
|
|
188
|
+
target.classList.add("pmfa-msg-flash");
|
|
189
|
+
setTimeout(() => target.classList.remove("pmfa-msg-flash"), 1200);
|
|
190
|
+
}, [scroll.ref]);
|
|
191
|
+
const loadMore = useCallback(() => void controller.loadMore(), [controller]);
|
|
192
|
+
const canRetry = typeof controller.retry === "function";
|
|
193
|
+
const now = Date.now();
|
|
194
|
+
const dayLabels = {
|
|
195
|
+
today: text(configuration, "chat.today"),
|
|
196
|
+
yesterday: text(configuration, "chat.yesterday"),
|
|
197
|
+
};
|
|
198
|
+
return (_jsx("div", { ...rootProps, ref: scroll.ref, onScroll: scroll.onScroll, "data-pmfa": "message-list", role: "log", "aria-live": "polite", "aria-label": text(configuration, "chat.title"), children: _jsxs("ol", { className: "pmfa-items", children: [snapshot.hasMore && (_jsx("li", { className: "pmfa-loadmore", children: _jsx("button", { type: "button", ...slots("loadMore", "pmfa-btn"), onClick: loadMore, children: text(configuration, "chat.loadMore") }) })), entries.length === 0 ? (_jsx("li", { ...slots("empty", "pmfa-empty"), children: text(configuration, "chat.empty") })) : (entries.map((entry) => entry.kind === "date" ? (_jsx("li", { ...slots("dateSeparator", "pmfa-date"), children: formatDayLabel(entry.time, configuration.locale.code, dayLabels, now) }, entry.key)) : (_jsx(MessageItem, { message: entry.message, quoted: entry.message.replyTo === undefined
|
|
199
|
+
? undefined
|
|
200
|
+
: byId.get(entry.message.replyTo), groupStart: entry.groupStart, groupEnd: entry.groupEnd, configuration: configuration, slots: slots, canReply: onReply !== undefined, canRetry: canRetry, onReply: reply, onRetry: retry, onJump: jump, renderMessage: renderMessage, renderAttachment: renderAttachment }, entry.key))))] }) }));
|
|
201
|
+
}
|
|
202
|
+
export function MessageList({ controller, createController, className, classNames, ...viewProps }) {
|
|
203
|
+
const resolved = useResolvedController(controller, createController);
|
|
204
|
+
const slots = useSlots(classNames);
|
|
205
|
+
const root = useShell(slots, "messageList", "pmfa-list", className);
|
|
206
|
+
return (_jsx(ConversationLog, { ...viewProps, controller: resolved, rootProps: root, slots: slots }));
|
|
207
|
+
}
|
|
208
|
+
// ── Composer ──────────────────────────────────────────────────────────
|
|
209
|
+
let attachmentCounter = 0;
|
|
210
|
+
function attachmentFromFile(file) {
|
|
211
|
+
attachmentCounter += 1;
|
|
212
|
+
return {
|
|
213
|
+
id: globalThis.crypto?.randomUUID?.() ??
|
|
214
|
+
`local-${Date.now()}-${attachmentCounter}`,
|
|
215
|
+
name: file.name,
|
|
216
|
+
size: file.size,
|
|
217
|
+
contentType: file.type === "" ? "application/octet-stream" : file.type,
|
|
218
|
+
file,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function errorText(cause) {
|
|
222
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
223
|
+
}
|
|
224
|
+
const supportsFieldSizing = typeof CSS !== "undefined" &&
|
|
225
|
+
typeof CSS.supports === "function" &&
|
|
226
|
+
CSS.supports("field-sizing", "content");
|
|
227
|
+
function hasFiles(event) {
|
|
228
|
+
return [...(event.dataTransfer?.types ?? [])].includes("Files");
|
|
229
|
+
}
|
|
230
|
+
function rejectionCleared(previous, next) {
|
|
231
|
+
if (previous.text !== next.text)
|
|
232
|
+
return true;
|
|
233
|
+
if (previous.sending && !next.sending && next.error === undefined)
|
|
234
|
+
return true;
|
|
235
|
+
return (next.status === "ready" &&
|
|
236
|
+
!next.sending &&
|
|
237
|
+
next.error === undefined &&
|
|
238
|
+
next.text === "" &&
|
|
239
|
+
next.attachments.length === 0 &&
|
|
240
|
+
next.replyTo === undefined);
|
|
241
|
+
}
|
|
242
|
+
function AttachmentChip({ attachment, configuration, slots, onRemove, }) {
|
|
243
|
+
const percent = Math.round(attachment.progress * 100);
|
|
244
|
+
return (_jsxs("li", { ...slots("attachmentChip", `pmfa-chip pmfa-chip-${attachment.status}`), "data-attachment-id": attachment.id, children: [_jsx(Icon, { name: attachment.status === "failed" ? "failed" : "file" }), _jsxs("span", { className: "pmfa-chip-body", children: [_jsx("span", { className: "pmfa-chip-name", children: attachment.name }), attachment.status === "uploading" && (_jsx("span", { className: "pmfa-progress", role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": percent, "aria-label": text(configuration, "composer.uploading", {
|
|
245
|
+
name: attachment.name,
|
|
246
|
+
}), children: _jsx("span", { style: { width: `${percent}%` } }) })), _jsx("span", { className: "pmfa-chip-status", "aria-live": "polite", children: attachment.status === "failed" ? ((attachment.error ?? text(configuration, "composer.uploadFailed"))) : attachment.status === "uploading" ? (_jsx("span", { className: "pmfa-sr", children: text(configuration, "composer.uploading", {
|
|
247
|
+
name: attachment.name,
|
|
248
|
+
}) })) : (formatFileSize(attachment.size, configuration.locale.code)) })] }), _jsx("button", { type: "button", className: "pmfa-btn pmfa-btn-ghost pmfa-btn-icon", "aria-label": text(configuration, "composer.removeNamedAttachment", {
|
|
249
|
+
name: attachment.name,
|
|
250
|
+
}), onClick: () => onRemove(attachment.id), children: _jsx(Icon, { name: "close" }) })] }));
|
|
251
|
+
}
|
|
252
|
+
const EMOJI_COLUMNS = 8;
|
|
253
|
+
function gridColumns(cells) {
|
|
254
|
+
const first = cells[0];
|
|
255
|
+
if (first === undefined)
|
|
256
|
+
return EMOJI_COLUMNS;
|
|
257
|
+
let columns = 0;
|
|
258
|
+
for (const cell of cells) {
|
|
259
|
+
if (cell.offsetTop !== first.offsetTop)
|
|
260
|
+
break;
|
|
261
|
+
columns += 1;
|
|
262
|
+
}
|
|
263
|
+
return columns > 0 && columns < cells.length ? columns : EMOJI_COLUMNS;
|
|
264
|
+
}
|
|
265
|
+
function narrowViewport() {
|
|
266
|
+
return (typeof matchMedia === "function" && matchMedia("(max-width: 600px)").matches);
|
|
267
|
+
}
|
|
268
|
+
function EmojiPicker({ anchor, configuration, slots, onPick, onClose, }) {
|
|
269
|
+
const popover = useRef(null);
|
|
270
|
+
const search = useRef(null);
|
|
271
|
+
const grid = useRef(null);
|
|
272
|
+
const id = useId();
|
|
273
|
+
const [recent, setRecent] = useState(() => readRecentEmoji());
|
|
274
|
+
const [category, setCategory] = useState(() => recent.length > 0 ? "recent" : "smileys");
|
|
275
|
+
const [query, setQuery] = useState("");
|
|
276
|
+
const [active, setActive] = useState(0);
|
|
277
|
+
const latestClose = useLatest(onClose);
|
|
278
|
+
const entries = useMemo(() => query.trim() !== ""
|
|
279
|
+
? searchEmoji(query)
|
|
280
|
+
: category === "recent"
|
|
281
|
+
? recentEmojiEntries(recent)
|
|
282
|
+
: emojiInCategory(category), [query, category, recent]);
|
|
283
|
+
const activeIndex = Math.min(active, Math.max(0, entries.length - 1));
|
|
284
|
+
useIsomorphicLayoutEffect(() => {
|
|
285
|
+
const node = popover.current;
|
|
286
|
+
if (node === null)
|
|
287
|
+
return;
|
|
288
|
+
const place = () => {
|
|
289
|
+
const target = anchor.current;
|
|
290
|
+
if (target === null)
|
|
291
|
+
return;
|
|
292
|
+
const rect = target.getBoundingClientRect();
|
|
293
|
+
const width = node.offsetWidth || 360;
|
|
294
|
+
const height = node.offsetHeight || 400;
|
|
295
|
+
const direction = getComputedStyle(target).direction === "rtl" ? "rtl" : "ltr";
|
|
296
|
+
const spot = placePopover(rect, { width, height }, { width: window.innerWidth, height: window.innerHeight }, { direction });
|
|
297
|
+
node.style.setProperty("--pmfa-pop-top", `${spot.top}px`);
|
|
298
|
+
node.style.setProperty("--pmfa-pop-left", `${spot.left}px`);
|
|
299
|
+
node.style.setProperty("--pmfa-pop-max", `${spot.maxHeight}px`);
|
|
300
|
+
node.dataset.placement = spot.placement;
|
|
301
|
+
node.removeAttribute("data-measuring");
|
|
302
|
+
};
|
|
303
|
+
place();
|
|
304
|
+
window.addEventListener("resize", place);
|
|
305
|
+
window.addEventListener("scroll", place, true);
|
|
306
|
+
return () => {
|
|
307
|
+
window.removeEventListener("resize", place);
|
|
308
|
+
window.removeEventListener("scroll", place, true);
|
|
309
|
+
};
|
|
310
|
+
}, [anchor]);
|
|
311
|
+
useEffect(() => {
|
|
312
|
+
// On small screens the search field would raise the keyboard over the
|
|
313
|
+
// sheet, so focus goes to the sheet itself.
|
|
314
|
+
if (narrowViewport())
|
|
315
|
+
popover.current?.focus({ preventScroll: true });
|
|
316
|
+
else
|
|
317
|
+
search.current?.focus({ preventScroll: true });
|
|
318
|
+
const onPointer = (event) => {
|
|
319
|
+
const path = event.composedPath();
|
|
320
|
+
if ((popover.current !== null && path.includes(popover.current)) ||
|
|
321
|
+
(anchor.current !== null && path.includes(anchor.current)))
|
|
322
|
+
return;
|
|
323
|
+
latestClose.current(false);
|
|
324
|
+
};
|
|
325
|
+
document.addEventListener("pointerdown", onPointer, true);
|
|
326
|
+
return () => document.removeEventListener("pointerdown", onPointer, true);
|
|
327
|
+
}, [anchor, latestClose]);
|
|
328
|
+
const cells = () => [
|
|
329
|
+
...(grid.current?.querySelectorAll(".pmfa-emoji-cell") ?? []),
|
|
330
|
+
];
|
|
331
|
+
const focusCell = (index) => {
|
|
332
|
+
const list = cells();
|
|
333
|
+
const next = Math.max(0, Math.min(index, list.length - 1));
|
|
334
|
+
setActive(next);
|
|
335
|
+
list[next]?.focus();
|
|
336
|
+
list[next]?.scrollIntoView?.({ block: "nearest" });
|
|
337
|
+
};
|
|
338
|
+
const pick = (emoji) => {
|
|
339
|
+
setRecent(recordRecentEmoji(emoji));
|
|
340
|
+
onPick(emoji);
|
|
341
|
+
};
|
|
342
|
+
const selectTab = (next) => {
|
|
343
|
+
setCategory(next);
|
|
344
|
+
setQuery("");
|
|
345
|
+
setActive(0);
|
|
346
|
+
if (grid.current !== null)
|
|
347
|
+
grid.current.parentElement?.scrollTo?.({ top: 0 });
|
|
348
|
+
};
|
|
349
|
+
const heading = query.trim() !== ""
|
|
350
|
+
? text(configuration, "composer.emojiSearch")
|
|
351
|
+
: text(configuration, `composer.emojiCategory.${category}`);
|
|
352
|
+
return (_jsxs("div", { ref: popover, ...slots("emojiPicker", "pmfa-popover pmfa-emoji"), role: "dialog", "aria-label": text(configuration, "composer.emojiPicker"), tabIndex: -1, "data-measuring": "", onKeyDown: (event) => {
|
|
353
|
+
if (event.key !== "Escape" || event.nativeEvent.isComposing)
|
|
354
|
+
return;
|
|
355
|
+
event.preventDefault();
|
|
356
|
+
event.stopPropagation();
|
|
357
|
+
latestClose.current(true);
|
|
358
|
+
}, children: [_jsxs("div", { className: "pmfa-emoji-search", children: [_jsx(Icon, { name: "search" }), _jsx("input", { ref: search, type: "search", className: "pmfa-input", "aria-label": text(configuration, "composer.emojiSearch"), placeholder: text(configuration, "composer.emojiSearch"), value: query, "aria-controls": `${id}-panel`, onChange: (event) => {
|
|
359
|
+
setQuery(event.currentTarget.value);
|
|
360
|
+
setActive(0);
|
|
361
|
+
}, onKeyDown: (event) => {
|
|
362
|
+
if (event.key === "ArrowDown") {
|
|
363
|
+
event.preventDefault();
|
|
364
|
+
focusCell(0);
|
|
365
|
+
}
|
|
366
|
+
else if (event.key === "Enter" && entries[0] !== undefined) {
|
|
367
|
+
event.preventDefault();
|
|
368
|
+
pick(entries[0].emoji);
|
|
369
|
+
}
|
|
370
|
+
} })] }), _jsx("div", { className: "pmfa-emoji-tabs", role: "tablist", "aria-label": text(configuration, "composer.emojiCategories"), onKeyDown: (event) => {
|
|
371
|
+
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight")
|
|
372
|
+
return;
|
|
373
|
+
event.preventDefault();
|
|
374
|
+
const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
|
|
375
|
+
const step = (event.key === "ArrowRight") !== rtl ? 1 : -1;
|
|
376
|
+
const index = EMOJI_PICKER_CATEGORIES.indexOf(category);
|
|
377
|
+
const next = EMOJI_PICKER_CATEGORIES[(index + step + EMOJI_PICKER_CATEGORIES.length) %
|
|
378
|
+
EMOJI_PICKER_CATEGORIES.length] ?? "smileys";
|
|
379
|
+
selectTab(next);
|
|
380
|
+
event.currentTarget
|
|
381
|
+
.querySelector(`[data-category="${next}"]`)
|
|
382
|
+
?.focus();
|
|
383
|
+
}, children: EMOJI_PICKER_CATEGORIES.map((name) => {
|
|
384
|
+
const selected = query.trim() === "" && name === category;
|
|
385
|
+
return (_jsx("button", { type: "button", role: "tab", className: "pmfa-emoji-tab", "data-category": name, id: `${id}-tab-${name}`, "aria-selected": selected, "aria-controls": `${id}-panel`, tabIndex: name === (query.trim() === "" ? category : "recent") ? 0 : -1, "aria-label": text(configuration, `composer.emojiCategory.${name}`), title: text(configuration, `composer.emojiCategory.${name}`), onClick: () => selectTab(name), children: _jsx("span", { "aria-hidden": "true", children: EMOJI_CATEGORY_ICONS[name] }) }, name));
|
|
386
|
+
}) }), _jsxs("div", { className: "pmfa-emoji-body", id: `${id}-panel`, role: "tabpanel", "aria-label": heading, children: [_jsx("p", { className: "pmfa-emoji-heading", "aria-hidden": "true", children: heading }), entries.length === 0 ? (_jsx("p", { className: "pmfa-emoji-empty", children: text(configuration, query.trim() === ""
|
|
387
|
+
? "composer.emojiNoRecent"
|
|
388
|
+
: "composer.emojiNoResults") })) : (_jsx("div", { ref: grid, className: "pmfa-emoji-grid", role: "group", "aria-label": heading, onKeyDown: (event) => {
|
|
389
|
+
const list = cells();
|
|
390
|
+
const index = list.indexOf(event.target);
|
|
391
|
+
if (index < 0)
|
|
392
|
+
return;
|
|
393
|
+
const columns = gridColumns(list);
|
|
394
|
+
const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
|
|
395
|
+
const moves = {
|
|
396
|
+
ArrowRight: rtl ? -1 : 1,
|
|
397
|
+
ArrowLeft: rtl ? 1 : -1,
|
|
398
|
+
ArrowDown: columns,
|
|
399
|
+
ArrowUp: -columns,
|
|
400
|
+
Home: -index,
|
|
401
|
+
End: list.length - 1 - index,
|
|
402
|
+
};
|
|
403
|
+
const move = moves[event.key];
|
|
404
|
+
if (move === undefined)
|
|
405
|
+
return;
|
|
406
|
+
event.preventDefault();
|
|
407
|
+
if (event.key === "ArrowUp" && index < columns) {
|
|
408
|
+
search.current?.focus();
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
focusCell(index + move);
|
|
412
|
+
}, children: entries.map((entry, index) => (_jsx("button", { type: "button", className: "pmfa-emoji-cell", tabIndex: index === activeIndex ? 0 : -1, "aria-label": entry.name, title: entry.name, onFocus: () => setActive(index), onClick: () => pick(entry.emoji), children: entry.emoji }, `${entry.emoji}-${index}`))) }))] })] }));
|
|
413
|
+
}
|
|
414
|
+
const LEVEL_BARS = 28;
|
|
415
|
+
function RecordingBar({ snapshot, configuration, slots, autoSend, onCancel, onStop, }) {
|
|
416
|
+
// A rolling history of input levels, newest last.
|
|
417
|
+
const [history, setHistory] = useState({ revision: snapshot.revision, levels: [] });
|
|
418
|
+
if (history.revision !== snapshot.revision) {
|
|
419
|
+
setHistory({
|
|
420
|
+
revision: snapshot.revision,
|
|
421
|
+
levels: [...history.levels, snapshot.level].slice(-LEVEL_BARS),
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
const levels = [
|
|
425
|
+
...Array.from({
|
|
426
|
+
length: LEVEL_BARS - history.levels.length,
|
|
427
|
+
}).fill(0),
|
|
428
|
+
...history.levels,
|
|
429
|
+
];
|
|
430
|
+
const stopLabel = text(configuration, autoSend ? "composer.sendVoiceNote" : "composer.stopRecording");
|
|
431
|
+
return (_jsxs("div", { ...slots("recordingBar", "pmfa-recording"), role: "group", "aria-label": text(configuration, "composer.recording"), children: [_jsx("button", { type: "button", className: "pmfa-btn pmfa-btn-ghost pmfa-btn-icon pmfa-rec-cancel", "aria-label": text(configuration, "composer.cancelRecording"), title: text(configuration, "composer.cancelRecording"), onClick: onCancel, children: _jsx(Icon, { name: "trash" }) }), _jsxs("div", { className: "pmfa-recording-status", children: [_jsx("span", { className: "pmfa-rec-dot", "aria-hidden": "true" }), _jsx("span", { className: "pmfa-rec-time", role: "timer", children: formatElapsed(snapshot.elapsed) }), _jsx("span", { className: "pmfa-rec-level", role: "meter", "aria-label": text(configuration, "composer.recordingLevel"), "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": Math.round(snapshot.level * 100), children: levels.map((level, index) => (_jsx("span", { style: {
|
|
432
|
+
"--pmfa-level": String(Math.max(0.12, level)),
|
|
433
|
+
} }, index))) })] }), _jsx("button", { type: "button", className: "pmfa-btn pmfa-btn-primary pmfa-btn-icon pmfa-send", "aria-label": stopLabel, title: stopLabel, disabled: snapshot.status !== "recording", onClick: onStop, children: _jsx(Icon, { name: autoSend ? "sendArrow" : "sent" }) })] }));
|
|
434
|
+
}
|
|
435
|
+
const voiceErrorKeys = {
|
|
436
|
+
"permission-denied": "composer.microphoneDenied",
|
|
437
|
+
unavailable: "composer.microphoneUnavailable",
|
|
438
|
+
failed: "composer.recordingFailed",
|
|
439
|
+
};
|
|
440
|
+
const isVoiceSupported = () => VoiceNoteRecorder.isSupported();
|
|
441
|
+
const notSupported = () => false;
|
|
442
|
+
export function ComposeBox({ controller, createController, onSent, className, classNames, attachments = true, accept, multiple = true, conversation, messages, placeholder, startActions, endActions, emoji = true, voiceNotes, voiceNoteAutoSend = true, quickReplies, onQuickReply, maxRows = 8, }) {
|
|
443
|
+
const resolved = useResolvedController(controller, createController);
|
|
444
|
+
const snapshot = useController(resolved);
|
|
445
|
+
const conversationSnapshot = useOptionalController(conversation);
|
|
446
|
+
const configuration = usePolymorfa();
|
|
447
|
+
const slots = useSlots(classNames);
|
|
448
|
+
const root = useShell(slots, "composer", "pmfa-composer", className);
|
|
449
|
+
const inputRef = useRef(null);
|
|
450
|
+
const fileRef = useRef(null);
|
|
451
|
+
const emojiButtonRef = useRef(null);
|
|
452
|
+
const micRef = useRef(null);
|
|
453
|
+
const pendingCaret = useRef(undefined);
|
|
454
|
+
const menuId = useId();
|
|
455
|
+
const [dragging, setDragging] = useState(false);
|
|
456
|
+
const [rejection, setRejection] = useState(undefined);
|
|
457
|
+
const [emojiOpen, setEmojiOpen] = useState(false);
|
|
458
|
+
const [caret, setCaret] = useState(0);
|
|
459
|
+
const [dismissedText, setDismissedText] = useState(undefined);
|
|
460
|
+
const [activeOption, setActiveOption] = useState(0);
|
|
461
|
+
const [announcement, setAnnouncement] = useState("");
|
|
462
|
+
// A rejected file's message clears once the text changes, a send
|
|
463
|
+
// succeeds, or the composer resets.
|
|
464
|
+
const [previousSnapshot, setPreviousSnapshot] = useState(snapshot);
|
|
465
|
+
if (previousSnapshot !== snapshot) {
|
|
466
|
+
setPreviousSnapshot(snapshot);
|
|
467
|
+
if (rejection !== undefined && rejectionCleared(previousSnapshot, snapshot))
|
|
468
|
+
setRejection(undefined);
|
|
469
|
+
}
|
|
470
|
+
// Voice notes
|
|
471
|
+
const voiceSupported = useSyncExternalStore(NO_SUBSCRIPTION, isVoiceSupported, notSupported);
|
|
472
|
+
const voiceEnabled = (voiceNotes ?? true) && voiceSupported;
|
|
473
|
+
const recorderRef = useRef(undefined);
|
|
474
|
+
const [recorder, setRecorder] = useState(undefined);
|
|
475
|
+
const recording = useOptionalController(recorder);
|
|
476
|
+
useEffect(() => () => {
|
|
477
|
+
recorderRef.current?.dispose();
|
|
478
|
+
recorderRef.current = undefined;
|
|
479
|
+
}, []);
|
|
480
|
+
const recorderActive = recording !== undefined &&
|
|
481
|
+
(recording.status === "recording" || recording.status === "stopping");
|
|
482
|
+
const voiceError = recording?.status === "error" && recording.error !== undefined
|
|
483
|
+
? text(configuration, voiceErrorKeys[recording.error])
|
|
484
|
+
: undefined;
|
|
485
|
+
useIsomorphicLayoutEffect(() => {
|
|
486
|
+
const input = inputRef.current;
|
|
487
|
+
if (input === null)
|
|
488
|
+
return;
|
|
489
|
+
if (!supportsFieldSizing) {
|
|
490
|
+
input.style.height = "auto";
|
|
491
|
+
input.style.height = `${input.scrollHeight + 2}px`;
|
|
492
|
+
}
|
|
493
|
+
const target = pendingCaret.current;
|
|
494
|
+
if (target !== undefined && input.value === snapshot.text) {
|
|
495
|
+
pendingCaret.current = undefined;
|
|
496
|
+
input.setSelectionRange(target, target);
|
|
497
|
+
}
|
|
498
|
+
}, [snapshot.text]);
|
|
499
|
+
const canSend = !snapshot.sending &&
|
|
500
|
+
(snapshot.text.trim() !== "" ||
|
|
501
|
+
snapshot.attachments.some(({ status }) => status === "ready"));
|
|
502
|
+
const showMic = voiceEnabled &&
|
|
503
|
+
!snapshot.sending &&
|
|
504
|
+
snapshot.text.trim() === "" &&
|
|
505
|
+
!snapshot.attachments.some(({ status }) => status === "ready");
|
|
506
|
+
const submit = () => {
|
|
507
|
+
if (!canSend)
|
|
508
|
+
return;
|
|
509
|
+
void resolved.submit().then(() => {
|
|
510
|
+
// A failed send resolves with the error in the snapshot.
|
|
511
|
+
if (resolved.getSnapshot().status !== "error")
|
|
512
|
+
onSent?.();
|
|
513
|
+
}, () => undefined);
|
|
514
|
+
};
|
|
515
|
+
const addFiles = (files) => {
|
|
516
|
+
if (files === null || files === undefined)
|
|
517
|
+
return;
|
|
518
|
+
setRejection(undefined);
|
|
519
|
+
for (const file of [...files])
|
|
520
|
+
resolved
|
|
521
|
+
.addAttachment(attachmentFromFile(file))
|
|
522
|
+
.catch((cause) => setRejection(errorText(cause)));
|
|
523
|
+
};
|
|
524
|
+
const remove = useCallback((id) => resolved.cancelAttachment(id), [resolved]);
|
|
525
|
+
const replaceText = (value, nextCaret) => {
|
|
526
|
+
pendingCaret.current = nextCaret;
|
|
527
|
+
setRejection(undefined);
|
|
528
|
+
setCaret(nextCaret);
|
|
529
|
+
resolved.setText(value);
|
|
530
|
+
};
|
|
531
|
+
const insertEmoji = (value) => {
|
|
532
|
+
const input = inputRef.current;
|
|
533
|
+
const current = resolved.getSnapshot().text;
|
|
534
|
+
const start = input?.selectionStart ?? current.length;
|
|
535
|
+
const end = input?.selectionEnd ?? start;
|
|
536
|
+
const edit = insertText(current, value, start, end);
|
|
537
|
+
replaceText(edit.text, edit.caret);
|
|
538
|
+
};
|
|
539
|
+
const closeEmoji = useCallback((restoreFocus) => {
|
|
540
|
+
setEmojiOpen(false);
|
|
541
|
+
if (restoreFocus)
|
|
542
|
+
emojiButtonRef.current?.focus();
|
|
543
|
+
}, []);
|
|
544
|
+
// Quick replies
|
|
545
|
+
const quickMatch = quickReplies !== undefined && quickReplies.length > 0 && !recorderActive
|
|
546
|
+
? findQuickReplyQuery(snapshot.text, Math.min(caret, snapshot.text.length))
|
|
547
|
+
: undefined;
|
|
548
|
+
const options = quickMatch === undefined || quickReplies === undefined
|
|
549
|
+
? []
|
|
550
|
+
: filterQuickReplies(quickReplies, quickMatch.query);
|
|
551
|
+
const menuOpen = options.length > 0 && dismissedText !== snapshot.text;
|
|
552
|
+
const [previousQuery, setPreviousQuery] = useState(quickMatch?.query);
|
|
553
|
+
if (previousQuery !== quickMatch?.query) {
|
|
554
|
+
setPreviousQuery(quickMatch?.query);
|
|
555
|
+
setActiveOption(0);
|
|
556
|
+
}
|
|
557
|
+
const activeIndex = Math.min(activeOption, Math.max(0, options.length - 1));
|
|
558
|
+
const chooseOption = (option) => {
|
|
559
|
+
if (quickMatch === undefined)
|
|
560
|
+
return;
|
|
561
|
+
const edit = applyQuickReply(snapshot.text, quickMatch, option);
|
|
562
|
+
replaceText(edit.text, edit.caret);
|
|
563
|
+
onQuickReply?.(option);
|
|
564
|
+
inputRef.current?.focus();
|
|
565
|
+
};
|
|
566
|
+
const startRecording = () => {
|
|
567
|
+
let active = recorderRef.current;
|
|
568
|
+
if (active === undefined) {
|
|
569
|
+
active = new VoiceNoteRecorder();
|
|
570
|
+
recorderRef.current = active;
|
|
571
|
+
setRecorder(active);
|
|
572
|
+
}
|
|
573
|
+
setEmojiOpen(false);
|
|
574
|
+
const current = active;
|
|
575
|
+
void current.start().then(() => {
|
|
576
|
+
if (current.getSnapshot().status === "recording")
|
|
577
|
+
setAnnouncement(text(configuration, "composer.recording"));
|
|
578
|
+
});
|
|
579
|
+
};
|
|
580
|
+
const cancelRecording = () => {
|
|
581
|
+
recorderRef.current?.cancel();
|
|
582
|
+
setAnnouncement(text(configuration, "composer.recordingCancelled"));
|
|
583
|
+
requestAnimationFrameSafe(() => micRef.current?.focus());
|
|
584
|
+
};
|
|
585
|
+
const stopRecording = () => {
|
|
586
|
+
const active = recorderRef.current;
|
|
587
|
+
if (active === undefined)
|
|
588
|
+
return;
|
|
589
|
+
void active.stop().then(async (file) => {
|
|
590
|
+
setAnnouncement(text(configuration, "composer.recordingStopped"));
|
|
591
|
+
requestAnimationFrameSafe(() => inputRef.current?.focus());
|
|
592
|
+
if (file === undefined)
|
|
593
|
+
return;
|
|
594
|
+
const attachment = localAttachmentFromFile(file);
|
|
595
|
+
try {
|
|
596
|
+
await resolved.addAttachment(attachment);
|
|
597
|
+
}
|
|
598
|
+
catch (cause) {
|
|
599
|
+
setRejection(errorText(cause));
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (!voiceNoteAutoSend)
|
|
603
|
+
return;
|
|
604
|
+
const after = resolved.getSnapshot();
|
|
605
|
+
const added = after.attachments.find(({ id }) => id === attachment.id);
|
|
606
|
+
if (added?.status !== "ready" ||
|
|
607
|
+
after.sending ||
|
|
608
|
+
after.attachments.some(({ status }) => status !== "ready"))
|
|
609
|
+
return;
|
|
610
|
+
await resolved.submit().then(() => {
|
|
611
|
+
if (resolved.getSnapshot().status !== "error")
|
|
612
|
+
onSent?.();
|
|
613
|
+
}, () => undefined);
|
|
614
|
+
});
|
|
615
|
+
};
|
|
616
|
+
const pool = messages ?? conversationSnapshot?.messages;
|
|
617
|
+
const replied = snapshot.replyTo === undefined
|
|
618
|
+
? undefined
|
|
619
|
+
: pool?.find(({ id }) => id === snapshot.replyTo);
|
|
620
|
+
const error = snapshot.error ?? rejection ?? voiceError;
|
|
621
|
+
const inputSlot = slots("composerInput", "pmfa-input");
|
|
622
|
+
const combobox = quickReplies !== undefined && quickReplies.length > 0
|
|
623
|
+
? {
|
|
624
|
+
role: "combobox",
|
|
625
|
+
"aria-autocomplete": "list",
|
|
626
|
+
"aria-expanded": menuOpen,
|
|
627
|
+
"aria-controls": menuId,
|
|
628
|
+
...(menuOpen
|
|
629
|
+
? { "aria-activedescendant": `${menuId}-${activeIndex}` }
|
|
630
|
+
: {}),
|
|
631
|
+
}
|
|
632
|
+
: {};
|
|
633
|
+
return (_jsxs("form", { ...root, "data-pmfa": "compose-box", ...(dragging ? { "data-dragging": "" } : {}), ...(recorderActive ? { "data-recording": "" } : {}), onSubmit: (event) => {
|
|
634
|
+
event.preventDefault();
|
|
635
|
+
submit();
|
|
636
|
+
}, onDragEnter: (event) => {
|
|
637
|
+
if (!attachments || !hasFiles(event))
|
|
638
|
+
return;
|
|
639
|
+
event.preventDefault();
|
|
640
|
+
setDragging(true);
|
|
641
|
+
}, onDragOver: (event) => {
|
|
642
|
+
if (!attachments || !hasFiles(event))
|
|
643
|
+
return;
|
|
644
|
+
event.preventDefault();
|
|
645
|
+
if (event.dataTransfer)
|
|
646
|
+
event.dataTransfer.dropEffect = "copy";
|
|
647
|
+
}, onDragLeave: (event) => {
|
|
648
|
+
if (!event.currentTarget.contains(event.relatedTarget))
|
|
649
|
+
setDragging(false);
|
|
650
|
+
}, onDrop: (event) => {
|
|
651
|
+
if (!attachments || !hasFiles(event))
|
|
652
|
+
return;
|
|
653
|
+
event.preventDefault();
|
|
654
|
+
setDragging(false);
|
|
655
|
+
addFiles(event.dataTransfer.files);
|
|
656
|
+
}, children: [_jsx("div", { className: "pmfa-drop-hint", "aria-hidden": "true", children: text(configuration, "composer.dropHint") }), _jsx("span", { className: "pmfa-sr", "aria-live": "polite", children: announcement }), menuOpen && (_jsxs("div", { ...slots("quickReplyMenu", "pmfa-qr"), children: [_jsx("div", { className: "pmfa-qr-title", "aria-hidden": "true", children: text(configuration, "composer.quickReplies") }), _jsx("ul", { id: menuId, role: "listbox", "aria-label": text(configuration, "composer.quickReplies"), children: options.map((option, index) => (_jsxs("li", { id: `${menuId}-${index}`, role: "option", className: "pmfa-qr-option", "aria-selected": index === activeIndex, onMouseDown: (event) => event.preventDefault(), onMouseMove: () => {
|
|
657
|
+
if (index !== activeIndex)
|
|
658
|
+
setActiveOption(index);
|
|
659
|
+
}, onClick: () => chooseOption(option), children: [_jsxs("span", { className: "pmfa-qr-head", children: [_jsx("span", { className: "pmfa-qr-shortcut", children: quickReplyLabel(option) }), option.description !== undefined && (_jsx("span", { className: "pmfa-qr-description", children: option.description }))] }), _jsx("span", { className: "pmfa-qr-text", children: option.text })] }, option.id))) })] })), snapshot.replyTo !== undefined && (_jsxs("div", { ...slots("replyBanner", "pmfa-reply-banner"), children: [_jsxs("span", { className: "pmfa-reply-body", children: [_jsx("span", { className: "pmfa-reply-name", children: text(configuration, "composer.replyingTo", {
|
|
660
|
+
name: authorName(configuration, replied),
|
|
661
|
+
}) }), replied !== undefined && (_jsx("span", { className: "pmfa-reply-text", children: snippet(configuration, replied) }))] }), _jsx("button", { type: "button", className: "pmfa-btn pmfa-btn-ghost pmfa-btn-icon", "aria-label": text(configuration, "composer.cancelReply"), onClick: () => {
|
|
662
|
+
resolved.setReplyTo();
|
|
663
|
+
inputRef.current?.focus();
|
|
664
|
+
}, children: _jsx(Icon, { name: "close" }) })] })), snapshot.attachments.length > 0 && (_jsx("ul", { className: "pmfa-chips", "aria-label": text(configuration, "composer.attachments"), children: snapshot.attachments.map((attachment) => (_jsx(AttachmentChip, { attachment: attachment, configuration: configuration, slots: slots, onRemove: remove }, attachment.id))) })), recorderActive && recording !== undefined && (_jsx(RecordingBar, { snapshot: recording, configuration: configuration, slots: slots, autoSend: voiceNoteAutoSend, onCancel: cancelRecording, onStop: stopRecording })), _jsxs("div", { ...slots("composerToolbar", "pmfa-composer-row"), hidden: recorderActive, children: [startActions !== undefined && startActions !== null && (_jsx("div", { className: "pmfa-composer-actions pmfa-composer-start", children: startActions })), emoji && (_jsx("button", { ref: emojiButtonRef, type: "button", ...slots("emojiButton", "pmfa-btn pmfa-btn-ghost pmfa-btn-icon pmfa-emoji-button"), "aria-label": text(configuration, "composer.emoji"), title: text(configuration, "composer.emoji"), "aria-haspopup": "dialog", "aria-expanded": emojiOpen, onClick: () => setEmojiOpen((open) => !open), children: _jsx(Icon, { name: "smiley" }) })), attachments && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", ...slots("composerAttach", "pmfa-btn pmfa-btn-ghost pmfa-btn-icon"), "aria-label": text(configuration, "composer.attach"), title: text(configuration, "composer.attach"), onClick: () => fileRef.current?.click(), children: _jsx(Icon, { name: "attach" }) }), _jsx("input", { ref: fileRef, type: "file", className: "pmfa-sr", tabIndex: -1, "aria-hidden": "true", accept: accept, multiple: multiple, onChange: (event) => {
|
|
665
|
+
addFiles(event.currentTarget.files);
|
|
666
|
+
event.currentTarget.value = "";
|
|
667
|
+
} })] })), _jsx("textarea", { ref: inputRef, ...inputSlot, style: {
|
|
668
|
+
...inputSlot.style,
|
|
669
|
+
"--pmfa-composer-max-rows": String(Math.max(1, maxRows)),
|
|
670
|
+
}, ...combobox, "aria-label": text(configuration, "composer.label"), rows: 1, placeholder: placeholder ?? text(configuration, "composer.placeholder"), value: snapshot.text, onChange: (event) => {
|
|
671
|
+
setRejection(undefined);
|
|
672
|
+
setCaret(event.currentTarget.selectionStart);
|
|
673
|
+
resolved.setText(event.currentTarget.value);
|
|
674
|
+
}, onSelect: (event) => setCaret(event.currentTarget.selectionStart), onPaste: (event) => {
|
|
675
|
+
const files = event.clipboardData?.files;
|
|
676
|
+
if (!attachments || files === undefined || files.length === 0)
|
|
677
|
+
return;
|
|
678
|
+
event.preventDefault();
|
|
679
|
+
addFiles(files);
|
|
680
|
+
}, onKeyDown: (event) => {
|
|
681
|
+
if (event.nativeEvent.isComposing)
|
|
682
|
+
return;
|
|
683
|
+
if (menuOpen) {
|
|
684
|
+
const option = options[activeIndex];
|
|
685
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
686
|
+
event.preventDefault();
|
|
687
|
+
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
688
|
+
setActiveOption((activeIndex + step + options.length) % options.length);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if ((event.key === "Enter" && !event.shiftKey) ||
|
|
692
|
+
event.key === "Tab") {
|
|
693
|
+
if (option === undefined)
|
|
694
|
+
return;
|
|
695
|
+
event.preventDefault();
|
|
696
|
+
chooseOption(option);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (event.key === "Escape") {
|
|
700
|
+
event.preventDefault();
|
|
701
|
+
event.stopPropagation();
|
|
702
|
+
setDismissedText(snapshot.text);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
707
|
+
event.preventDefault();
|
|
708
|
+
submit();
|
|
709
|
+
}
|
|
710
|
+
} }), endActions !== undefined && endActions !== null && (_jsx("div", { className: "pmfa-composer-actions pmfa-composer-end", children: endActions })), showMic ? (_jsx("button", { ref: micRef, type: "button", ...slots("voiceButton", "pmfa-btn pmfa-btn-ghost pmfa-btn-icon pmfa-voice"), "aria-label": text(configuration, "composer.voiceNote"), title: text(configuration, "composer.voiceNote"), "aria-busy": recording?.status === "requesting", disabled: recording?.status === "requesting", onClick: startRecording, children: _jsx(Icon, { name: "mic" }) })) : (_jsx("button", { type: "submit", ...slots("composerSend", "pmfa-btn pmfa-btn-primary pmfa-btn-icon pmfa-send"), "aria-label": snapshot.sending
|
|
711
|
+
? text(configuration, "composer.sending")
|
|
712
|
+
: text(configuration, "composer.send"), title: text(configuration, "composer.send"), disabled: !canSend, children: _jsx(Icon, { name: "sendArrow" }) }))] }), emojiOpen && emoji && !recorderActive && (_jsx(EmojiPicker, { anchor: emojiButtonRef, configuration: configuration, slots: slots, onPick: insertEmoji, onClose: closeEmoji })), error !== undefined && (_jsx("p", { ...slots("error", "pmfa-error"), role: "alert", children: error }))] }));
|
|
713
|
+
}
|
|
714
|
+
function requestAnimationFrameSafe(callback) {
|
|
715
|
+
if (typeof requestAnimationFrame === "function")
|
|
716
|
+
requestAnimationFrame(callback);
|
|
717
|
+
else
|
|
718
|
+
setTimeout(callback, 0);
|
|
719
|
+
}
|
|
720
|
+
function focusFirst(root) {
|
|
721
|
+
if (root === null)
|
|
722
|
+
return;
|
|
723
|
+
const target = root.querySelector("textarea") ??
|
|
724
|
+
root.querySelector(".pmfa-drawer-header button") ??
|
|
725
|
+
root;
|
|
726
|
+
target.focus({ preventScroll: true });
|
|
727
|
+
}
|
|
728
|
+
export function ChatDrawer({ open = true, onClose, composer, title, className, classNames, conversation, controller, createController, composerController, createComposerController, composerProps, onReply, autoFocus = false, ...viewProps }) {
|
|
729
|
+
const configuration = usePolymorfa();
|
|
730
|
+
const slots = useSlots(classNames);
|
|
731
|
+
const root = useShell(slots, "drawer", "pmfa-drawer", className);
|
|
732
|
+
const panel = useRef(null);
|
|
733
|
+
const titleId = useId();
|
|
734
|
+
const resolved = useResolvedController(controller ?? conversation, createController);
|
|
735
|
+
const composing = useOwnedController(composerController, createComposerController);
|
|
736
|
+
const latestClose = useLatest(onClose);
|
|
737
|
+
const onKeyDown = (event) => {
|
|
738
|
+
if (event.key !== "Escape" ||
|
|
739
|
+
event.defaultPrevented ||
|
|
740
|
+
event.nativeEvent.isComposing ||
|
|
741
|
+
latestClose.current === undefined)
|
|
742
|
+
return;
|
|
743
|
+
event.preventDefault();
|
|
744
|
+
latestClose.current();
|
|
745
|
+
};
|
|
746
|
+
// Focus moves when `open` becomes true after mount, or on mount with
|
|
747
|
+
// `autoFocus`. Closing returns focus to where it was.
|
|
748
|
+
const lastOpen = useRef(undefined);
|
|
749
|
+
const moveFocus = useRef(false);
|
|
750
|
+
const latestAutoFocus = useLatest(autoFocus);
|
|
751
|
+
useEffect(() => {
|
|
752
|
+
// An effect re-run with the same `open` (StrictMode, `<Activity>`) keeps
|
|
753
|
+
// the earlier decision.
|
|
754
|
+
if (lastOpen.current !== open) {
|
|
755
|
+
moveFocus.current =
|
|
756
|
+
open &&
|
|
757
|
+
(lastOpen.current === false ||
|
|
758
|
+
(lastOpen.current === undefined && latestAutoFocus.current));
|
|
759
|
+
lastOpen.current = open;
|
|
760
|
+
}
|
|
761
|
+
if (!moveFocus.current || typeof document === "undefined")
|
|
762
|
+
return;
|
|
763
|
+
const previous = document.activeElement;
|
|
764
|
+
focusFirst(panel.current);
|
|
765
|
+
return () => {
|
|
766
|
+
if (previous instanceof HTMLElement && previous.isConnected)
|
|
767
|
+
previous.focus({ preventScroll: true });
|
|
768
|
+
};
|
|
769
|
+
}, [open, latestAutoFocus]);
|
|
770
|
+
const reply = useMemo(() => onReply === undefined && composing === undefined
|
|
771
|
+
? undefined
|
|
772
|
+
: (message) => {
|
|
773
|
+
onReply?.(message);
|
|
774
|
+
if (composing !== undefined) {
|
|
775
|
+
composing.setReplyTo(message.id);
|
|
776
|
+
panel.current?.querySelector("textarea")?.focus();
|
|
777
|
+
}
|
|
778
|
+
}, [onReply, composing]);
|
|
779
|
+
if (!open)
|
|
780
|
+
return null;
|
|
781
|
+
const heading = title ?? text(configuration, "chat.title");
|
|
782
|
+
const footer = composer ??
|
|
783
|
+
(composing === undefined ? undefined : (_jsx(ComposeBox, { ...composerProps, controller: composing, conversation: resolved })));
|
|
784
|
+
return (_jsxs("aside", { ...root, ref: panel, tabIndex: -1, onKeyDown: onKeyDown, "data-pmfa": "chat-drawer", role: "dialog", "aria-modal": "false", "aria-labelledby": titleId, children: [_jsxs("header", { ...slots("drawerHeader", "pmfa-drawer-header"), children: [_jsx("h2", { ...slots("drawerTitle", "pmfa-drawer-title"), id: titleId, children: heading }), onClose !== undefined && (_jsx("button", { type: "button", ...slots("drawerClose", "pmfa-btn pmfa-btn-ghost pmfa-btn-icon"), "aria-label": text(configuration, "common.close"), onClick: onClose, children: _jsx(Icon, { name: "close" }) }))] }), _jsx(ConversationLog, { ...viewProps, ...(reply === undefined ? {} : { onReply: reply }), controller: resolved, rootProps: slots("messageList", "pmfa-list"), slots: slots }), _jsx(DrawerError, { controller: resolved, slots: slots }), footer !== undefined && (_jsx("div", { className: "pmfa-drawer-footer", children: footer }))] }));
|
|
785
|
+
}
|
|
786
|
+
function DrawerError({ controller, slots, }) {
|
|
787
|
+
const snapshot = useController(controller);
|
|
788
|
+
const configuration = usePolymorfa();
|
|
789
|
+
if (snapshot.status !== "error")
|
|
790
|
+
return null;
|
|
791
|
+
return (_jsx("p", { ...slots("error", "pmfa-error"), role: "alert", children: snapshot.error ?? text(configuration, "chat.loadError") }));
|
|
792
|
+
}
|
|
793
|
+
// ── Template builder ──────────────────────────────────────────────────
|
|
794
|
+
function Field({ label, slots, children, }) {
|
|
795
|
+
return (_jsxs("label", { ...slots("field", "pmfa-field"), children: [_jsx("span", { ...slots("label", "pmfa-label"), children: label }), children] }));
|
|
796
|
+
}
|
|
797
|
+
export function TemplateBuilder({ controller, createController, renderPreview, className, classNames, }) {
|
|
798
|
+
const resolved = useResolvedController(controller, createController);
|
|
799
|
+
const snapshot = useController(resolved);
|
|
800
|
+
const configuration = usePolymorfa();
|
|
801
|
+
const slots = useSlots(classNames);
|
|
802
|
+
const root = useShell(slots, "templateBuilder", "pmfa-tb", className);
|
|
803
|
+
const definition = snapshot.draft?.definition;
|
|
804
|
+
const busy = snapshot.status === "saving" ||
|
|
805
|
+
snapshot.status === "previewing" ||
|
|
806
|
+
snapshot.status === "submitting";
|
|
807
|
+
const unsaved = snapshot.templateId === undefined || snapshot.dirty;
|
|
808
|
+
const input = slots("input", "pmfa-input");
|
|
809
|
+
return (_jsxs("section", { ...root, "data-pmfa": "template-builder", children: [_jsx("h2", { className: "pmfa-tb-title", children: text(configuration, "templates.title") }), _jsxs("div", { className: "pmfa-tb-grid", children: [_jsxs("div", { className: "pmfa-tb-form", children: [_jsx(Field, { label: text(configuration, "templates.name"), slots: slots, children: _jsx("input", { ...input, "data-field": "name", value: snapshot.draft?.name ?? "", onChange: (event) => resolved.setName(event.currentTarget.value) }) }), definition?.header?.format === "text" && (_jsx(Field, { label: text(configuration, "templates.header"), slots: slots, children: _jsx("input", { ...input, "data-field": "header", value: definition.header.text, onChange: (event) => resolved.updateDefinition({
|
|
810
|
+
header: { format: "text", text: event.currentTarget.value },
|
|
811
|
+
}) }) })), _jsx(Field, { label: text(configuration, "templates.body"), slots: slots, children: _jsx("textarea", { ...input, rows: 4, "data-field": "body", value: definition?.body ?? "", onChange: (event) => resolved.setBody(event.currentTarget.value) }) }), definition?.footer !== undefined && (_jsx(Field, { label: text(configuration, "templates.footer"), slots: slots, children: _jsx("input", { ...input, "data-field": "footer", value: definition.footer, onChange: (event) => resolved.updateDefinition({
|
|
812
|
+
footer: event.currentTarget.value,
|
|
813
|
+
}) }) })), (definition?.variables.length ?? 0) > 0 && (_jsxs("fieldset", { ...slots("field", "pmfa-field"), children: [_jsx("legend", { children: text(configuration, "templates.variables") }), _jsx("div", { className: "pmfa-pairs", children: definition?.variables.map((variable) => (_jsxs("label", { className: "pmfa-pair", children: [_jsx("span", { children: `{{${variable.name}}}` }), _jsx("input", { ...input, "aria-label": text(configuration, "templates.variableExample", {
|
|
814
|
+
name: variable.name,
|
|
815
|
+
}), value: variable.example, onChange: (event) => resolved.setVariableExample(variable.name, event.currentTarget.value) })] }, variable.name))) })] })), (definition?.buttons?.length ?? 0) > 0 && (_jsxs("fieldset", { ...slots("field", "pmfa-field"), children: [_jsx("legend", { children: text(configuration, "templates.buttons") }), _jsx("div", { className: "pmfa-pairs", children: definition?.buttons?.map((templateButton, index) => (_jsxs("label", { className: "pmfa-pair", children: [_jsx("span", { children: templateButton.type.replace("_", " ") }), _jsx("input", { ...input, "aria-label": text(configuration, "templates.buttonText", {
|
|
816
|
+
index: String(index + 1),
|
|
817
|
+
}), value: templateButton.text ?? "", onChange: (event) => {
|
|
818
|
+
const buttons = [...(definition.buttons ?? [])];
|
|
819
|
+
const current = buttons[index];
|
|
820
|
+
if (current !== undefined)
|
|
821
|
+
buttons[index] = {
|
|
822
|
+
...current,
|
|
823
|
+
text: event.currentTarget.value,
|
|
824
|
+
};
|
|
825
|
+
resolved.updateDefinition({ buttons });
|
|
826
|
+
} })] }, `${templateButton.type}-${index}`))) })] })), (definition?.carousel?.cards.length ?? 0) > 0 && (_jsxs("fieldset", { ...slots("field", "pmfa-field"), children: [_jsx("legend", { children: text(configuration, "templates.cards") }), definition?.carousel?.cards.map((card, index) => (_jsx("textarea", { ...input, "aria-label": text(configuration, "templates.cardBody", {
|
|
827
|
+
index: String(index + 1),
|
|
828
|
+
}), value: card.body, onChange: (event) => {
|
|
829
|
+
const cards = [...(definition.carousel?.cards ?? [])];
|
|
830
|
+
const current = cards[index];
|
|
831
|
+
if (current !== undefined)
|
|
832
|
+
cards[index] = {
|
|
833
|
+
...current,
|
|
834
|
+
body: event.currentTarget.value,
|
|
835
|
+
};
|
|
836
|
+
resolved.updateDefinition({ carousel: { cards } });
|
|
837
|
+
} }, `card-${index}`)))] })), snapshot.localIssues.map((issue) => (_jsx("p", { ...slots("error", "pmfa-error"), role: "alert", children: issue.message }, `${issue.code}-${issue.path ?? ""}`))), snapshot.error !== undefined && (_jsx("p", { ...slots("error", "pmfa-error"), role: "alert", children: snapshot.error.message })), _jsxs("div", { ...slots("actions", "pmfa-actions"), children: [_jsx("button", { type: "button", ...slots("primaryButton", "pmfa-btn pmfa-btn-primary"), disabled: busy ||
|
|
838
|
+
snapshot.draft === undefined ||
|
|
839
|
+
snapshot.localIssues.length > 0, onClick: () => void resolved.save(), children: text(configuration, "templates.save") }), _jsx("button", { type: "button", ...slots("button", "pmfa-btn"), disabled: busy || unsaved, onClick: () => void resolved.refreshPreview(), children: text(configuration, "templates.preview") }), _jsx("button", { type: "button", ...slots("button", "pmfa-btn"), disabled: busy || unsaved, onClick: () => void resolved.submitToMeta(), children: text(configuration, "templates.submit") })] })] }), _jsx("div", { ...slots("preview", "pmfa-tb-aside"), children: snapshot.preview ? ((renderPreview?.(snapshot.preview.rendered) ?? (_jsx(TemplatePreviewOutput, { preview: snapshot.preview.rendered })))) : (_jsx("p", { className: "pmfa-hint", children: text(configuration, "templates.previewHint") })) })] })] }));
|
|
840
|
+
}
|
|
841
|
+
function TemplatePreviewOutput({ preview, }) {
|
|
842
|
+
return (_jsxs("output", { className: "pmfa-preview", children: [_jsxs("div", { className: "pmfa-preview-bubble", children: [preview.header?.text && (_jsx("strong", { className: "pmfa-preview-header", children: preview.header.text })), _jsx("p", { className: "pmfa-preview-body", children: preview.body }), preview.footer && (_jsx("small", { className: "pmfa-preview-footer", children: preview.footer }))] }), preview.buttons.map((templateButton, index) => (_jsx("span", { className: "pmfa-preview-button", children: templateButton.text }, `${templateButton.type}-${index}`))), preview.cards.length > 0 && (_jsx("div", { className: "pmfa-preview-cards", children: preview.cards.map((card, index) => (_jsxs("article", { className: "pmfa-preview-card", children: [_jsx("p", { className: "pmfa-preview-body", children: card.body }), card.buttons.map((templateButton, buttonIndex) => (_jsx("span", { className: "pmfa-preview-button", children: templateButton.text }, `${templateButton.type}-${buttonIndex}`)))] }, `card-${index}`))) }))] }));
|
|
843
|
+
}
|