feat: file uploads, URL previews, emoji search, voice mute fixes, UX improvements

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
This commit is contained in:
jevb
2026-03-18 14:13:52 +01:00
parent e1659fcfd2
commit 13be0fd6d3
24 changed files with 1487 additions and 60 deletions
+10
View File
@@ -12,6 +12,7 @@
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-http": "^2.5.7",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-store": "^2"
},
"devDependencies": {
@@ -1470,6 +1471,15 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
"integrity": "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-store": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.2.tgz",
+2
View File
@@ -13,6 +13,7 @@
"test:integration": "vitest run tests/integration",
"test:e2e": "playwright test",
"test:e2e:prod": "npm run build && playwright test --config playwright.config.prod.ts",
"test:e2e:native": "playwright test --config playwright.config.native.ts",
"test:e2e:ui": "playwright test --ui",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
@@ -31,6 +32,7 @@
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-http": "^2.5.7",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-store": "^2"
}
}
+60
View File
@@ -1927,6 +1927,25 @@ dependencies = [
"serde",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itoa"
version = "1.0.17"
@@ -2473,6 +2492,18 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "open"
version = "5.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
dependencies = [
"dunce",
"is-wsl",
"libc",
"pathdiff",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2503,6 +2534,7 @@ dependencies = [
"tauri-plugin-global-shortcut",
"tauri-plugin-http",
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-store",
"tokio",
"tokio-tungstenite",
@@ -2563,6 +2595,12 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -4232,6 +4270,28 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f"
dependencies = [
"dunce",
"glob",
"objc2-app-kit",
"objc2-foundation",
"open",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"url",
"windows 0.61.3",
"zbus",
]
[[package]]
name = "tauri-plugin-store"
version = "2.4.2"
+1
View File
@@ -19,6 +19,7 @@ tauri-plugin-notification = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-http = { version = "2.5.7", features = ["rustls-tls", "dangerous-settings"] }
tauri-plugin-opener = "2"
tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"] }
futures-util = "0.3.32"
tokio = { version = "1", features = ["sync"] }
@@ -62,6 +62,7 @@
}
]
},
"http:allow-fetch-cancel"
"http:allow-fetch-cancel",
"opener:default"
]
}
+1
View File
@@ -11,6 +11,7 @@ pub fn run() {
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_opener::init())
.manage(ws_proxy::WsState::new())
.invoke_handler(tauri::generate_handler![
commands::get_settings,
@@ -22,7 +22,7 @@
}
],
"security": {
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https: wss:; img-src 'self' https: data:"
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https: wss:; img-src 'self' https: data:; frame-src https://www.youtube.com https://youtube.com"
}
},
"bundle": {
@@ -168,10 +168,16 @@ function renderVoiceChannelItem(
);
row.appendChild(nameEl);
if (user.muted || user.deafened) {
const icon = user.deafened ? "\uD83D\uDD08" : "\uD83D\uDD07";
const mutedEl = createElement("span", { class: "vu-muted" }, icon);
row.appendChild(mutedEl);
if (user.deafened) {
// Deafened: show both crossed mic and crossed headphone
const muteIcon = createElement("span", { class: "vu-muted vu-icon-crossed" }, "\uD83C\uDFA4");
const deafIcon = createElement("span", { class: "vu-muted vu-icon-crossed" }, "\uD83C\uDFA7");
row.appendChild(muteIcon);
row.appendChild(deafIcon);
} else if (user.muted) {
// Muted only: show crossed mic
const muteIcon = createElement("span", { class: "vu-muted vu-icon-crossed" }, "\uD83C\uDFA4");
row.appendChild(muteIcon);
}
usersContainer.appendChild(row);
@@ -85,6 +85,80 @@ const CATEGORIES: readonly EmojiCategory[] = [
},
];
/** Emoji name lookup for search. Maps emoji character → searchable keywords. */
const EMOJI_NAMES: Readonly<Record<string, string>> = {
"😀": "grinning face happy smile", "😃": "smiley face happy smile", "😄": "smile happy grin",
"😁": "beaming grin teeth smile", "😆": "laughing happy squint smile", "😅": "sweat smile nervous",
"🤣": "rofl laughing rolling floor", "😂": "joy tears laughing cry happy", "🙂": "slightly smiling",
"😊": "blush happy smile shy", "😇": "innocent angel halo", "🥰": "love hearts face smiling",
"😍": "heart eyes love", "🤩": "star struck excited", "😘": "kiss blowing wink",
"😗": "kissing face", "😋": "yummy delicious tongue food", "😛": "tongue out",
"😜": "wink tongue playful", "🤪": "zany crazy wild", "😝": "squinting tongue",
"🤑": "money face rich dollar", "🤗": "hugging hug hands", "🤭": "hand over mouth oops giggle",
"🤫": "shushing quiet secret shh", "🤔": "thinking hmm wonder", "🤐": "zipper mouth shut secret",
"🤨": "raised eyebrow skeptical", "😐": "neutral face blank", "😑": "expressionless blank",
"😶": "no mouth silent mute", "😏": "smirk smug", "😒": "unamused bored annoyed",
"🙄": "eye roll whatever", "😬": "grimace awkward teeth", "🤥": "lying pinocchio nose",
"😌": "relieved calm peaceful", "😔": "pensive sad thoughtful", "😪": "sleepy tired",
"🤤": "drooling hungry", "😴": "sleeping zzz tired", "😷": "mask sick medical face",
"🤒": "thermometer sick fever", "🤕": "bandage hurt injured", "🤢": "nauseous sick green",
"🤮": "vomiting throw up sick", "🥵": "hot face overheated", "🥶": "cold face freezing",
"🥴": "woozy drunk dizzy", "😵": "dizzy spiral knocked out", "🤯": "mind blown exploding head",
"🤠": "cowboy hat yeehaw", "🥳": "party celebration birthday", "😎": "sunglasses cool",
"🤓": "nerd glasses geek", "🧐": "monocle detective inspect", "😕": "confused puzzled",
"😟": "worried concerned", "🙁": "frowning sad", "😮": "open mouth surprised",
"😲": "astonished shocked wow", "😳": "flushed embarrassed", "🥺": "pleading puppy eyes please",
"😢": "crying sad tear", "😭": "sobbing crying loud", "😤": "steam nose angry huffing",
"😠": "angry mad", "😡": "rage furious red", "🤬": "cursing swearing symbols angry",
"💀": "skull dead death skeleton",
"👋": "wave hello hi bye hand", "🤚": "raised back hand", "🖐": "hand fingers splayed five",
"✋": "raised hand stop high five", "🖖": "vulcan spock", "👌": "ok okay perfect",
"🤌": "pinched fingers italian", "🤏": "pinching small little", "✌️": "peace victory two",
"🤞": "crossed fingers luck hope", "🤟": "love you gesture rock",
"🤘": "rock on horns metal", "🤙": "call me hang loose shaka", "👈": "pointing left",
"👉": "pointing right", "👆": "pointing up", "👇": "pointing down", "☝️": "index pointing up",
"👍": "thumbs up like good yes", "👎": "thumbs down dislike bad no",
"✊": "raised fist power", "👊": "fist bump punch", "🤛": "left fist bump",
"🤜": "right fist bump", "👏": "clap applause bravo", "🙌": "raising hands hooray celebrate",
"👐": "open hands jazz", "🤲": "palms up together prayer", "🤝": "handshake deal agreement",
"🙏": "pray thanks please folded hands",
"🐶": "dog puppy pet", "🐱": "cat kitten pet", "🐭": "mouse rat", "🐹": "hamster",
"🐰": "rabbit bunny", "🦊": "fox", "🐻": "bear", "🐼": "panda bear",
"🐨": "koala", "🐯": "tiger", "🦁": "lion king", "🐮": "cow moo",
"🐷": "pig oink", "🐸": "frog toad", "🐵": "monkey face", "🐔": "chicken hen",
"🐧": "penguin", "🐦": "bird", "🐤": "chick baby bird", "🦄": "unicorn magic",
"🌸": "cherry blossom flower pink", "🌹": "rose flower red", "🌺": "hibiscus flower",
"🌻": "sunflower", "🌼": "blossom flower", "🌷": "tulip flower",
"🌱": "seedling sprout plant", "🌲": "evergreen tree pine", "🌳": "tree deciduous", "🍀": "four leaf clover luck",
"🍎": "red apple fruit", "🍊": "orange tangerine fruit", "🍋": "lemon fruit", "🍌": "banana fruit",
"🍉": "watermelon fruit", "🍇": "grapes fruit", "🍓": "strawberry fruit", "🍒": "cherries fruit",
"🍑": "peach fruit butt", "🍍": "pineapple fruit", "🥝": "kiwi fruit",
"🍔": "hamburger burger food", "🍟": "fries french food", "🍕": "pizza food slice",
"🌭": "hot dog food", "🍿": "popcorn snack movie", "🧀": "cheese wedge",
"🥚": "egg", "🍳": "cooking fried egg", "🥓": "bacon",
"☕": "coffee hot drink", "🍵": "tea hot drink", "🍺": "beer mug drink",
"🍻": "clinking beers cheers drink", "🥂": "champagne toast celebrate drink",
"🍷": "wine glass drink red", "🍸": "cocktail martini drink", "🍹": "tropical drink",
"🍾": "bottle popping champagne celebrate", "🧁": "cupcake dessert sweet",
"⚽": "soccer football ball sport", "🏀": "basketball ball sport", "🏈": "football american sport",
"⚾": "baseball ball sport", "🎾": "tennis ball sport", "🎮": "video game controller gaming",
"🎲": "dice game random", "🎯": "bullseye target dart", "🎵": "music note",
"🎶": "music notes", "💡": "light bulb idea", "🔥": "fire hot flame lit",
"⭐": "star yellow", "🌟": "glowing star sparkle", "💫": "dizzy star shooting",
"✨": "sparkles magic shine", "💥": "boom collision crash", "❤️": "red heart love",
"🧡": "orange heart love", "💛": "yellow heart love", "💚": "green heart love",
"💙": "blue heart love", "💜": "purple heart love", "🖤": "black heart dark love",
"🤍": "white heart love", "💯": "hundred percent perfect score", "💢": "anger symbol mad",
"💬": "speech bubble chat talk", "👁‍🗨": "eye speech bubble witness", "🗨": "speech balloon left",
"✅": "check mark yes done complete", "❌": "cross mark no wrong cancel",
"❓": "question mark red", "❗": "exclamation mark red alert", "‼️": "double exclamation",
"⁉️": "exclamation question", "💤": "sleeping zzz tired", "💮": "white flower",
"♻️": "recycle green environment", "🔰": "beginner new japanese", "⚠️": "warning caution alert",
"🚫": "prohibited forbidden no", "🔴": "red circle", "🟠": "orange circle",
"🟡": "yellow circle", "🟢": "green circle", "🔵": "blue circle",
"🟣": "purple circle", "⚫": "black circle", "⚪": "white circle",
};
const MAX_RECENT = 20;
const RECENT_KEY = "owncord:recent-emoji";
@@ -193,7 +267,14 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
if (cat.emoji.length === 0) continue;
const filtered = searchQuery
? cat.emoji.filter((e) => e.toLowerCase().includes(searchQuery.toLowerCase()))
? cat.emoji.filter((e) => {
const q = searchQuery.toLowerCase();
// Match against emoji name/keywords, or the character itself
const name = EMOJI_NAMES[e];
if (name !== undefined && name.includes(q)) return true;
// Also match custom emoji shortcodes like :wave:
return e.toLowerCase().includes(q);
})
: cat.emoji;
if (filtered.length === 0) continue;
@@ -10,7 +10,8 @@ import { createEmojiPicker } from "@components/EmojiPicker";
export interface MessageInputOptions {
readonly channelId: number;
readonly channelName: string;
readonly onSend: (content: string, replyTo: number | null) => void;
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;
}
@@ -41,6 +42,10 @@ export function createMessageInput(
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;
@@ -66,10 +71,21 @@ export function createMessageInput(
}
}
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();
if (content.length === 0) return;
const hasAttachments = pendingAttachments.length > 0;
if (content.length === 0 && !hasAttachments) return;
// Debounce to prevent double-click duplicate sends
const now = Date.now();
@@ -80,8 +96,13 @@ export function createMessageInput(
options.onEditMessage(state.editing.messageId, content);
cancelEdit();
} else {
options.onSend(content, state.replyTo?.messageId ?? null);
// 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 = "";
@@ -89,6 +110,106 @@ export function createMessageInput(
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 };
@@ -139,9 +260,32 @@ export function createMessageInput(
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", disabled: "true", title: "File uploads coming soon" }, "+");
{ 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",
@@ -159,16 +303,45 @@ export function createMessageInput(
}
}, { 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 toggleEmojiPicker(): void {
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({
@@ -182,40 +355,44 @@ export function createMessageInput(
textarea.selectionStart = textarea.selectionEnd = start + emoji.length;
textarea.focus();
}
// Close after selection
if (emojiPicker !== null) {
emojiPicker.element.remove();
emojiPicker.destroy();
emojiPicker = null;
}
closeEmojiPicker();
},
onClose: () => {
if (emojiPicker !== null) {
emojiPicker.element.remove();
emojiPicker.destroy();
emojiPicker = null;
}
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, inputBox);
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 };
@@ -315,6 +315,12 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
parentContainer.appendChild(root);
renderAll();
// Scroll to bottom on initial mount — use multiple deferred calls to handle
// layout shifts from images/embeds loading after the initial render.
scrollToBottom();
requestAnimationFrame(() => scrollToBottom());
setTimeout(() => scrollToBottom(), 100);
setTimeout(() => scrollToBottom(), 500);
unsubscribers.push(messagesStore.subscribe(() => { renderAll(); }));
@@ -8,11 +8,31 @@ import {
setText,
appendChildren,
} from "@lib/dom";
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import type { Attachment } from "@lib/types";
import type { Message } from "@stores/messages.store";
import { membersStore } from "@stores/members.store";
import type { MessageListOptions } from "../MessageList";
/** Module-level server host for resolving relative attachment URLs. */
let _serverHost: string | null = null;
/** Set the server host (called once from MainPage on connect). */
export function setServerHost(host: string): void {
_serverHost = host;
}
/** Resolve a potentially relative URL to a full URL using the server host. */
function resolveServerUrl(url: string): string {
if (url.startsWith("http://") || url.startsWith("https://")) {
return url;
}
if (_serverHost !== null) {
return `https://${_serverHost}${url}`;
}
return url;
}
// -- Constants ----------------------------------------------------------------
export const GROUP_THRESHOLD_MS = 5 * 60 * 1000;
@@ -20,6 +40,7 @@ export const GROUP_THRESHOLD_MS = 5 * 60 * 1000;
const MENTION_REGEX = /@(\w+)/g;
const CODE_BLOCK_REGEX = /```([\s\S]*?)```/g;
const INLINE_CODE_REGEX = /`([^`]+)`/g;
const URL_REGEX = /https?:\/\/[^\s<>"']+/g;
// -- Formatting helpers -------------------------------------------------------
@@ -89,6 +110,38 @@ function renderInlineContent(text: string): DocumentFragment {
}
export function renderMentions(text: string): DocumentFragment {
// First pass: split by URLs, then handle mentions in non-URL segments
const fragment = document.createDocumentFragment();
let lastIndex = 0;
for (const match of text.matchAll(URL_REGEX)) {
const idx = match.index;
if (idx === undefined) continue;
if (idx > lastIndex) {
fragment.appendChild(renderMentionSegment(text.slice(lastIndex, idx)));
}
const url = match[0];
if (isSafeUrl(url)) {
const link = createElement("a", {
class: "msg-link",
href: url,
target: "_blank",
rel: "noopener noreferrer",
});
setText(link, url);
fragment.appendChild(link);
} else {
fragment.appendChild(document.createTextNode(url));
}
lastIndex = idx + match[0].length;
}
if (lastIndex < text.length) {
fragment.appendChild(renderMentionSegment(text.slice(lastIndex)));
}
return fragment;
}
/** Render @mentions within a text segment (no URLs). */
function renderMentionSegment(text: string): DocumentFragment {
const fragment = document.createDocumentFragment();
let lastIndex = 0;
for (const match of text.matchAll(MENTION_REGEX)) {
@@ -139,6 +192,482 @@ function renderMessageContent(content: string): DocumentFragment {
return fragment;
}
// -- URL embed rendering ------------------------------------------------------
/** Extract YouTube video ID from various YouTube URL formats. */
function extractYouTubeId(url: string): string | null {
try {
const parsed = new URL(url);
// youtube.com/watch?v=ID
if (
(parsed.hostname === "www.youtube.com" || parsed.hostname === "youtube.com") &&
parsed.pathname === "/watch"
) {
return parsed.searchParams.get("v");
}
// youtu.be/ID
if (parsed.hostname === "youtu.be") {
const id = parsed.pathname.slice(1);
return id.length > 0 ? id : null;
}
// youtube.com/embed/ID
if (
(parsed.hostname === "www.youtube.com" || parsed.hostname === "youtube.com") &&
parsed.pathname.startsWith("/embed/")
) {
const id = parsed.pathname.slice(7);
return id.length > 0 ? id : null;
}
// youtube.com/shorts/ID
if (
(parsed.hostname === "www.youtube.com" || parsed.hostname === "youtube.com") &&
parsed.pathname.startsWith("/shorts/")
) {
const id = parsed.pathname.slice(8);
return id.length > 0 ? id : null;
}
} catch {
// Invalid URL
}
return null;
}
/** Cache for YouTube video titles to avoid re-fetching on every re-render. */
const ytTitleCache = new Map<string, string>();
/** Render a YouTube embed player with title header. */
function renderYouTubeEmbed(videoId: string, originalUrl: string): HTMLDivElement {
const wrap = createElement("div", { class: "msg-embed msg-embed-youtube" });
// Header: channel name + video title
const header = createElement("div", { class: "msg-embed-yt-header" });
const channelLabel = createElement("div", { class: "msg-embed-host" }, "YouTube");
const titleLink = createElement("a", {
class: "msg-embed-yt-title",
href: originalUrl,
target: "_blank",
rel: "noopener noreferrer",
});
const cached = ytTitleCache.get(videoId);
if (cached !== undefined) {
setText(titleLink, cached);
} else {
setText(titleLink, "Loading...");
const oembedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`;
fetch(oembedUrl)
.then((res) => (res.ok ? res.json() : null))
.then((data: { title?: string } | null) => {
const title = data?.title ?? "YouTube Video";
ytTitleCache.set(videoId, title);
setText(titleLink, title);
})
.catch(() => {
ytTitleCache.set(videoId, "YouTube Video");
setText(titleLink, "YouTube Video");
});
}
appendChildren(header, channelLabel, titleLink);
wrap.appendChild(header);
// Thumbnail container with play button overlay
const thumbWrap = createElement("div", { class: "msg-embed-yt-player" });
const thumbUrl = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`;
const thumb = createElement("img", {
class: "msg-embed-thumb",
src: thumbUrl,
alt: "YouTube video",
loading: "lazy",
});
const playBtn = createElement("div", { class: "msg-embed-play" }, "\u25B6");
appendChildren(thumbWrap, thumb, playBtn);
wrap.appendChild(thumbWrap);
// On click thumbnail, replace with iframe player
thumbWrap.addEventListener("click", () => {
const iframe = document.createElement("iframe");
iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1`;
iframe.setAttribute("allowfullscreen", "");
iframe.setAttribute("allow", "autoplay; encrypted-media");
iframe.className = "msg-embed-iframe";
thumbWrap.replaceChildren(iframe);
}, { once: true });
return wrap;
}
/** Extract all URLs from a message content string. */
function extractUrls(content: string): string[] {
// Skip URLs inside code blocks
const withoutCodeBlocks = content.replace(CODE_BLOCK_REGEX, "").replace(INLINE_CODE_REGEX, "");
const matches = withoutCodeBlocks.match(URL_REGEX);
return matches ?? [];
}
/** Render URL embeds (YouTube players, generic link previews). */
function renderUrlEmbeds(content: string): DocumentFragment {
const fragment = document.createDocumentFragment();
const urls = extractUrls(content);
const seen = new Set<string>();
for (const url of urls) {
if (seen.has(url)) continue;
seen.add(url);
// YouTube embed
const ytId = extractYouTubeId(url);
if (ytId !== null) {
fragment.appendChild(renderYouTubeEmbed(ytId, url));
continue;
}
// Generic URL preview (compact link card)
if (isSafeUrl(url)) {
fragment.appendChild(renderGenericLinkPreview(url));
}
}
return fragment;
}
/** Open Graph metadata extracted from a page. */
interface OgMeta {
readonly title: string | null;
readonly description: string | null;
readonly image: string | null;
readonly siteName: string | null;
}
/** Cache for OG metadata to avoid re-fetching on re-render. */
const ogCache = new Map<string, OgMeta>();
/** URLs currently being fetched (prevents duplicate requests). */
const ogInFlight = new Set<string>();
/** Extract Open Graph meta tags from raw HTML using regex (no DOM parser needed). */
function parseOgTags(html: string): OgMeta {
function getMetaContent(property: string): string | null {
// Match both property="og:X" and name="og:X" patterns
const regex = new RegExp(
`<meta[^>]*(?:property|name)=["']${property}["'][^>]*content=["']([^"']*)["']` +
`|<meta[^>]*content=["']([^"']*)["'][^>]*(?:property|name)=["']${property}["']`,
"i",
);
const match = html.match(regex);
if (match !== null) {
return match[1] ?? match[2] ?? null;
}
return null;
}
// Fallback: extract <title> tag if no og:title
function getTitle(): string | null {
const og = getMetaContent("og:title");
if (og !== null) return og;
const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i);
return titleMatch?.[1]?.trim() ?? null;
}
// Fallback: extract meta description if no og:description
function getDescription(): string | null {
const og = getMetaContent("og:description");
if (og !== null) return og;
return getMetaContent("description");
}
return {
title: getTitle(),
description: getDescription(),
image: getMetaContent("og:image"),
siteName: getMetaContent("og:site_name"),
};
}
/** Fetch OG metadata for a URL using the Tauri native HTTP client (no CORS). */
async function fetchOgMeta(url: string): Promise<OgMeta> {
const cached = ogCache.get(url);
if (cached !== undefined) return cached;
// Return empty while in-flight to avoid duplicate requests
if (ogInFlight.has(url)) {
return { title: null, description: null, image: null, siteName: null };
}
ogInFlight.add(url);
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
const res = await tauriFetch(url, {
signal: controller.signal,
headers: { "User-Agent": "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)" },
danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false },
} as RequestInit);
clearTimeout(timer);
if (!res.ok) {
const empty: OgMeta = { title: null, description: null, image: null, siteName: null };
ogCache.set(url, empty);
return empty;
}
// Only parse HTML responses (skip binary, JSON, etc.)
const contentType = res.headers.get("content-type") ?? "";
if (!contentType.includes("text/html")) {
const empty: OgMeta = { title: null, description: null, image: null, siteName: null };
ogCache.set(url, empty);
return empty;
}
const html = await res.text();
// Only parse the first 50KB to avoid parsing huge pages
const meta = parseOgTags(html.slice(0, 50_000));
ogCache.set(url, meta);
return meta;
} catch {
const empty: OgMeta = { title: null, description: null, image: null, siteName: null };
ogCache.set(url, empty);
return empty;
} finally {
ogInFlight.delete(url);
}
}
/** Render a link preview card with OG metadata (title, description, image). */
function renderGenericLinkPreview(url: string): HTMLDivElement {
const wrap = createElement("div", { class: "msg-embed msg-embed-link" });
let displayHost = "";
try {
displayHost = new URL(url).hostname;
} catch {
displayHost = url;
}
const content = createElement("div", { class: "msg-embed-link-content" });
const hostEl = createElement("div", { class: "msg-embed-host" }, displayHost);
content.appendChild(hostEl);
const titleEl = createElement("a", {
class: "msg-embed-link-title",
href: url,
target: "_blank",
rel: "noopener noreferrer",
});
content.appendChild(titleEl);
const descEl = createElement("div", { class: "msg-embed-link-desc" });
content.appendChild(descEl);
wrap.appendChild(content);
// Image container (shown if og:image exists)
const imageWrap = createElement("div", { class: "msg-embed-link-image" });
imageWrap.style.display = "none";
wrap.appendChild(imageWrap);
// Check cache first for instant render
const cached = ogCache.get(url);
if (cached !== undefined) {
applyOgMeta(cached, titleEl, descEl, hostEl, imageWrap, url, displayHost);
} else {
// Show URL as fallback title while loading
setText(titleEl, displayHost);
void fetchOgMeta(url).then((meta) => {
applyOgMeta(meta, titleEl, descEl, hostEl, imageWrap, url, displayHost);
});
}
return wrap;
}
/** Apply fetched OG metadata to the preview card elements. */
function applyOgMeta(
meta: OgMeta,
titleEl: HTMLElement,
descEl: HTMLElement,
hostEl: HTMLElement,
imageWrap: HTMLElement,
url: string,
displayHost: string,
): void {
setText(titleEl, meta.title ?? displayHost);
if (meta.siteName !== null) {
setText(hostEl, meta.siteName);
}
if (meta.description !== null) {
const desc = meta.description.length > 200
? meta.description.slice(0, 197) + "..."
: meta.description;
setText(descEl, desc);
descEl.style.display = "";
} else {
descEl.style.display = "none";
}
if (meta.image !== null && meta.image.length > 0) {
// Resolve relative image URLs
let imgSrc = meta.image;
if (imgSrc.startsWith("/")) {
try {
const base = new URL(url);
imgSrc = `${base.origin}${imgSrc}`;
} catch { /* keep as-is */ }
}
if (isSafeUrl(imgSrc)) {
const img = createElement("img", {
class: "msg-embed-link-img",
src: imgSrc,
alt: meta.title ?? "",
loading: "lazy",
});
img.addEventListener("error", () => {
imageWrap.style.display = "none";
});
imageWrap.appendChild(img);
imageWrap.style.display = "";
}
}
}
// -- Image lightbox -----------------------------------------------------------
/** Open a full-screen lightbox overlay with zoom and pan. */
function openImageLightbox(src: string, alt: string): void {
const overlay = createElement("div", { class: "image-lightbox" });
const imgWrap = createElement("div", { class: "image-lightbox-wrap" });
const img = createElement("img", { src, alt }) as HTMLImageElement;
imgWrap.appendChild(img);
overlay.appendChild(imgWrap);
const closeBtn = createElement("button", { class: "image-lightbox-close" }, "\u2715");
overlay.appendChild(closeBtn);
// Zoom & pan state
let scale = 1;
let panX = 0;
let panY = 0;
let isDragging = false;
let dragStartX = 0;
let dragStartY = 0;
let panStartX = 0;
let panStartY = 0;
function applyTransform(): void {
img.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
}
function resetZoom(): void {
scale = 1;
panX = 0;
panY = 0;
applyTransform();
}
function close(): void {
overlay.remove();
document.removeEventListener("keydown", onKey);
}
// Mouse wheel zoom
imgWrap.addEventListener("wheel", (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.15 : 0.15;
const newScale = Math.max(0.5, Math.min(10, scale + delta * scale));
// Zoom towards cursor position
const rect = img.getBoundingClientRect();
const cx = e.clientX - rect.left - rect.width / 2;
const cy = e.clientY - rect.top - rect.height / 2;
const factor = newScale / scale;
panX = panX - cx * (factor - 1);
panY = panY - cy * (factor - 1);
scale = newScale;
applyTransform();
});
// Single click to toggle zoom, with drag detection to avoid zoom on pan
let clickStartX = 0;
let clickStartY = 0;
img.addEventListener("mousedown", (e) => {
e.preventDefault();
clickStartX = e.clientX;
clickStartY = e.clientY;
if (scale > 1.1) {
// Zoomed in — start panning
isDragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
panStartX = panX;
panStartY = panY;
overlay.classList.add("dragging");
}
});
img.addEventListener("click", (e) => {
e.stopPropagation();
// Only toggle zoom if mouse didn't move (not a pan gesture)
const dx = Math.abs(e.clientX - clickStartX);
const dy = Math.abs(e.clientY - clickStartY);
if (dx > 5 || dy > 5) return;
if (scale > 1.1) {
resetZoom();
} else {
// Zoom to 3x towards click position
const rect = img.getBoundingClientRect();
const cx = e.clientX - rect.left - rect.width / 2;
const cy = e.clientY - rect.top - rect.height / 2;
scale = 3;
panX = -cx * 2;
panY = -cy * 2;
applyTransform();
}
});
document.addEventListener("mousemove", function onMove(e) {
if (!isDragging) return;
panX = panStartX + (e.clientX - dragStartX);
panY = panStartY + (e.clientY - dragStartY);
applyTransform();
});
document.addEventListener("mouseup", function onUp() {
if (isDragging) {
isDragging = false;
overlay.classList.remove("dragging");
}
});
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
close();
});
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close();
});
function onKey(e: KeyboardEvent): void {
if (e.key === "Escape") close();
if (e.key === "+" || e.key === "=") {
scale = Math.min(10, scale * 1.3);
applyTransform();
}
if (e.key === "-") {
scale = Math.max(0.5, scale / 1.3);
applyTransform();
}
if (e.key === "0") resetZoom();
}
document.addEventListener("keydown", onKey);
document.body.appendChild(overlay);
}
// -- Attachment rendering -----------------------------------------------------
function formatFileSize(bytes: number): string {
@@ -160,18 +689,165 @@ function isSafeUrl(url: string): boolean {
}
}
// ---------------------------------------------------------------------------
// Image cache: memory + IndexedDB for persistence across restarts
// ---------------------------------------------------------------------------
/** In-memory cache for instant re-render. */
const memoryCache = new Map<string, string>();
/** In-flight fetch promises to prevent duplicate concurrent requests. */
const inFlight = new Map<string, Promise<string | null>>();
/** IndexedDB database name and store. */
const IDB_NAME = "owncord-image-cache";
const IDB_STORE = "images";
const IDB_VERSION = 1;
/** Open (or create) the IndexedDB database. */
function openCacheDb(): Promise<IDBDatabase | null> {
return new Promise((resolve) => {
try {
const req = indexedDB.open(IDB_NAME, IDB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(IDB_STORE)) {
db.createObjectStore(IDB_STORE);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
} catch {
resolve(null);
}
});
}
/** Read a cached data URL from IndexedDB. */
async function idbGet(url: string): Promise<string | null> {
const db = await openCacheDb();
if (db === null) return null;
return new Promise((resolve) => {
try {
const tx = db.transaction(IDB_STORE, "readonly");
const store = tx.objectStore(IDB_STORE);
const req = store.get(url);
req.onsuccess = () => resolve(typeof req.result === "string" ? req.result : null);
req.onerror = () => resolve(null);
} catch {
resolve(null);
}
});
}
/** Write a data URL to IndexedDB. */
async function idbPut(url: string, dataUrl: string): Promise<void> {
const db = await openCacheDb();
if (db === null) return;
try {
const tx = db.transaction(IDB_STORE, "readwrite");
tx.objectStore(IDB_STORE).put(dataUrl, url);
} catch {
// IndexedDB full or unavailable — ignore
}
}
/** Convert a Uint8Array to a base64 string. */
function uint8ToBase64(bytes: Uint8Array): string {
// Process in chunks to avoid call stack overflow on large files
const CHUNK = 8192;
let binary = "";
for (let i = 0; i < bytes.length; i += CHUNK) {
const slice = bytes.subarray(i, Math.min(i + CHUNK, bytes.length));
binary += String.fromCharCode(...slice);
}
return btoa(binary);
}
/** Fetch an image and return a data: URI. Uses memory → IndexedDB → network. */
function fetchImageAsDataUrl(url: string): Promise<string | null> {
// 1. Memory cache (instant)
const cached = memoryCache.get(url);
if (cached !== undefined) return Promise.resolve(cached);
// 2. Deduplicate concurrent requests for the same URL
const existing = inFlight.get(url);
if (existing !== undefined) return existing;
const promise = (async (): Promise<string | null> => {
// 3. IndexedDB cache (persists across restarts)
const idbCached = await idbGet(url);
if (idbCached !== null) {
memoryCache.set(url, idbCached);
return idbCached;
}
// 4. Network fetch via Tauri HTTP plugin
try {
const res = await tauriFetch(url, {
danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false },
} as RequestInit);
if (!res.ok) return null;
const contentType = res.headers.get("content-type") ?? "image/png";
const buffer = await res.arrayBuffer();
const base64 = uint8ToBase64(new Uint8Array(buffer));
const dataUrl = `data:${contentType};base64,${base64}`;
// Store in both caches
memoryCache.set(url, dataUrl);
void idbPut(url, dataUrl);
return dataUrl;
} catch (err) {
console.error("Failed to fetch attachment image:", url, err);
return null;
}
})();
inFlight.set(url, promise);
void promise.finally(() => inFlight.delete(url));
return promise;
}
function renderAttachment(att: Attachment): HTMLDivElement {
if (isImageMime(att.mime) && isSafeUrl(att.url)) {
const resolvedUrl = resolveServerUrl(att.url);
if (isImageMime(att.mime) && isSafeUrl(resolvedUrl)) {
const wrap = createElement("div", { class: "msg-image" });
const img = createElement("img", {
src: att.url,
alt: att.filename,
loading: "lazy",
});
img.addEventListener("error", () => {
img.replaceWith(createElement("div", { class: "placeholder-img" }, att.filename));
});
wrap.appendChild(img);
function attachLightbox(img: HTMLImageElement): void {
img.addEventListener("click", () => {
openImageLightbox(img.src, att.filename);
});
}
// Check cache first for instant render
const cached = memoryCache.get(resolvedUrl);
if (cached !== undefined) {
const img = createElement("img", {
src: cached,
alt: att.filename,
}) as HTMLImageElement;
attachLightbox(img);
wrap.appendChild(img);
} else {
// Show loading placeholder, then replace with image
const placeholder = createElement("div", { class: "placeholder-img loading" }, att.filename);
wrap.appendChild(placeholder);
void fetchImageAsDataUrl(resolvedUrl).then((dataUrl) => {
if (dataUrl !== null) {
const img = createElement("img", {
src: dataUrl,
alt: att.filename,
}) as HTMLImageElement;
attachLightbox(img);
placeholder.replaceWith(img);
}
});
}
return wrap;
}
const wrap = createElement("div", { class: "msg-file" });
@@ -313,6 +989,12 @@ export function renderMessage(
el.appendChild(renderAttachment(att));
}
// URL embeds (YouTube players, link previews)
const embeds = renderUrlEmbeds(msg.content);
if (embeds.childNodes.length > 0) {
el.appendChild(embeds);
}
if (msg.reactions.length > 0) {
el.appendChild(renderReactions(msg, opts, signal));
}
+12 -1
View File
@@ -13,7 +13,7 @@ import type { VadDetector } from "@lib/vad";
import { createWebRtcService } from "@lib/webrtc";
import { createAudioManager } from "@lib/audio";
import { createVadDetector } from "@lib/vad";
import { setLocalMuted, setLocalDeafened, setLocalSpeaking } from "@stores/voice.store";
import { voiceStore, setLocalMuted, setLocalDeafened, setLocalSpeaking } from "@stores/voice.store";
import { loadPref } from "@components/settings/helpers";
import { createLogger } from "@lib/logger";
@@ -318,6 +318,13 @@ export function leaveVoice(sendWs = true): void {
/** Mute or unmute the local microphone. */
export function setMuted(muted: boolean): void {
setLocalMuted(muted);
// Disable tracks on the local stream directly as a fallback
if (localStream !== null) {
for (const track of localStream.getAudioTracks()) {
track.enabled = !muted;
}
}
// Also replace tracks on WebRTC senders for full RTP-level muting
if (webrtcService !== null) {
webrtcService.setMuted(muted);
}
@@ -356,6 +363,10 @@ export async function switchInputDevice(deviceId: string): Promise<void> {
// Replace in WebRTC
if (webrtcService !== null) {
webrtcService.setLocalStream(localStream);
// Re-apply mute state to new tracks
if (voiceStore.getState().localMuted) {
webrtcService.setMuted(true);
}
}
// Restart VAD on new stream
+20 -2
View File
@@ -45,6 +45,8 @@ function applyOpusBitrate(sdp: string, bitrate: number): string {
export function createWebRtcService(): WebRtcService {
let pc: RTCPeerConnection | null = null;
let localSenders: readonly RTCRtpSender[] = [];
/** Original tracks stored so we can restore them after unmute. */
const mutedTracks = new Map<RTCRtpSender, MediaStreamTrack>();
let remoteStreams: readonly MediaStream[] = [];
let opusBitrate: number | undefined;
let destroyed = false;
@@ -163,6 +165,7 @@ export function createWebRtcService(): WebRtcService {
for (const sender of localSenders) {
conn.removeTrack(sender);
}
mutedTracks.clear();
// Add all tracks from the new stream
const newSenders = stream.getTracks().map((track) => conn.addTrack(track, stream));
localSenders = newSenders;
@@ -174,8 +177,22 @@ export function createWebRtcService(): WebRtcService {
setMuted(muted: boolean): void {
for (const sender of localSenders) {
if (sender.track !== null) {
sender.track.enabled = !muted;
if (muted) {
// Store original track and replace with null to fully stop sending audio
const track = sender.track;
if (track !== null) {
track.enabled = false;
mutedTracks.set(sender, track);
void sender.replaceTrack(null);
}
} else {
// Restore the original track
const track = mutedTracks.get(sender);
if (track !== undefined) {
track.enabled = true;
void sender.replaceTrack(track);
mutedTracks.delete(sender);
}
}
}
},
@@ -206,6 +223,7 @@ export function createWebRtcService(): WebRtcService {
pc = null;
}
localSenders = [];
mutedTracks.clear();
remoteStreams = [];
iceCandidateCallbacks.clear();
remoteTrackCallbacks.clear();
+13 -2
View File
@@ -25,15 +25,26 @@ import { createCertMismatchModal } from "@components/CertMismatchModal";
import { createProfileManager, createTauriBackend } from "@lib/profiles";
import type { CertTofuEvent } from "@lib/ws";
import { openUrl } from "@tauri-apps/plugin-opener";
const log = createLogger("main");
// Disable the default browser context menu globally.
// Custom context menus (e.g. channel edit/delete) call e.preventDefault()
// themselves before the event reaches this handler.
document.addEventListener("contextmenu", (e) => {
e.preventDefault();
});
// Open external links (target="_blank") in the user's default browser.
document.addEventListener("click", (e) => {
const link = (e.target as HTMLElement).closest("a[target='_blank']") as HTMLAnchorElement | null;
if (link === null) return;
e.preventDefault();
const href = link.href;
if (href && (href.startsWith("http://") || href.startsWith("https://"))) {
void openUrl(href);
}
});
// Install global error handlers first
installGlobalErrorHandlers();
+42 -8
View File
@@ -51,6 +51,7 @@ import {
getChannelMessages,
} from "@stores/messages.store";
import { buildChatHeader } from "./main-page/ChatHeader";
import { setServerHost } from "@components/message-list/renderers";
import {
createQuickSwitcherManager,
createInviteManagerController,
@@ -78,6 +79,12 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
// Let voiceSession send signaling messages over this WS connection
setWsClient(ws);
// Set server host for resolving relative attachment URLs
const apiConfig = api.getConfig();
if (apiConfig.host) {
setServerHost(apiConfig.host);
}
const limiters = createRateLimiterSet();
let container: Element | null = null;
@@ -261,7 +268,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
messageInput = createMessageInput({
channelId,
channelName,
onSend: (content: string, replyTo: number | null) => {
onSend: (content: string, replyTo: number | null, attachments: readonly string[]) => {
if (ws.getState() !== "connected") {
log.warn("Cannot send message: not connected");
toast?.show("Not connected — message not sent", "error");
@@ -273,10 +280,14 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
channel_id: channelId,
content,
reply_to: replyTo,
attachments: [],
attachments,
},
});
},
onUploadFile: async (file: File) => {
const result = await api.uploadFile(file);
return { id: result.id, url: result.url, filename: result.filename };
},
onTyping: () => {
if (limiters.typing.tryConsume(String(channelId))) {
ws.send({
@@ -532,15 +543,38 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
},
onMuteToggle: () => {
if (!limiters.voice.tryConsume()) return;
const next = !voiceStore.getState().localMuted;
voiceSessionSetMuted(next);
ws.send({ type: "voice_mute", payload: { muted: next } });
const state = voiceStore.getState();
if (state.localMuted) {
// Unmuting: also undeafen if deafened
voiceSessionSetMuted(false);
ws.send({ type: "voice_mute", payload: { muted: false } });
if (state.localDeafened) {
voiceSessionSetDeafened(false);
ws.send({ type: "voice_deafen", payload: { deafened: false } });
}
} else {
voiceSessionSetMuted(true);
ws.send({ type: "voice_mute", payload: { muted: true } });
}
},
onDeafenToggle: () => {
if (!limiters.voice.tryConsume()) return;
const next = !voiceStore.getState().localDeafened;
voiceSessionSetDeafened(next);
ws.send({ type: "voice_deafen", payload: { deafened: next } });
const state = voiceStore.getState();
if (state.localDeafened) {
// Undeafening: also unmute mic
voiceSessionSetDeafened(false);
ws.send({ type: "voice_deafen", payload: { deafened: false } });
voiceSessionSetMuted(false);
ws.send({ type: "voice_mute", payload: { muted: false } });
} else {
// Deafening: also mute mic
voiceSessionSetDeafened(true);
ws.send({ type: "voice_deafen", payload: { deafened: true } });
if (!state.localMuted) {
voiceSessionSetMuted(true);
ws.send({ type: "voice_mute", payload: { muted: true } });
}
}
},
onCameraToggle: () => {
if (!limiters.voiceVideo.tryConsume()) return;
@@ -111,13 +111,14 @@ export function addMessage(payload: ChatMessagePayload): void {
});
}
/** Bulk set messages from a REST response. Marks channel as loaded. */
/** Bulk set messages from a REST response. Marks channel as loaded.
* The server returns messages newest-first; we reverse to chronological order. */
export function setMessages(
channelId: number,
messages: readonly MessageResponse[],
hasMore: boolean,
): void {
const converted = messages.map(messageResponseToMessage);
const converted = messages.map(messageResponseToMessage).reverse();
messagesStore.setState((prev) => {
const updatedMessages = new Map(prev.messagesByChannel);
updatedMessages.set(channelId, converted);
@@ -137,13 +138,14 @@ export function setMessages(
});
}
/** Prepend older messages for infinite scroll. */
/** Prepend older messages for infinite scroll.
* The server returns messages newest-first; we reverse to chronological order. */
export function prependMessages(
channelId: number,
messages: readonly MessageResponse[],
hasMore: boolean,
): void {
const converted = messages.map(messageResponseToMessage);
const converted = messages.map(messageResponseToMessage).reverse();
messagesStore.setState((prev) => {
const existing = prev.messagesByChannel.get(channelId) ?? [];
const updatedMessages = new Map(prev.messagesByChannel);
+169 -4
View File
@@ -120,7 +120,18 @@
font-size: 9px; font-weight: 700; color: white; flex-shrink: 0;
}
.voice-user-item.speaking .vu-avatar { box-shadow: 0 0 0 2px var(--green); }
.voice-user-item .vu-muted { color: var(--red); font-size: 12px; margin-left: auto; }
.voice-user-item .vu-muted { color: var(--red); font-size: 12px; margin-left: 2px; }
.voice-user-item .vu-muted:first-of-type { margin-left: auto; }
.vu-icon-crossed {
position: relative; opacity: .9;
}
.vu-icon-crossed::after {
content: ""; position: absolute;
top: 50%; left: -1px; right: -1px;
height: 2px; background: var(--red);
transform: rotate(-45deg);
border-radius: 1px;
}
/* Voice widget (above user bar, when connected) */
.voice-widget {
@@ -160,9 +171,9 @@
width: 12px; height: 12px; border-radius: var(--radius-circle);
border: 3px solid rgba(17,18,20,.6);
}
.user-bar .ub-info { flex: 1; min-width: 0; }
.user-bar .ub-name { font-size: 13px; font-weight: 600; color: white; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user-bar .ub-status { font-size: 11px; color: var(--text-muted); }
.user-bar .ub-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.user-bar .ub-name { font-size: 13px; font-weight: 600; color: white; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; line-height: 1.2; }
.user-bar .ub-status { font-size: 11px; color: var(--text-muted); line-height: 1.2; }
.user-bar .ub-controls { display: flex; gap: 2px; }
.user-bar .ub-controls button {
width: 32px; height: 32px; border-radius: var(--radius-sm);
@@ -287,6 +298,79 @@
.msg-reply-ref .rr-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.msg-reply-ref:hover .rr-text { color: var(--text-normal); }
/* URL links in messages */
.msg-link { color: var(--text-link, #00aff4); text-decoration: none; }
.msg-link:hover { text-decoration: underline; }
/* URL embeds */
.msg-embed {
margin-top: 8px; border-left: 4px solid var(--accent);
border-radius: var(--radius-sm); background: var(--bg-secondary);
overflow: hidden; max-width: 420px;
}
.msg-embed-youtube {
width: 420px; max-width: 100%;
}
.msg-embed-yt-header { padding: 10px 12px 6px; }
.msg-embed-yt-title {
font-size: 14px; font-weight: 600;
color: var(--text-link, #00aff4); text-decoration: none;
display: block; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
.msg-embed-yt-title:hover { text-decoration: underline; }
.msg-embed-yt-player {
position: relative; cursor: pointer;
}
.msg-embed-thumb {
display: block; width: 100%; height: auto;
}
.msg-embed-play {
position: absolute; top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 48px; height: 48px; border-radius: var(--radius-circle);
background: rgba(0,0,0,.7); color: white;
display: flex; align-items: center; justify-content: center;
font-size: 20px; pointer-events: none;
transition: background .15s;
}
.msg-embed-yt-player:hover .msg-embed-play { background: var(--red); }
.msg-embed-iframe {
width: 100%; height: 236px;
border: none;
}
.msg-embed-link {
display: flex; flex-direction: column;
padding: 12px 16px; max-width: 520px;
}
.msg-embed-link-content { min-width: 0; }
.msg-embed-host {
font-size: 12px; font-weight: 600; color: var(--text-faint);
margin-bottom: 4px;
}
.msg-embed-link-title {
font-size: 16px; font-weight: 600;
color: var(--text-link, #00aff4); text-decoration: none;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; line-height: 1.3;
}
.msg-embed-link-title:hover { text-decoration: underline; }
.msg-embed-link-desc {
font-size: 14px; color: var(--text-muted); margin-top: 6px;
line-height: 1.45; overflow: hidden;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
}
.msg-embed-link-image { margin-top: 10px; }
.msg-embed-link-img {
max-width: 100%; max-height: 300px; object-fit: cover;
border-radius: var(--radius-sm);
}
.msg-embed-url {
font-size: 13px; color: var(--text-link, #00aff4);
text-decoration: none; word-break: break-all;
}
.msg-embed-url:hover { text-decoration: underline; }
/* Reactions */
.msg-reactions { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; }
.reaction-chip {
@@ -317,12 +401,45 @@
margin-top: 4px; max-width: 400px; border-radius: var(--radius-md);
overflow: hidden; cursor: pointer;
}
.msg-image img {
display: block; max-width: 100%; max-height: 350px;
object-fit: contain; border-radius: var(--radius-md);
}
.msg-image .placeholder-img {
width: 100%; height: 200px;
background: linear-gradient(135deg, #1a1a2e, #16213e, #0f3460);
display: flex; align-items: center; justify-content: center;
color: var(--text-micro); font-size: 12px;
}
.msg-image .placeholder-img.loading { opacity: .6; }
/* Image lightbox overlay */
.image-lightbox {
position: fixed; inset: 0; z-index: 600;
background: rgba(0,0,0,.85);
display: flex; align-items: center; justify-content: center;
cursor: zoom-in; animation: fadeIn .2s ease;
}
.image-lightbox.dragging { cursor: grabbing; }
.image-lightbox-wrap {
display: flex; align-items: center; justify-content: center;
overflow: visible;
}
.image-lightbox img {
max-width: 90vw; max-height: 90vh;
object-fit: contain; border-radius: var(--radius-sm);
box-shadow: 0 8px 48px rgba(0,0,0,.5);
transition: transform .1s ease;
cursor: zoom-in; user-select: none;
}
.image-lightbox-close {
position: absolute; top: 16px; right: 16px;
width: 36px; height: 36px; border-radius: var(--radius-circle);
background: rgba(0,0,0,.6); color: white; border: none;
font-size: 20px; cursor: pointer; display: flex;
align-items: center; justify-content: center; z-index: 1;
}
.image-lightbox-close:hover { background: rgba(255,255,255,.2); }
/* File attachment */
.msg-file {
@@ -410,6 +527,54 @@
/* Message input */
.message-input-wrap { padding: 0 16px 20px; flex-shrink: 0; position: relative; }
.message-input-wrap.reply-active { padding-top: 0; }
/* Attachment preview bar (above input box) */
.attachment-preview-bar {
display: none; gap: 8px; padding: 8px 8px 4px;
background: var(--bg-input); border-radius: var(--radius-md) var(--radius-md) 0 0;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.attachment-preview-bar.visible { display: flex; }
.attachment-preview-item {
position: relative; border-radius: var(--radius-sm);
overflow: hidden; background: var(--bg-secondary);
}
.attachment-preview-img {
display: block; max-width: 120px; max-height: 120px;
object-fit: cover; border-radius: var(--radius-sm);
}
.attachment-preview-file {
display: flex; align-items: center; gap: 6px;
padding: 8px 12px; font-size: 20px;
}
.attachment-preview-name {
font-size: 12px; color: var(--text-muted); max-width: 80px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.attachment-preview-remove {
position: absolute; top: 2px; right: 2px;
width: 20px; height: 20px; border-radius: var(--radius-circle);
background: rgba(0,0,0,.7); color: white;
font-size: 14px; display: flex; align-items: center;
justify-content: center; cursor: pointer; border: none;
transition: background .15s;
}
.attachment-preview-remove:hover { background: rgba(255,255,255,.2); }
.attachment-preview-item.uploading { opacity: .6; }
.attachment-preview-spinner {
position: absolute; top: 50%; left: 50%;
transform: translate(-50%, -50%);
font-size: 18px; animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: translate(-50%, -50%) rotate(360deg); } }
.attachment-upload-error {
padding: 6px 10px; font-size: 12px; color: var(--red);
background: rgba(237,66,69,.1); border-radius: var(--radius-sm);
}
.attachment-preview-bar.visible + .message-input-box {
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
.message-input-box {
background: var(--bg-input); border-radius: var(--radius-md);
display: flex; align-items: flex-end; padding: 4px;
+1 -1
View File
@@ -526,7 +526,7 @@ describe("MessageInput", () => {
const textarea = container.querySelector(".msg-textarea") as HTMLTextAreaElement;
textarea.value = "Hello world";
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
expect(onSend).toHaveBeenCalledWith("Hello world", null);
expect(onSend).toHaveBeenCalledWith("Hello world", null, []);
input.destroy?.();
});
@@ -68,7 +68,7 @@ describe("MessageInput", () => {
const sendBtn = container.querySelector(".send-btn") as HTMLButtonElement;
sendBtn.click();
expect(opts.onSend).toHaveBeenCalledWith("Hello world", null);
expect(opts.onSend).toHaveBeenCalledWith("Hello world", null, []);
comp.destroy?.();
});
@@ -85,7 +85,7 @@ describe("MessageInput", () => {
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
expect(opts.onSend).toHaveBeenCalledWith("Enter message", null);
expect(opts.onSend).toHaveBeenCalledWith("Enter message", null, []);
comp.destroy?.();
});
@@ -220,7 +220,7 @@ describe("MessageInput", () => {
const attachBtn = container.querySelector(".attach-btn") as HTMLButtonElement;
expect(attachBtn).not.toBeNull();
expect(attachBtn.disabled).toBe(true);
expect(attachBtn.title).toBe("File uploads coming soon");
expect(attachBtn.title).toBe("File uploads not available");
comp.destroy?.();
});
+9
View File
@@ -13,6 +13,7 @@ import (
"github.com/owncord/server/auth"
"github.com/owncord/server/config"
"github.com/owncord/server/db"
"github.com/owncord/server/storage"
"github.com/owncord/server/updater"
"github.com/owncord/server/ws"
)
@@ -53,6 +54,14 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string) http.Handler {
// Channel and message REST routes.
MountChannelRoutes(r, database)
// File upload and serving routes.
store, storeErr := storage.New(cfg.Upload.StorageDir, cfg.Upload.MaxSizeMB)
if storeErr != nil {
slog.Error("failed to create file storage", "error", storeErr)
} else {
MountUploadRoutes(r, database, store)
}
// Voice credentials REST route.
MountVoiceRoutes(r, cfg, database)
+138
View File
@@ -0,0 +1,138 @@
package api
import (
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/owncord/server/db"
"github.com/owncord/server/storage"
)
// uploadResponse is the JSON shape returned by POST /api/v1/uploads.
type uploadResponse struct {
ID string `json:"id"`
Filename string `json:"filename"`
Size int64 `json:"size"`
Mime string `json:"mime"`
URL string `json:"url"`
}
// MountUploadRoutes registers upload and file-serving endpoints.
func MountUploadRoutes(r chi.Router, database *db.DB, store *storage.Storage) {
// Upload requires authentication and a higher body size limit (100 MB).
r.With(
AuthMiddleware(database),
MaxBodySize(100<<20),
).Post("/api/v1/uploads", handleUpload(database, store))
// File serving is public (URLs are unguessable UUIDs).
r.Get("/api/v1/files/{id}", handleServeFile(database, store))
}
func handleUpload(database *db.DB, store *storage.Storage) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Parse multipart form — 10 MB in memory, rest on disk.
if err := r.ParseMultipartForm(10 << 20); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "BAD_REQUEST",
"message": "invalid multipart form",
})
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "BAD_REQUEST",
"message": "missing file field",
})
return
}
defer file.Close() //nolint:errcheck
// Generate UUID for storage.
fileID := uuid.New().String()
// Detect MIME type from the Content-Type header (set by the browser).
mime := header.Header.Get("Content-Type")
if mime == "" {
mime = "application/octet-stream"
}
// Strip parameters (e.g., "image/png; charset=utf-8" → "image/png").
if idx := strings.Index(mime, ";"); idx != -1 {
mime = strings.TrimSpace(mime[:idx])
}
// Store file on disk (validates file type via magic bytes).
if err := store.Save(fileID, file); err != nil {
slog.Warn("file upload rejected", "error", err)
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "BAD_REQUEST",
"message": fmt.Sprintf("upload rejected: %s", err),
})
return
}
// Insert attachment record in DB (unlinked — message_id is NULL).
if err := database.CreateAttachment(fileID, header.Filename, fileID, mime, header.Size); err != nil {
// Clean up stored file on DB failure.
_ = store.Delete(fileID)
slog.Error("failed to create attachment record", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "INTERNAL_ERROR",
"message": "failed to save attachment",
})
return
}
slog.Info("file uploaded", "id", fileID, "filename", header.Filename, "size", header.Size, "mime", mime)
writeJSON(w, http.StatusCreated, uploadResponse{
ID: fileID,
Filename: header.Filename,
Size: header.Size,
Mime: mime,
URL: "/api/v1/files/" + fileID,
})
}
}
func handleServeFile(database *db.DB, store *storage.Storage) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fileID := chi.URLParam(r, "id")
if fileID == "" {
http.NotFound(w, r)
return
}
// Look up attachment metadata.
att, err := database.GetAttachmentByID(fileID)
if err != nil {
http.NotFound(w, r)
return
}
// Open file from storage.
f, err := store.Open(att.StoredAs)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close() //nolint:errcheck
// Set headers before ServeContent to ensure correct MIME type.
w.Header().Set("Content-Type", att.MimeType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, att.Filename))
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
// CORS: allow webview to read the response body.
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Expose-Headers", "Content-Type, Content-Length")
modTime := time.Now()
http.ServeContent(w, r, att.Filename, modTime, f)
}
}
+12
View File
@@ -16,6 +16,18 @@ type Attachment struct {
UploadedAt string
}
// CreateAttachment inserts a new attachment record (initially unlinked to any message).
func (d *DB) CreateAttachment(id, filename, storedAs, mimeType string, size int64) error {
_, err := d.sqlDB.Exec(
`INSERT INTO attachments (id, filename, stored_as, mime_type, size) VALUES (?, ?, ?, ?, ?)`,
id, filename, storedAs, mimeType, size,
)
if err != nil {
return fmt.Errorf("CreateAttachment: %w", err)
}
return nil
}
// GetAttachmentByID returns the attachment with the given ID, or nil if not found.
func (d *DB) GetAttachmentByID(id string) (*Attachment, error) {
row := d.sqlDB.QueryRow(
+1 -1
View File
@@ -171,7 +171,7 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) {
// Sanitize and validate content length.
content := sanitizer.Sanitize(p.Content)
if content == "" {
if content == "" && len(p.Attachments) == 0 {
c.sendMsg(buildErrorMsg("BAD_REQUEST", "message content cannot be empty"))
return
}