mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
gifProvider.ts now goes through api.ts (TOFU-pinned via the Rust http proxy) instead of api.klipy.com, and the VITE_KLIPY_API_KEY path is deleted outright. The built bundle greps clean of the key name and of api.klipy.com. The klipy.com CDN allowlist stays: media URLs still load from Klipy's CDN, and the server is trusted to hold the key but not to dictate what the client renders. Degradation: on 503 GIF_DISABLED the picker shows "GIFs are not enabled on this server" and calls onUnavailable, which disables the composer's GIF button with a title/aria-label reason — mirroring the existing attach-button rule so re-enabling the composer does not resurrect it. A MessageInput with no gifApi wired renders the button disabled from the start. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
674 lines
22 KiB
TypeScript
674 lines
22 KiB
TypeScript
/**
|
|
* MessageInput component — textarea with send, reply bar, and edit mode.
|
|
* Step 5.42 of the Tauri v2 migration.
|
|
*/
|
|
|
|
import { createElement, appendChildren, setText } from "@lib/dom";
|
|
import { createIcon } from "@lib/icons";
|
|
import type { MountableComponent } from "@lib/safe-render";
|
|
import { createEmojiPicker } from "@components/EmojiPicker";
|
|
import { createGifPicker } from "@components/GifPicker";
|
|
import type { GifApi } from "@lib/gifProvider";
|
|
|
|
export interface MessageInputOptions {
|
|
readonly channelId: number;
|
|
readonly channelName: string;
|
|
/**
|
|
* GIF endpoints on the user's own server. Omit to hide the GIF affordance
|
|
* entirely — the button is rendered disabled rather than offering a picker
|
|
* that cannot load.
|
|
*/
|
|
readonly gifApi?: GifApi;
|
|
readonly onSend: (
|
|
content: string,
|
|
replyTo: number | null,
|
|
attachments: readonly string[],
|
|
) => void;
|
|
readonly onUploadFile?: (file: File) => Promise<{ id: string; url: string; filename: string }>;
|
|
readonly onTyping: () => void;
|
|
readonly onEditMessage: (messageId: number, content: string) => void;
|
|
/** Initial disabled reason (e.g. read-only / no-permission / offline). */
|
|
readonly disabledReason?: string | null;
|
|
}
|
|
|
|
export type MessageInputComponent = MountableComponent & {
|
|
setReplyTo(messageId: number, username: string): void;
|
|
clearReply(): void;
|
|
startEdit(messageId: number, content: string): void;
|
|
cancelEdit(): void;
|
|
/**
|
|
* Disable the composer with a visible reason (permission / connection), or
|
|
* pass null to re-enable. Permission is expressed as affordance: a send that
|
|
* the server would refuse is prevented here, not attempted and rejected.
|
|
*/
|
|
setDisabled(reason: string | null): void;
|
|
};
|
|
|
|
const TYPING_THROTTLE_MS = 3_000;
|
|
const MAX_TEXTAREA_HEIGHT = 200;
|
|
const SEND_DEBOUNCE_MS = 200;
|
|
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB matches server limit
|
|
const ALLOWED_TYPES = [
|
|
"image/",
|
|
"video/",
|
|
"audio/",
|
|
"application/pdf",
|
|
"text/",
|
|
"application/zip",
|
|
"application/x-zip-compressed",
|
|
"application/json",
|
|
];
|
|
|
|
/** Disable the GIF button and say why, instead of silently doing nothing. */
|
|
function markGifUnavailable(gifBtn: HTMLButtonElement, reason: string): void {
|
|
gifBtn.setAttribute("disabled", "true");
|
|
gifBtn.title = reason;
|
|
gifBtn.setAttribute("aria-label", `GIF — ${reason}`);
|
|
}
|
|
|
|
export function createMessageInput(options: MessageInputOptions): MessageInputComponent {
|
|
const ac = new AbortController();
|
|
const signal = ac.signal;
|
|
let root: HTMLDivElement | null = null;
|
|
let state = {
|
|
replyTo: null as { messageId: number; username: string } | null,
|
|
editing: null as { messageId: number } | null,
|
|
};
|
|
let lastTypingTime = 0;
|
|
let lastSendTime = 0;
|
|
|
|
let textarea: HTMLTextAreaElement | null = null;
|
|
let replyBar: HTMLDivElement | null = null;
|
|
let replyText: HTMLSpanElement | null = null;
|
|
let editBar: HTMLDivElement | null = null;
|
|
let disabledReason: string | null = options.disabledReason ?? null;
|
|
/** True once the server has told us GIFs are off, or if no GIF api was wired. */
|
|
let gifUnavailable = options.gifApi === undefined;
|
|
const controlButtons: HTMLButtonElement[] = [];
|
|
let attachmentPreviewBar: HTMLDivElement | null = null;
|
|
|
|
/** Pending attachment IDs to send with the next message. */
|
|
const pendingAttachments: { id: string; filename: string; readonly previewEl: HTMLDivElement }[] =
|
|
[];
|
|
/** Count of file uploads currently in flight. */
|
|
let pendingUploadCount = 0;
|
|
/** References to picker close functions, set by mount() for destroy() to call. */
|
|
let cleanupPickers: (() => void) | null = null;
|
|
/** Timer IDs for cleanup on destroy. */
|
|
const activeTimers: Set<ReturnType<typeof setTimeout>> = new Set();
|
|
|
|
function showReplyBar(username: string): void {
|
|
if (replyBar === null || replyText === null) return;
|
|
setText(replyText, `Replying to @${username}`);
|
|
replyBar.classList.add("visible");
|
|
}
|
|
|
|
function hideReplyBar(): void {
|
|
replyBar?.classList.remove("visible");
|
|
}
|
|
function showEditBar(): void {
|
|
editBar?.classList.add("visible");
|
|
}
|
|
function hideEditBar(): void {
|
|
editBar?.classList.remove("visible");
|
|
}
|
|
|
|
function autoResize(): void {
|
|
if (textarea === null) return;
|
|
textarea.style.height = "auto";
|
|
textarea.style.height = `${Math.min(textarea.scrollHeight, MAX_TEXTAREA_HEIGHT)}px`;
|
|
}
|
|
|
|
function maybeEmitTyping(): void {
|
|
const now = Date.now();
|
|
if (now - lastTypingTime >= TYPING_THROTTLE_MS) {
|
|
lastTypingTime = now;
|
|
options.onTyping();
|
|
}
|
|
}
|
|
|
|
function clearPendingAttachments(): void {
|
|
for (const att of pendingAttachments) {
|
|
att.previewEl.remove();
|
|
}
|
|
pendingAttachments.length = 0;
|
|
if (attachmentPreviewBar !== null) {
|
|
attachmentPreviewBar.classList.remove("visible");
|
|
}
|
|
}
|
|
|
|
function showUploadError(message: string): void {
|
|
if (attachmentPreviewBar === null) return;
|
|
const errEl = createElement(
|
|
"div",
|
|
{
|
|
class: "attachment-upload-error",
|
|
},
|
|
message,
|
|
);
|
|
attachmentPreviewBar.appendChild(errEl);
|
|
const t = setTimeout(() => {
|
|
activeTimers.delete(t);
|
|
errEl.remove();
|
|
}, 4000);
|
|
activeTimers.add(t);
|
|
}
|
|
|
|
/** Reflect the current disabledReason onto the DOM (textarea + controls). */
|
|
function applyDisabledState(): void {
|
|
if (textarea === null) return;
|
|
const disabled = disabledReason !== null;
|
|
textarea.disabled = disabled;
|
|
textarea.placeholder = disabled ? disabledReason! : `Message #${options.channelName}`;
|
|
for (const btn of controlButtons) {
|
|
if (disabled) {
|
|
btn.setAttribute("disabled", "true");
|
|
} else {
|
|
// Don't re-enable the attach button when uploads aren't wired.
|
|
if (btn.classList.contains("attach-btn") && options.onUploadFile === undefined) continue;
|
|
// Likewise for GIFs when this server has no GIF provider configured.
|
|
if (btn.classList.contains("gif-btn") && gifUnavailable) continue;
|
|
btn.removeAttribute("disabled");
|
|
}
|
|
}
|
|
if (root !== null) {
|
|
root.classList.toggle("composer-disabled", disabled);
|
|
}
|
|
}
|
|
|
|
function setDisabled(reason: string | null): void {
|
|
disabledReason = reason;
|
|
applyDisabledState();
|
|
}
|
|
|
|
function handleSend(): void {
|
|
if (disabledReason !== null) return;
|
|
if (textarea === null) return;
|
|
const content = textarea.value.trim();
|
|
const hasAttachments = pendingAttachments.length > 0;
|
|
if (content.length === 0 && !hasAttachments) return;
|
|
|
|
// Block send while uploads are still in flight
|
|
if (pendingUploadCount > 0) {
|
|
showUploadError("Please wait for uploads to finish");
|
|
return;
|
|
}
|
|
|
|
// Debounce to prevent double-click duplicate sends
|
|
const now = Date.now();
|
|
if (now - lastSendTime < SEND_DEBOUNCE_MS) return;
|
|
lastSendTime = now;
|
|
|
|
if (state.editing !== null) {
|
|
options.onEditMessage(state.editing.messageId, content);
|
|
cancelEdit();
|
|
} else {
|
|
// Only include attachments that have finished uploading (have a real server ID)
|
|
const attachmentIds = pendingAttachments
|
|
.filter((a) => !a.id.startsWith("pending-"))
|
|
.map((a) => a.id);
|
|
options.onSend(content, state.replyTo?.messageId ?? null, attachmentIds);
|
|
clearReply();
|
|
clearPendingAttachments();
|
|
}
|
|
|
|
textarea.value = "";
|
|
autoResize();
|
|
textarea.focus();
|
|
}
|
|
|
|
/** Unique counter for preview items (before upload completes and we have a server ID). */
|
|
let previewCounter = 0;
|
|
|
|
function removePreviewItem(tempId: string): void {
|
|
const idx = pendingAttachments.findIndex((a) => a.id === tempId);
|
|
const att = idx !== -1 ? pendingAttachments[idx] : undefined;
|
|
if (att !== undefined) {
|
|
const img = att.previewEl.querySelector("img");
|
|
if (img !== null && img.src.startsWith("blob:")) {
|
|
URL.revokeObjectURL(img.src);
|
|
}
|
|
att.previewEl.remove();
|
|
pendingAttachments.splice(idx, 1);
|
|
if (pendingAttachments.length === 0) {
|
|
attachmentPreviewBar?.classList.remove("visible");
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Read a File as a data: URL (more reliable than createObjectURL in WebView2). */
|
|
// oxlint-disable-next-line consistent-function-scoping -- co-located with handlePasteFile for readability
|
|
function readFileAsDataUrl(file: File): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.addEventListener("load", () => resolve(reader.result as string));
|
|
reader.addEventListener("error", () => reject(new Error("Failed to read file")));
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
async function handlePasteFile(file: File): Promise<void> {
|
|
if (options.onUploadFile === undefined || attachmentPreviewBar === null) return;
|
|
|
|
// Validate file size
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
showUploadError(`File too large: ${file.name} exceeds 100 MB limit`);
|
|
return;
|
|
}
|
|
|
|
// Validate file type — reject files with unknown/empty MIME type
|
|
if (file.type === "" || !ALLOWED_TYPES.some((t) => file.type.startsWith(t))) {
|
|
showUploadError(`${file.name} is not a supported file type`);
|
|
return;
|
|
}
|
|
|
|
const tempId = `pending-${++previewCounter}`;
|
|
const isImage = file.type.startsWith("image/");
|
|
|
|
attachmentPreviewBar.classList.add("visible");
|
|
|
|
const item = createElement("div", { class: "attachment-preview-item uploading" });
|
|
|
|
if (isImage) {
|
|
// Read file as data URL for preview (works reliably in WebView2)
|
|
const img = createElement("img", {
|
|
class: "attachment-preview-img",
|
|
alt: file.name,
|
|
});
|
|
item.appendChild(img);
|
|
readFileAsDataUrl(file)
|
|
.then((dataUrl) => {
|
|
if (signal.aborted) return;
|
|
img.src = dataUrl;
|
|
})
|
|
.catch(() => {
|
|
if (signal.aborted) return;
|
|
// Fallback: show filename
|
|
const nameEl = createElement("span", { class: "attachment-preview-name" }, file.name);
|
|
img.replaceWith(nameEl);
|
|
});
|
|
} else {
|
|
const icon = createElement("div", { class: "attachment-preview-file" });
|
|
icon.appendChild(createIcon("file-text", 16));
|
|
const nameEl = createElement("span", { class: "attachment-preview-name" }, file.name);
|
|
appendChildren(item, icon, nameEl);
|
|
}
|
|
|
|
// Loading spinner overlay
|
|
const spinner = createElement("div", { class: "attachment-preview-spinner" });
|
|
spinner.appendChild(createIcon("loader", 16));
|
|
item.appendChild(spinner);
|
|
|
|
const removeBtn = createElement("button", {
|
|
class: "attachment-preview-remove",
|
|
"data-testid": "attachment-remove",
|
|
});
|
|
removeBtn.appendChild(createIcon("x", 14));
|
|
removeBtn.addEventListener(
|
|
"click",
|
|
(e) => {
|
|
e.stopPropagation();
|
|
removePreviewItem(tempId);
|
|
},
|
|
{ signal },
|
|
);
|
|
item.appendChild(removeBtn);
|
|
|
|
attachmentPreviewBar.appendChild(item);
|
|
pendingAttachments.push({ id: tempId, filename: file.name, previewEl: item });
|
|
|
|
// Upload in background
|
|
pendingUploadCount++;
|
|
try {
|
|
const result = await options.onUploadFile(file);
|
|
// Replace temp ID with real server ID (immutable update)
|
|
const attIdx = pendingAttachments.findIndex((a) => a.id === tempId);
|
|
if (attIdx !== -1) {
|
|
pendingAttachments[attIdx] = {
|
|
...pendingAttachments[attIdx]!,
|
|
id: result.id,
|
|
filename: result.filename,
|
|
};
|
|
item.classList.remove("uploading");
|
|
spinner.remove();
|
|
}
|
|
} catch (err) {
|
|
// Upload failed — remove preview and show error
|
|
removePreviewItem(tempId);
|
|
const errMsg = err instanceof Error ? err.message : "Upload failed";
|
|
showUploadError(`Upload failed: ${errMsg}`);
|
|
} finally {
|
|
pendingUploadCount--;
|
|
}
|
|
}
|
|
|
|
function setReplyTo(messageId: number, username: string): void {
|
|
if (state.editing !== null) hideEditBar();
|
|
state = { replyTo: { messageId, username }, editing: null };
|
|
showReplyBar(username);
|
|
textarea?.focus();
|
|
}
|
|
|
|
function clearReply(): void {
|
|
state = { ...state, replyTo: null };
|
|
hideReplyBar();
|
|
}
|
|
|
|
function startEdit(messageId: number, content: string): void {
|
|
if (state.replyTo !== null) hideReplyBar();
|
|
state = { replyTo: null, editing: { messageId } };
|
|
showEditBar();
|
|
if (textarea !== null) {
|
|
textarea.value = content;
|
|
autoResize();
|
|
textarea.focus();
|
|
}
|
|
}
|
|
|
|
function cancelEdit(): void {
|
|
state = { ...state, editing: null };
|
|
hideEditBar();
|
|
if (textarea !== null) {
|
|
textarea.value = "";
|
|
autoResize();
|
|
}
|
|
}
|
|
|
|
function mount(container: Element): void {
|
|
root = createElement("div", { class: "message-input-wrap", "data-testid": "message-input" });
|
|
|
|
replyBar = createElement("div", { class: "reply-bar" });
|
|
const replyInner = createElement("div", { class: "reply-bar-inner" });
|
|
replyText = createElement("strong", {});
|
|
replyInner.appendChild(replyText);
|
|
const replyClose = createElement("button", { class: "reply-close" });
|
|
replyClose.appendChild(createIcon("x", 14));
|
|
replyClose.addEventListener("click", clearReply, { signal });
|
|
replyInner.appendChild(replyClose);
|
|
replyBar.appendChild(replyInner);
|
|
|
|
editBar = createElement("div", { class: "reply-bar" });
|
|
const editInner = createElement("div", { class: "reply-bar-inner" });
|
|
const editText = createElement("strong", {}, "Editing message");
|
|
editInner.appendChild(editText);
|
|
const editClose = createElement("button", { class: "reply-close" });
|
|
editClose.appendChild(createIcon("x", 14));
|
|
editClose.addEventListener("click", () => cancelEdit(), { signal });
|
|
editInner.appendChild(editClose);
|
|
editBar.appendChild(editInner);
|
|
|
|
attachmentPreviewBar = createElement("div", { class: "attachment-preview-bar" });
|
|
|
|
const inputBox = createElement("div", { class: "message-input-box" });
|
|
const attachBtn = createElement(
|
|
"button",
|
|
{ class: "input-btn attach-btn", "aria-label": "Attach file" },
|
|
"+",
|
|
);
|
|
|
|
// File picker via attach button
|
|
if (options.onUploadFile !== undefined) {
|
|
const fileInput = createElement("input", {
|
|
type: "file",
|
|
style: "display: none;",
|
|
accept: "image/*,video/*,audio/*,.pdf,.txt,.zip,.rar,.7z",
|
|
});
|
|
fileInput.addEventListener(
|
|
"change",
|
|
() => {
|
|
const file = fileInput.files?.[0];
|
|
if (file != null) {
|
|
void handlePasteFile(file);
|
|
}
|
|
fileInput.value = "";
|
|
},
|
|
{ signal },
|
|
);
|
|
attachBtn.addEventListener("click", () => fileInput.click(), { signal });
|
|
root?.appendChild(fileInput);
|
|
} else {
|
|
attachBtn.setAttribute("disabled", "true");
|
|
attachBtn.title = "File uploads not available";
|
|
}
|
|
textarea = createElement("textarea", {
|
|
class: "msg-textarea",
|
|
placeholder: `Message #${options.channelName}`,
|
|
rows: "1",
|
|
"data-testid": "msg-textarea",
|
|
});
|
|
const emojiBtn = createElement("button", {
|
|
class: "input-btn emoji-btn",
|
|
"aria-label": "Emoji",
|
|
});
|
|
emojiBtn.appendChild(createIcon("smile", 20));
|
|
const gifBtn = createElement(
|
|
"button",
|
|
{ class: "input-btn gif-btn", "aria-label": "GIF" },
|
|
"GIF",
|
|
);
|
|
if (gifUnavailable) {
|
|
markGifUnavailable(gifBtn, "GIFs are not enabled on this server");
|
|
}
|
|
const sendBtn = createElement("button", {
|
|
class: "input-btn send-btn",
|
|
"aria-label": "Send message",
|
|
"data-testid": "send-btn",
|
|
});
|
|
sendBtn.appendChild(createIcon("send", 20));
|
|
// Register interactive controls so the disabled state can toggle them.
|
|
controlButtons.length = 0;
|
|
controlButtons.push(sendBtn, emojiBtn, gifBtn);
|
|
if (options.onUploadFile !== undefined) {
|
|
controlButtons.push(attachBtn);
|
|
}
|
|
|
|
textarea.addEventListener(
|
|
"input",
|
|
() => {
|
|
autoResize();
|
|
maybeEmitTyping();
|
|
},
|
|
{ signal },
|
|
);
|
|
textarea.addEventListener(
|
|
"keydown",
|
|
(e: KeyboardEvent) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
if (e.key === "Escape") {
|
|
if (state.editing !== null) {
|
|
cancelEdit();
|
|
} else if (state.replyTo !== null) {
|
|
clearReply();
|
|
}
|
|
}
|
|
if (e.key === "ArrowUp" && textarea !== null && textarea.value.length === 0) {
|
|
root?.dispatchEvent(new CustomEvent("edit-last-message", { bubbles: true }));
|
|
}
|
|
},
|
|
{ signal },
|
|
);
|
|
|
|
// Clipboard paste: detect images/files
|
|
textarea.addEventListener(
|
|
"paste",
|
|
(e: ClipboardEvent) => {
|
|
const items = e.clipboardData?.items;
|
|
if (items === undefined) return;
|
|
for (const item of items) {
|
|
if (item.kind !== "file") continue;
|
|
const file = item.getAsFile();
|
|
if (file === null) continue;
|
|
e.preventDefault();
|
|
void handlePasteFile(file);
|
|
}
|
|
},
|
|
{ signal },
|
|
);
|
|
|
|
sendBtn.addEventListener("click", handleSend, { signal });
|
|
|
|
// Picker state (declared together so both toggle functions can cross-close)
|
|
let emojiPicker: { element: HTMLDivElement; destroy(): void } | null = null;
|
|
let gifPicker: { element: HTMLDivElement; destroy(): void } | null = null;
|
|
|
|
function closeEmojiPicker(): void {
|
|
if (emojiPicker !== null) {
|
|
emojiPicker.element.remove();
|
|
emojiPicker.destroy();
|
|
emojiPicker = null;
|
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
}
|
|
}
|
|
|
|
function handleClickOutside(e: MouseEvent): void {
|
|
if (emojiPicker === null) return;
|
|
const target = e.target as Node;
|
|
// Close if click is outside both the picker and the emoji button
|
|
if (
|
|
!emojiPicker.element.contains(target) &&
|
|
target !== emojiBtn &&
|
|
!emojiBtn.contains(target)
|
|
) {
|
|
closeEmojiPicker();
|
|
}
|
|
}
|
|
|
|
function toggleEmojiPicker(): void {
|
|
// Close GIF picker if open
|
|
if (gifPicker !== null) {
|
|
closeGifPicker();
|
|
}
|
|
if (emojiPicker !== null) {
|
|
closeEmojiPicker();
|
|
return;
|
|
}
|
|
emojiPicker = createEmojiPicker({
|
|
onSelect: (emoji: string) => {
|
|
if (textarea !== null) {
|
|
const start = textarea.selectionStart;
|
|
const end = textarea.selectionEnd;
|
|
const before = textarea.value.slice(0, start);
|
|
const after = textarea.value.slice(end);
|
|
textarea.value = before + emoji + after;
|
|
textarea.selectionStart = textarea.selectionEnd = start + emoji.length;
|
|
textarea.focus();
|
|
}
|
|
closeEmojiPicker();
|
|
},
|
|
onClose: () => {
|
|
closeEmojiPicker();
|
|
},
|
|
});
|
|
root?.appendChild(emojiPicker.element);
|
|
// Defer so this click doesn't immediately close it
|
|
const t1 = setTimeout(() => {
|
|
activeTimers.delete(t1);
|
|
if (!signal.aborted) {
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
}
|
|
}, 0);
|
|
activeTimers.add(t1);
|
|
}
|
|
|
|
emojiBtn.addEventListener("click", toggleEmojiPicker, { signal });
|
|
|
|
// GIF picker toggle
|
|
function closeGifPicker(): void {
|
|
if (gifPicker !== null) {
|
|
gifPicker.element.remove();
|
|
gifPicker.destroy();
|
|
gifPicker = null;
|
|
document.removeEventListener("mousedown", handleGifClickOutside);
|
|
}
|
|
}
|
|
|
|
function handleGifClickOutside(e: MouseEvent): void {
|
|
if (gifPicker === null) return;
|
|
const target = e.target as Node;
|
|
if (!gifPicker.element.contains(target) && target !== gifBtn && !gifBtn.contains(target)) {
|
|
closeGifPicker();
|
|
}
|
|
}
|
|
|
|
function toggleGifPicker(): void {
|
|
const gifApi = options.gifApi;
|
|
if (gifApi === undefined) return;
|
|
// Close emoji picker if open
|
|
if (emojiPicker !== null) {
|
|
closeEmojiPicker();
|
|
}
|
|
if (gifPicker !== null) {
|
|
closeGifPicker();
|
|
return;
|
|
}
|
|
gifPicker = createGifPicker({
|
|
api: gifApi,
|
|
onUnavailable: (reason: string) => {
|
|
gifUnavailable = true;
|
|
markGifUnavailable(gifBtn, reason);
|
|
},
|
|
onSelect: (gifUrl: string) => {
|
|
if (textarea !== null) {
|
|
textarea.value = gifUrl;
|
|
handleSend();
|
|
}
|
|
closeGifPicker();
|
|
},
|
|
onClose: () => {
|
|
closeGifPicker();
|
|
},
|
|
});
|
|
root?.appendChild(gifPicker.element);
|
|
const t2 = setTimeout(() => {
|
|
activeTimers.delete(t2);
|
|
if (!signal.aborted) {
|
|
document.addEventListener("mousedown", handleGifClickOutside);
|
|
}
|
|
}, 0);
|
|
activeTimers.add(t2);
|
|
}
|
|
|
|
gifBtn.addEventListener("click", toggleGifPicker, { signal });
|
|
|
|
// Store picker cleanup for destroy()
|
|
cleanupPickers = () => {
|
|
closeEmojiPicker();
|
|
closeGifPicker();
|
|
};
|
|
|
|
appendChildren(inputBox, attachBtn, textarea, emojiBtn, gifBtn, sendBtn);
|
|
appendChildren(root, replyBar, editBar, attachmentPreviewBar, inputBox);
|
|
container.appendChild(root);
|
|
// Apply any initial disabled reason before focusing.
|
|
applyDisabledState();
|
|
if (disabledReason === null) {
|
|
textarea.focus();
|
|
}
|
|
}
|
|
|
|
function destroy(): void {
|
|
// Close any open pickers and their document listeners before aborting
|
|
cleanupPickers?.();
|
|
cleanupPickers = null;
|
|
// Clear all pending timers
|
|
for (const t of activeTimers) clearTimeout(t);
|
|
activeTimers.clear();
|
|
ac.abort();
|
|
// Image previews now use data: URLs (via readFileAsDataUrl) which don't
|
|
// require revocation — just clear the array and let GC reclaim them.
|
|
pendingAttachments.length = 0;
|
|
root?.remove();
|
|
root = null;
|
|
textarea = null;
|
|
replyBar = null;
|
|
replyText = null;
|
|
editBar = null;
|
|
attachmentPreviewBar = null;
|
|
}
|
|
|
|
return { mount, destroy, setReplyTo, clearReply, startEdit, cancelEdit, setDisabled };
|
|
}
|