mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+60
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?.();
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user