mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Server:
- Add POST /api/v1/uploads and GET /api/v1/files/{id} endpoints
- Add CreateAttachment DB method for file upload records
- Allow empty message content when attachments are present
- CORS headers on file serving for WebView2 compatibility
Client — File uploads & attachments:
- Clipboard paste (Ctrl+V) and attach button (+) for file uploads
- Preview bar above input with thumbnail, spinner, and remove button
- Images fetched via Tauri HTTP plugin as base64 data URIs (bypasses
self-signed cert rejection in WebView2)
- Three-layer image cache: memory → IndexedDB → network
- In-flight deduplication prevents duplicate concurrent fetches
- Image lightbox with click-to-zoom, scroll wheel zoom, pan, keyboard shortcuts
Client — URL previews & embeds:
- URLs in messages rendered as clickable links
- YouTube embeds with thumbnail, play button, video title via oEmbed API
- Generic link previews with OG metadata (title, description, image)
- Fetched via Tauri HTTP plugin with Facebook crawler User-Agent
- YouTube title cache and OG metadata cache prevent re-fetch on re-render
- Links open in default browser via tauri-plugin-opener
Client — Voice & audio fixes:
- Mute uses replaceTrack(null) for reliable RTP-level muting in WebView2
- Deafen also mutes mic; undeafen/unmute unmutes both
- Muted users show crossed mic icon, deafened show crossed mic + headphone
- Re-apply mute state after input device switch
Client — UX improvements:
- Disable browser context menu globally (only custom menus show)
- Emoji search now matches by keyword names (smile, heart, fire, etc.)
- Emoji picker closes on click outside
- User bar status text moved below username
- Messages sorted chronologically (oldest first, newest at bottom)
- Scroll to bottom on initial load with deferred retries for layout shifts
- Image attachments constrained to 400x350px with click-to-lightbox
400 lines
14 KiB
TypeScript
400 lines
14 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 type { MountableComponent } from "@lib/safe-render";
|
|
import { createEmojiPicker } from "@components/EmojiPicker";
|
|
|
|
export interface MessageInputOptions {
|
|
readonly channelId: number;
|
|
readonly channelName: string;
|
|
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;
|
|
}
|
|
|
|
export type MessageInputComponent = MountableComponent & {
|
|
setReplyTo(messageId: number, username: string): void;
|
|
clearReply(): void;
|
|
startEdit(messageId: number, content: string): void;
|
|
cancelEdit(): void;
|
|
};
|
|
|
|
const TYPING_THROTTLE_MS = 3_000;
|
|
const MAX_TEXTAREA_HEIGHT = 200;
|
|
const SEND_DEBOUNCE_MS = 200;
|
|
|
|
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 attachmentPreviewBar: HTMLDivElement | null = null;
|
|
|
|
/** Pending attachment IDs to send with the next message. */
|
|
const pendingAttachments: { id: string; filename: string; readonly previewEl: HTMLDivElement }[] = [];
|
|
|
|
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 handleSend(): void {
|
|
if (textarea === null) return;
|
|
const content = textarea.value.trim();
|
|
const hasAttachments = pendingAttachments.length > 0;
|
|
if (content.length === 0 && !hasAttachments) 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). */
|
|
function readFileAsDataUrl(file: File): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(reader.result as string);
|
|
reader.onerror = () => reject(new Error("Failed to read file"));
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
async function handlePasteFile(file: File): Promise<void> {
|
|
if (options.onUploadFile === undefined || attachmentPreviewBar === null) 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,
|
|
}) as HTMLImageElement;
|
|
item.appendChild(img);
|
|
readFileAsDataUrl(file).then((dataUrl) => {
|
|
img.src = dataUrl;
|
|
}).catch(() => {
|
|
// 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" }, "\uD83D\uDCC4");
|
|
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" }, "\u23F3");
|
|
item.appendChild(spinner);
|
|
|
|
const removeBtn = createElement("button", {
|
|
class: "attachment-preview-remove",
|
|
"data-testid": "attachment-remove",
|
|
}, "\u00D7");
|
|
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
|
|
try {
|
|
const result = await options.onUploadFile(file);
|
|
// Replace temp ID with real server ID
|
|
const att = pendingAttachments.find((a) => a.id === tempId);
|
|
if (att !== undefined) {
|
|
att.id = result.id;
|
|
att.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";
|
|
// Show error inline since we may not have toast access here
|
|
const errEl = createElement("div", {
|
|
class: "attachment-upload-error",
|
|
}, `Upload failed: ${errMsg}`);
|
|
attachmentPreviewBar.appendChild(errEl);
|
|
setTimeout(() => errEl.remove(), 4000);
|
|
}
|
|
}
|
|
|
|
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" }, "\u00D7");
|
|
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" }, "\u00D7");
|
|
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",
|
|
}) as HTMLInputElement;
|
|
fileInput.addEventListener("change", () => {
|
|
const file = fileInput.files?.[0];
|
|
if (file !== undefined) {
|
|
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" }, "\uD83D\uDE00");
|
|
const sendBtn = createElement("button",
|
|
{ class: "input-btn send-btn", "aria-label": "Send message", "data-testid": "send-btn" }, "\u27A4");
|
|
|
|
textarea.addEventListener("input", () => { autoResize(); maybeEmitTyping(); }, { signal });
|
|
textarea.addEventListener("keydown", (e: KeyboardEvent) => {
|
|
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
|
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 });
|
|
|
|
// Emoji picker toggle
|
|
let emojiPicker: { 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 {
|
|
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
|
|
setTimeout(() => {
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
}, 0);
|
|
}
|
|
|
|
emojiBtn.addEventListener("click", toggleEmojiPicker, { signal });
|
|
|
|
appendChildren(inputBox, attachBtn, textarea, emojiBtn, sendBtn);
|
|
appendChildren(root, replyBar, editBar, attachmentPreviewBar, inputBox);
|
|
container.appendChild(root);
|
|
textarea.focus();
|
|
}
|
|
|
|
function destroy(): void {
|
|
ac.abort();
|
|
// Revoke any blob URLs for image previews
|
|
for (const att of pendingAttachments) {
|
|
const img = att.previewEl.querySelector("img");
|
|
if (img !== null && img.src.startsWith("blob:")) {
|
|
URL.revokeObjectURL(img.src);
|
|
}
|
|
}
|
|
pendingAttachments.length = 0;
|
|
root?.remove();
|
|
root = null;
|
|
textarea = null;
|
|
replyBar = null;
|
|
replyText = null;
|
|
editBar = null;
|
|
attachmentPreviewBar = null;
|
|
}
|
|
|
|
return { mount, destroy, setReplyTo, clearReply, startEdit, cancelEdit };
|
|
}
|