Files
OwnCord/Client/tauri-client/src/components/MessageInput.ts
T
J3vbandClaude Fable 5 89401c64a6 feat(client): call own server for GIFs, drop VITE_KLIPY_API_KEY
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>
2026-07-20 13:29:57 +02:00

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 };
}