diff --git a/Client/tauri-client/src-tauri/Cargo.toml b/Client/tauri-client/src-tauri/Cargo.toml index 55581d4f..efce9405 100644 --- a/Client/tauri-client/src-tauri/Cargo.toml +++ b/Client/tauri-client/src-tauri/Cargo.toml @@ -32,4 +32,4 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" ring = "0.17" [target.'cfg(windows)'.dependencies] -windows = { version = "0.58", features = ["Win32_Security_Credentials", "Win32_Foundation"] } +windows = { version = "0.58", features = ["Win32_Security_Credentials", "Win32_Foundation", "Win32_UI_Input_KeyboardAndMouse"] } diff --git a/Client/tauri-client/src-tauri/src/lib.rs b/Client/tauri-client/src-tauri/src/lib.rs index 1e446051..f20f033d 100644 --- a/Client/tauri-client/src-tauri/src/lib.rs +++ b/Client/tauri-client/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod commands; mod credentials; mod hotkeys; +mod ptt; mod tray; mod update_commands; mod ws_proxy; @@ -32,6 +33,11 @@ pub fn run() { credentials::delete_credential, update_commands::check_client_update, update_commands::download_and_install_update, + ptt::ptt_start, + ptt::ptt_stop, + ptt::ptt_set_key, + ptt::ptt_get_key, + ptt::ptt_listen_for_key, ]) .setup(|app| { tray::create_tray(app.handle())?; diff --git a/Client/tauri-client/src-tauri/src/ptt.rs b/Client/tauri-client/src-tauri/src/ptt.rs new file mode 100644 index 00000000..3beba6f4 --- /dev/null +++ b/Client/tauri-client/src-tauri/src/ptt.rs @@ -0,0 +1,91 @@ +//! Push-to-Talk via GetAsyncKeyState polling. +//! +//! Uses a 20ms polling loop to detect key press/release without consuming +//! the keystroke — other applications and the chat input continue to +//! receive the key normally. + +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; +use std::time::Duration; +use tauri::{AppHandle, Emitter, Runtime}; + +/// Virtual key code for the PTT key. 0 = disabled. +static PTT_VKEY: AtomicI32 = AtomicI32::new(0); +/// Whether the polling loop is running. +static PTT_RUNNING: AtomicBool = AtomicBool::new(false); + +/// Check if a virtual key is currently held down (non-consuming). +#[cfg(windows)] +fn is_key_down(vk: i32) -> bool { + let state = + unsafe { windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState(vk) }; + (state as u16 & 0x8000) != 0 +} + +#[cfg(not(windows))] +fn is_key_down(_vk: i32) -> bool { + false +} + +/// Start the PTT polling loop. Emits `ptt-state` (bool) events. +#[tauri::command] +pub fn ptt_start(app: AppHandle) { + if PTT_RUNNING.swap(true, Ordering::SeqCst) { + return; // already running + } + + std::thread::spawn(move || { + let mut was_pressed = false; + + while PTT_RUNNING.load(Ordering::SeqCst) { + let vk = PTT_VKEY.load(Ordering::SeqCst); + if vk != 0 { + let pressed = is_key_down(vk); + if pressed != was_pressed { + was_pressed = pressed; + let _ = app.emit("ptt-state", pressed); + } + } + std::thread::sleep(Duration::from_millis(20)); + } + }); +} + +/// Stop the PTT polling loop. +#[tauri::command] +pub fn ptt_stop() { + PTT_RUNNING.store(false, Ordering::SeqCst); +} + +/// Set the PTT virtual key code. Pass 0 to disable. +#[tauri::command] +pub fn ptt_set_key(vk_code: i32) { + PTT_VKEY.store(vk_code, Ordering::SeqCst); +} + +/// Get the current PTT virtual key code. +#[tauri::command] +pub fn ptt_get_key() -> i32 { + PTT_VKEY.load(Ordering::SeqCst) +} + +/// Wait for the user to press any non-modifier key and return its VK code. +/// Used by the keybind capture UI. Blocks until a key is pressed and released. +#[tauri::command] +pub fn ptt_listen_for_key() -> i32 { + loop { + for vk in 1..=254i32 { + // Skip modifier keys + if matches!(vk, 0x10 | 0x11 | 0x12 | 0x5B | 0x5C) { + continue; + } + if is_key_down(vk) { + // Wait for release + while is_key_down(vk) { + std::thread::sleep(Duration::from_millis(20)); + } + return vk; + } + } + std::thread::sleep(Duration::from_millis(20)); + } +} diff --git a/Client/tauri-client/src/components/GifPicker.ts b/Client/tauri-client/src/components/GifPicker.ts new file mode 100644 index 00000000..39d3db47 --- /dev/null +++ b/Client/tauri-client/src/components/GifPicker.ts @@ -0,0 +1,164 @@ +// GifPicker — searchable GIF selector powered by Tenor API. +// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content. + +import { createElement, setText, appendChildren, clearChildren } from "@lib/dom"; +import { searchGifs, getTrendingGifs } from "@lib/tenor"; +import type { TenorGif } from "@lib/tenor"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface GifPickerOptions { + readonly onSelect: (gifUrl: string) => void; + readonly onClose: () => void; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEBOUNCE_MS = 300; +const GIF_LIMIT = 20; + +// --------------------------------------------------------------------------- +// GifPicker +// --------------------------------------------------------------------------- + +export function createGifPicker(options: GifPickerOptions): { + readonly element: HTMLDivElement; + destroy(): void; +} { + const abortController = new AbortController(); + const signal = abortController.signal; + + let debounceTimer: ReturnType | null = null; + let currentRequestId = 0; + + // ── DOM structure ── + const root = createElement("div", { class: "gif-picker open" }); + + // Header with search + const header = createElement("div", { class: "gp-header" }); + const searchInput = createElement("input", { + class: "gp-search", + type: "text", + placeholder: "Search Tenor", + }); + header.appendChild(searchInput); + + // Attribution + const attribution = createElement("div", { class: "gp-attribution" }); + setText(attribution, "Powered by Tenor"); + header.appendChild(attribution); + + root.appendChild(header); + + // Grid area (scrollable) + const gridArea = createElement("div", { class: "gp-grid-area" }); + root.appendChild(gridArea); + + // Loading indicator + const loadingEl = createElement("div", { class: "gp-loading" }); + setText(loadingEl, "Loading..."); + + // Empty state + const emptyEl = createElement("div", { class: "gp-empty" }); + setText(emptyEl, "No GIFs found"); + + // ── Rendering ── + + function renderGifs(gifs: readonly TenorGif[]): void { + clearChildren(gridArea); + + if (gifs.length === 0) { + gridArea.appendChild(emptyEl); + return; + } + + const grid = createElement("div", { class: "gp-grid" }); + + for (const gif of gifs) { + const item = createElement("div", { class: "gp-item" }); + const img = createElement("img", { + class: "gp-img", + src: gif.url, + alt: gif.title || "GIF", + loading: "lazy", + }); + item.appendChild(img); + + item.addEventListener("click", () => { + options.onSelect(gif.fullUrl); + options.onClose(); + }, { signal }); + + grid.appendChild(item); + } + + gridArea.appendChild(grid); + } + + function showLoading(): void { + clearChildren(gridArea); + gridArea.appendChild(loadingEl); + } + + async function loadGifs(query: string): Promise { + const requestId = ++currentRequestId; + showLoading(); + + try { + const gifs = query.length > 0 + ? await searchGifs(query, GIF_LIMIT) + : await getTrendingGifs(GIF_LIMIT); + + // Only render if this is still the latest request + if (requestId === currentRequestId) { + renderGifs(gifs); + } + } catch (err) { + if (requestId === currentRequestId) { + clearChildren(gridArea); + const errEl = createElement("div", { class: "gp-empty" }); + const msg = err instanceof Error ? err.message : "Failed to load GIFs"; + setText(errEl, msg); + gridArea.appendChild(errEl); + } + } + } + + // ── Event handlers ── + + searchInput.addEventListener("input", () => { + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + } + debounceTimer = setTimeout(() => { + void loadGifs(searchInput.value.trim()); + }, DEBOUNCE_MS); + }, { signal }); + + root.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + options.onClose(); + } + }, { signal }); + + // Focus search on mount + requestAnimationFrame(() => searchInput.focus()); + + // Load trending on init + void loadGifs(""); + + // ── Cleanup ── + + function destroy(): void { + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + } + abortController.abort(); + } + + return { element: root, destroy }; +} diff --git a/Client/tauri-client/src/components/MessageInput.ts b/Client/tauri-client/src/components/MessageInput.ts index 2168ff7f..7845e3a6 100644 --- a/Client/tauri-client/src/components/MessageInput.ts +++ b/Client/tauri-client/src/components/MessageInput.ts @@ -6,6 +6,7 @@ import { createElement, appendChildren, setText } from "@lib/dom"; import type { MountableComponent } from "@lib/safe-render"; import { createEmojiPicker } from "@components/EmojiPicker"; +import { createGifPicker } from "@components/GifPicker"; export interface MessageInputOptions { readonly channelId: number; @@ -292,6 +293,8 @@ export function createMessageInput( }); const emojiBtn = createElement("button", { class: "input-btn emoji-btn", "aria-label": "Emoji" }, "\uD83D\uDE00"); + const gifBtn = createElement("button", + { class: "input-btn gif-btn", "aria-label": "GIF" }, "GIF"); const sendBtn = createElement("button", { class: "input-btn send-btn", "aria-label": "Send message", "data-testid": "send-btn" }, "\u27A4"); @@ -318,8 +321,9 @@ export function createMessageInput( sendBtn.addEventListener("click", handleSend, { signal }); - // Emoji picker toggle + // Picker state (declared together so both toggle functions can cross-close) let emojiPicker: { element: HTMLDivElement; destroy(): void } | null = null; + let gifPicker: { element: HTMLDivElement; destroy(): void } | null = null; function closeEmojiPicker(): void { if (emojiPicker !== null) { @@ -340,6 +344,10 @@ export function createMessageInput( } function toggleEmojiPicker(): void { + // Close GIF picker if open + if (gifPicker !== null) { + closeGifPicker(); + } if (emojiPicker !== null) { closeEmojiPicker(); return; @@ -370,7 +378,54 @@ export function createMessageInput( emojiBtn.addEventListener("click", toggleEmojiPicker, { signal }); - appendChildren(inputBox, attachBtn, textarea, emojiBtn, sendBtn); + // GIF picker toggle + function closeGifPicker(): void { + if (gifPicker !== null) { + gifPicker.element.remove(); + gifPicker.destroy(); + gifPicker = null; + document.removeEventListener("mousedown", handleGifClickOutside); + } + } + + function handleGifClickOutside(e: MouseEvent): void { + if (gifPicker === null) return; + const target = e.target as Node; + if (!gifPicker.element.contains(target) && target !== gifBtn && !gifBtn.contains(target)) { + closeGifPicker(); + } + } + + function toggleGifPicker(): void { + // Close emoji picker if open + if (emojiPicker !== null) { + closeEmojiPicker(); + } + if (gifPicker !== null) { + closeGifPicker(); + return; + } + gifPicker = createGifPicker({ + onSelect: (gifUrl: string) => { + if (textarea !== null) { + textarea.value = gifUrl; + handleSend(); + } + closeGifPicker(); + }, + onClose: () => { + closeGifPicker(); + }, + }); + root?.appendChild(gifPicker.element); + setTimeout(() => { + document.addEventListener("mousedown", handleGifClickOutside); + }, 0); + } + + gifBtn.addEventListener("click", toggleGifPicker, { signal }); + + appendChildren(inputBox, attachBtn, textarea, emojiBtn, gifBtn, sendBtn); appendChildren(root, replyBar, editBar, attachmentPreviewBar, inputBox); container.appendChild(root); textarea.focus(); diff --git a/Client/tauri-client/src/components/MessageList.ts b/Client/tauri-client/src/components/MessageList.ts index 0887acdb..ca07ce49 100644 --- a/Client/tauri-client/src/components/MessageList.ts +++ b/Client/tauri-client/src/components/MessageList.ts @@ -33,7 +33,7 @@ const SCROLL_TOP_THRESHOLD = 50; const SCROLL_BOTTOM_THRESHOLD = 100; /** Number of items to render beyond visible viewport in each direction. */ -const OVERSCAN = 10; +const OVERSCAN = 20; /** Estimated pixel height per row (message or day divider) for initial layout. */ const ESTIMATED_ROW_HEIGHT = 52; @@ -236,6 +236,37 @@ export function createMessageList(options: MessageListOptions): MessageListCompo virtualItems = buildVirtualItems(allMessages); } + /** Render all items temporarily to measure their actual heights, then + * restore the normal virtual window. This eliminates the first-scroll + * jump caused by estimated heights differing from measured ones. */ + function premeasureAll(): void { + if (contentContainer === null || virtualItems.length === 0) return; + clearChildren(contentContainer); + const fragment = document.createDocumentFragment(); + for (let i = 0; i < virtualItems.length; i++) { + const item = virtualItems[i]!; + if (item.kind === "divider") { + fragment.appendChild(renderDayDivider(item.timestamp)); + } else { + fragment.appendChild( + renderMessage(item.message, item.isGrouped, allMessages, options, ac.signal), + ); + } + } + contentContainer.appendChild(fragment); + // Measure all + const children = contentContainer.children; + for (let i = 0; i < children.length; i++) { + const h = (children[i] as HTMLElement).offsetHeight; + if (h > 0) heightCache.set(itemKey(i), h); + } + // Restore virtual window + renderedStart = -1; + renderedEnd = -1; + clearChildren(contentContainer); + renderWindow(); + } + function renderAll(): void { if (root === null) return; wasAtBottom = isNearBottom(); @@ -302,25 +333,49 @@ export function createMessageList(options: MessageListOptions): MessageListCompo topSpacer = createElement("div", { class: "virtual-spacer-top" }); contentContainer = createElement("div", { class: "virtual-content" }); bottomSpacer = createElement("div", { class: "virtual-spacer-bottom" }); + const scrollAnchor = createElement("div", { class: "scroll-anchor" }); root.appendChild(topSpacer); root.appendChild(contentContainer); root.appendChild(bottomSpacer); + root.appendChild(scrollAnchor); root.addEventListener("scroll", handleScroll, { signal: ac.signal, passive: true, }); + // Watch for height changes in rendered items (images loading, embeds expanding). + // Re-measure heights and update spacers. The CSS scroll-anchor element handles + // pin-to-bottom automatically; for "scrolled up" we preserve distance-from-bottom. + const resizeObserver = new ResizeObserver(() => { + if (root === null || contentContainer === null) return; + // Capture scroll position relative to the bottom (stable reference point) + const distFromBottom = root.scrollHeight - root.scrollTop - root.clientHeight; + measureRendered(); + // Update spacer heights with new measurements + if (topSpacer !== null) topSpacer.style.height = `${offsetBefore(renderedStart)}px`; + if (bottomSpacer !== null) { + let bh = 0; + for (let i = renderedEnd; i < virtualItems.length; i++) bh += getItemHeight(i); + bottomSpacer.style.height = `${bh}px`; + } + // Restore scroll position (distance from bottom stays the same) + if (distFromBottom > SCROLL_BOTTOM_THRESHOLD) { + root.scrollTop = root.scrollHeight - root.clientHeight - distFromBottom; + } + }); + resizeObserver.observe(contentContainer); + ac.signal.addEventListener("abort", () => resizeObserver.disconnect()); + 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. + // Pre-measure all items to warm the height cache so scrolling up + // doesn't cause jumps from estimate→measured height corrections. + premeasureAll(); scrollToBottom(); requestAnimationFrame(() => scrollToBottom()); - setTimeout(() => scrollToBottom(), 100); - setTimeout(() => scrollToBottom(), 500); unsubscribers.push(messagesStore.subscribe(() => { renderAll(); })); diff --git a/Client/tauri-client/src/components/SettingsOverlay.ts b/Client/tauri-client/src/components/SettingsOverlay.ts index af46ee1e..22074e17 100644 --- a/Client/tauri-client/src/components/SettingsOverlay.ts +++ b/Client/tauri-client/src/components/SettingsOverlay.ts @@ -12,7 +12,7 @@ import type { ThemeName } from "./settings/helpers"; import { buildAccountTab } from "./settings/AccountTab"; import { buildAppearanceTab } from "./settings/AppearanceTab"; import { buildNotificationsTab } from "./settings/NotificationsTab"; -import { buildVoiceAudioTab } from "./settings/VoiceAudioTab"; +import { createVoiceAudioTab } from "./settings/VoiceAudioTab"; import { buildKeybindsTab } from "./settings/KeybindsTab"; import { createLogsTab } from "./settings/LogsTab"; @@ -72,8 +72,9 @@ export function createSettingsOverlay( const tabButtons = new Map(); let unsubUi: (() => void) | null = null; - // Logs tab has stateful cleanup needs — create once via factory + // Stateful tabs — create via factory for proper cleanup on tab switch const logsTab = createLogsTab(() => activeTab, ac.signal); + const voiceTab = createVoiceAudioTab(ac.signal); // ---- Tab content builders ------------------------------------------------- @@ -81,8 +82,8 @@ export function createSettingsOverlay( Account: () => buildAccountTab(options, ac.signal), Appearance: () => buildAppearanceTab(ac.signal), Notifications: () => buildNotificationsTab(ac.signal), - "Voice & Audio": () => buildVoiceAudioTab(ac.signal), - Keybinds: () => buildKeybindsTab(), + "Voice & Audio": () => voiceTab.build(), + Keybinds: () => buildKeybindsTab(ac.signal), Logs: () => logsTab.build(), }; @@ -97,6 +98,8 @@ export function createSettingsOverlay( function setActiveTab(tab: TabName): void { if (tab === activeTab) return; + // Clean up stateful tabs when switching away + if (activeTab === "Voice & Audio") voiceTab.cleanup(); activeTab = tab; for (const [name, btn] of tabButtons) { btn.classList.toggle("active", name === tab); @@ -173,6 +176,7 @@ export function createSettingsOverlay( unsubUi = null; } logsTab.cleanup(); + voiceTab.cleanup(); tabButtons.clear(); if (root !== null) { root.remove(); diff --git a/Client/tauri-client/src/components/message-list/renderers.ts b/Client/tauri-client/src/components/message-list/renderers.ts index d78560be..458e128a 100644 --- a/Client/tauri-client/src/components/message-list/renderers.ts +++ b/Client/tauri-client/src/components/message-list/renderers.ts @@ -46,13 +46,24 @@ const URL_REGEX = /https?:\/\/[^\s<>"']+/g; // -- Formatting helpers ------------------------------------------------------- +/** Parse a timestamp string, appending 'Z' if no timezone info is present + * so that UTC timestamps from SQLite are correctly interpreted. */ +function parseTimestamp(raw: string): Date { + // SQLite datetime('now') produces "2026-03-19 08:29:41" (UTC, no suffix). + // If there's no Z, +, or T with offset, treat as UTC by appending Z. + if (!raw.endsWith("Z") && !raw.includes("+") && !/T\d{2}:\d{2}:\d{2}[+-]/.test(raw)) { + return new Date(raw.replace(" ", "T") + "Z"); + } + return new Date(raw); +} + export function formatTime(iso: string): string { - const d = new Date(iso); + const d = parseTimestamp(iso); return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; } export function formatFullDate(iso: string): string { - return new Date(iso).toLocaleDateString("en-US", { + return parseTimestamp(iso).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", @@ -60,8 +71,8 @@ export function formatFullDate(iso: string): string { } export function isSameDay(a: string, b: string): boolean { - const da = new Date(a); - const db = new Date(b); + const da = parseTimestamp(a); + const db = parseTimestamp(b); return ( da.getFullYear() === db.getFullYear() && da.getMonth() === db.getMonth() && @@ -72,7 +83,7 @@ export function isSameDay(a: string, b: string): boolean { export function shouldGroup(prev: Message, curr: Message): boolean { if (prev.user.id !== curr.user.id) return false; if (prev.deleted || curr.deleted) return false; - const dt = new Date(curr.timestamp).getTime() - new Date(prev.timestamp).getTime(); + const dt = parseTimestamp(curr.timestamp).getTime() - parseTimestamp(prev.timestamp).getTime(); return dt < GROUP_THRESHOLD_MS; } @@ -309,6 +320,54 @@ function extractUrls(content: string): string[] { return matches ?? []; } +/** Check if a URL points directly to an image or GIF file. */ +function isDirectImageUrl(url: string): boolean { + try { + const pathname = new URL(url).pathname.toLowerCase(); + return /\.(gif|png|jpg|jpeg|webp)$/.test(pathname); + } catch { + return false; + } +} + +/** Render a direct image/GIF URL as an inline image with lightbox. */ +function renderInlineImage(url: string): HTMLDivElement { + const wrap = createElement("div", { + class: "msg-image", + style: "max-width: 400px; contain: layout;", + }); + const img = createElement("img", { + src: url, + alt: "Image", + loading: "lazy", + style: "max-width: 100%; max-height: 350px; display: block; border-radius: 4px; cursor: pointer;", + }) as unknown as HTMLImageElement; + + img.addEventListener("click", () => { + const lightbox = createElement("div", { class: "image-lightbox" }); + const lbWrap = createElement("div", { class: "image-lightbox-wrap" }); + const lbImg = createElement("img", { src: url, alt: "Image" }) as unknown as HTMLImageElement; + const closeBtn = createElement("button", { class: "image-lightbox-close" }, "\u00D7"); + + lbWrap.appendChild(lbImg); + lightbox.appendChild(lbWrap); + lightbox.appendChild(closeBtn); + document.body.appendChild(lightbox); + + const closeLightbox = (): void => { lightbox.remove(); }; + closeBtn.addEventListener("click", closeLightbox); + lightbox.addEventListener("click", (e) => { + if (e.target === lightbox) closeLightbox(); + }); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") closeLightbox(); + }, { once: true }); + }); + + wrap.appendChild(img); + return wrap; +} + /** Render URL embeds (YouTube players, generic link previews). */ function renderUrlEmbeds(content: string): DocumentFragment { const fragment = document.createDocumentFragment(); @@ -326,6 +385,12 @@ function renderUrlEmbeds(content: string): DocumentFragment { continue; } + // Direct image/GIF URL — render inline + if (isDirectImageUrl(url) && isSafeUrl(url)) { + fragment.appendChild(renderInlineImage(url)); + continue; + } + // Generic URL preview (compact link card) if (isSafeUrl(url)) { fragment.appendChild(renderGenericLinkPreview(url)); diff --git a/Client/tauri-client/src/components/settings/AccountTab.ts b/Client/tauri-client/src/components/settings/AccountTab.ts index 3d247c49..9da55dbf 100644 --- a/Client/tauri-client/src/components/settings/AccountTab.ts +++ b/Client/tauri-client/src/components/settings/AccountTab.ts @@ -44,12 +44,18 @@ export function buildAccountTab( editForm.style.display = "none"; }, { signal }); + const usernameError = createElement("div", { style: "color:var(--red);font-size:13px;margin-top:4px" }); + editForm.appendChild(usernameError); + saveBtn.addEventListener("click", () => { const newName = editInput.value.trim(); if (newName.length > 0) { + setText(usernameError, ""); void options.onUpdateProfile(newName).then(() => { setText(usernameValue, newName); editForm.style.display = "none"; + }).catch((err: unknown) => { + setText(usernameError, err instanceof Error ? err.message : "Failed to update username."); }); } }, { signal }); @@ -82,6 +88,11 @@ export function buildAccountTab( oldPw.value = ""; newPw.value = ""; confirmPw.value = ""; + pwError.style.color = "var(--green)"; + setText(pwError, "Password changed successfully."); + setTimeout(() => { setText(pwError, ""); pwError.style.color = "var(--red)"; }, 3000); + }).catch((err: unknown) => { + setText(pwError, err instanceof Error ? err.message : "Failed to change password."); }); }, { signal }); diff --git a/Client/tauri-client/src/components/settings/KeybindsTab.ts b/Client/tauri-client/src/components/settings/KeybindsTab.ts index 16cca4b0..dce88c49 100644 --- a/Client/tauri-client/src/components/settings/KeybindsTab.ts +++ b/Client/tauri-client/src/components/settings/KeybindsTab.ts @@ -1,21 +1,74 @@ /** - * Keybinds settings tab — push-to-talk and quick switcher bindings. + * Keybinds settings tab — push-to-talk key capture and quick switcher display. + * PTT uses Rust-side GetAsyncKeyState polling so the key is NOT hijacked. */ -import { createElement, appendChildren } from "@lib/dom"; +import { createElement, appendChildren, setText } from "@lib/dom"; import { loadPref } from "./helpers"; +import { updatePttKey, captureKeyPress, vkName } from "@lib/ptt"; -export function buildKeybindsTab(): HTMLDivElement { +export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement { const section = createElement("div", { class: "settings-pane active" }); const header = createElement("h1", {}, "Keybinds"); section.appendChild(header); + // ── Push to Talk ────────────────────────────────────────── const pttRow = createElement("div", { class: "keybind-row" }); const pttLabel = createElement("span", { class: "setting-label" }, "Push to Talk"); - const pttValue = createElement("span", { class: "kbd" }, loadPref("pttKey", "Not set")); - appendChildren(pttRow, pttLabel, pttValue); + const savedVk = loadPref("pttVk", 0); + const pttValue = createElement("span", { + class: "kbd", + style: "cursor: pointer; min-width: 80px; text-align: center;", + title: "Click to set keybind", + }, savedVk !== 0 ? vkName(savedVk) : "Not set"); + const pttClear = createElement("button", { + class: "ac-btn", + style: `margin-left: 8px; font-size: 12px; padding: 4px 10px; ${savedVk !== 0 ? "" : "display: none;"}`, + }, "Clear"); + + let capturing = false; + + pttValue.addEventListener("click", () => { + if (capturing) return; + capturing = true; + pttValue.textContent = "Press any key..."; + pttValue.style.borderColor = "var(--accent)"; + pttValue.style.color = "var(--accent)"; + + // Use Rust-side key detection (supports mouse buttons, works globally) + void captureKeyPress().then((vk) => { + capturing = false; + pttValue.style.borderColor = ""; + pttValue.style.color = ""; + setText(pttValue, vkName(vk)); + pttClear.style.display = ""; + void updatePttKey(vk); + }).catch(() => { + // Fallback: capture via JS keydown (dev mode without Tauri) + capturing = false; + pttValue.style.borderColor = ""; + pttValue.style.color = ""; + setText(pttValue, savedVk !== 0 ? vkName(savedVk) : "Not set"); + }); + }, { signal }); + + pttClear.addEventListener("click", (e) => { + e.stopPropagation(); + setText(pttValue, "Not set"); + pttClear.style.display = "none"; + void updatePttKey(0); + }, { signal }); + + appendChildren(pttRow, pttLabel, pttValue, pttClear); section.appendChild(pttRow); + // PTT hint + const pttHint = createElement("div", { + style: "font-size: 11px; color: var(--text-micro); margin: 4px 0 16px 0; line-height: 1.4;", + }, "PTT works globally and does not hijack the key \u2014 you can still type and use other apps normally. Mouse buttons (Mouse 4/5) also work."); + section.appendChild(pttHint); + + // ── Quick Switcher ──────────────────────────────────────── const searchRow = createElement("div", { class: "keybind-row" }); const searchLabel = createElement("span", { class: "setting-label" }, "Quick Switcher"); const searchValue = createElement("span", { class: "kbd" }, "Ctrl + K"); diff --git a/Client/tauri-client/src/components/settings/VoiceAudioTab.ts b/Client/tauri-client/src/components/settings/VoiceAudioTab.ts index 1fbfa90f..ecc6a2d3 100644 --- a/Client/tauri-client/src/components/settings/VoiceAudioTab.ts +++ b/Client/tauri-client/src/components/settings/VoiceAudioTab.ts @@ -7,7 +7,48 @@ import { loadPref, savePref } from "./helpers"; import { switchInputDevice, switchOutputDevice, setVoiceSensitivity, updateSilenceSuppressionPref } from "@lib/voiceSession"; import { sensitivityToThreshold } from "@lib/vad"; -export function buildVoiceAudioTab(signal: AbortSignal): HTMLDivElement { +export interface VoiceAudioTabHandle { + build(): HTMLDivElement; + cleanup(): void; +} + +export function createVoiceAudioTab(signal: AbortSignal): VoiceAudioTabHandle { + let micStream: MediaStream | null = null; + let micAudioCtx: AudioContext | null = null; + let micAnimFrame: number | null = null; + + function cleanupMic(): void { + if (micAnimFrame !== null) { cancelAnimationFrame(micAnimFrame); micAnimFrame = null; } + if (micStream !== null) { + for (const track of micStream.getTracks()) track.stop(); + micStream = null; + } + if (micAudioCtx !== null) { void micAudioCtx.close(); micAudioCtx = null; } + } + + function build(): HTMLDivElement { + // Clean up any previous mic stream before rebuilding + cleanupMic(); + return buildVoiceAudioTabInner(signal, (stream, ctx, frame) => { + micStream = stream; + micAudioCtx = ctx; + micAnimFrame = frame; + }); + } + + function cleanup(): void { + cleanupMic(); + } + + // Also clean up on overlay close + signal.addEventListener("abort", cleanupMic); + + return { build, cleanup }; +} + +type MicRegistrar = (stream: MediaStream, ctx: AudioContext, frame: number) => void; + +function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar): HTMLDivElement { const section = createElement("div", { class: "settings-pane active" }); const header = createElement("h1", {}, "Voice & Audio"); section.appendChild(header); @@ -121,11 +162,6 @@ export function buildVoiceAudioTab(signal: AbortSignal): HTMLDivElement { section.appendChild(sensitivityRow); // Start mic level monitoring for visual feedback - let micStream: MediaStream | null = null; - let micAudioCtx: AudioContext | null = null; - let micAnalyser: AnalyserNode | null = null; - let micAnimFrame: number | null = null; - void (async () => { try { const savedDevice = loadPref("audioInputDevice", ""); @@ -133,19 +169,19 @@ export function buildVoiceAudioTab(signal: AbortSignal): HTMLDivElement { audio: savedDevice ? { deviceId: { exact: savedDevice } } : true, video: false, }; - micStream = await navigator.mediaDevices.getUserMedia(constraints); - micAudioCtx = new AudioContext(); - micAnalyser = micAudioCtx.createAnalyser(); - micAnalyser.fftSize = 256; - micAnalyser.smoothingTimeConstant = 0.5; - const source = micAudioCtx.createMediaStreamSource(micStream); - source.connect(micAnalyser); + const stream = await navigator.mediaDevices.getUserMedia(constraints); + const audioCtx = new AudioContext(); + const analyser = audioCtx.createAnalyser(); + analyser.fftSize = 256; + analyser.smoothingTimeConstant = 0.5; + const source = audioCtx.createMediaStreamSource(stream); + source.connect(analyser); - const dataArray = new Uint8Array(micAnalyser.frequencyBinCount); + const dataArray = new Uint8Array(analyser.frequencyBinCount); function updateMeter(): void { - if (micAnalyser === null || signal.aborted) return; - micAnalyser.getByteFrequencyData(dataArray); + if (signal.aborted) return; + analyser.getByteFrequencyData(dataArray); // Compute RMS normalized to 0-1 let sum = 0; for (let i = 0; i < dataArray.length; i++) { @@ -159,30 +195,22 @@ export function buildVoiceAudioTab(signal: AbortSignal): HTMLDivElement { // Color: green if above threshold, yellow/red if below const threshold = sensitivityToThreshold(Number(sensitivitySlider.value)); - const normalizedRms = rms; - if (normalizedRms >= threshold) { + if (rms >= threshold) { meterLevel.style.background = "#43b581"; // green — voice detected } else { meterLevel.style.background = "#faa61a"; // yellow — below threshold } - micAnimFrame = requestAnimationFrame(updateMeter); + const frame = requestAnimationFrame(updateMeter); + registerMic(stream, audioCtx, frame); } - micAnimFrame = requestAnimationFrame(updateMeter); + const firstFrame = requestAnimationFrame(updateMeter); + registerMic(stream, audioCtx, firstFrame); } catch { // Mic access denied or unavailable — meter stays empty } })(); - // Cleanup mic monitoring when settings tab is closed - signal.addEventListener("abort", () => { - if (micAnimFrame !== null) cancelAnimationFrame(micAnimFrame); - if (micStream !== null) { - for (const track of micStream.getTracks()) track.stop(); - } - if (micAudioCtx !== null) void micAudioCtx.close(); - }); - // ── Audio processing toggles ────────────────────────────────────── const audioToggles: ReadonlyArray<{ key: string; label: string; desc: string; fallback: boolean }> = [ { key: "echoCancellation", label: "Echo Cancellation", desc: "Reduce echo from speakers feeding back into microphone", fallback: true }, diff --git a/Client/tauri-client/src/lib/dispatcher.ts b/Client/tauri-client/src/lib/dispatcher.ts index 293c318b..72e8b5cd 100644 --- a/Client/tauri-client/src/lib/dispatcher.ts +++ b/Client/tauri-client/src/lib/dispatcher.ts @@ -43,6 +43,7 @@ import { handleServerAnswer, handleServerIce, } from "@lib/voiceSession"; +import { notifyIncomingMessage } from "./notifications"; import { createLogger } from "./logger"; const log = createLogger("dispatcher"); @@ -120,6 +121,8 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup { if (payload.channel_id !== activeId) { incrementUnread(payload.channel_id); } + // Fire desktop notification, taskbar flash, and sound + notifyIncomingMessage(payload); }), ); diff --git a/Client/tauri-client/src/lib/notifications.ts b/Client/tauri-client/src/lib/notifications.ts new file mode 100644 index 00000000..b25ae409 --- /dev/null +++ b/Client/tauri-client/src/lib/notifications.ts @@ -0,0 +1,148 @@ +/** + * Notification service — fires desktop notifications, flashes taskbar, + * and plays sounds for incoming messages based on user preferences. + */ + +import { loadPref } from "@components/settings/helpers"; +import { authStore } from "@stores/auth.store"; +import { channelsStore } from "@stores/channels.store"; +import type { ChatMessagePayload } from "./types"; +import { createLogger } from "./logger"; + +const log = createLogger("notifications"); + +/** Check if the app window is currently focused. */ +function isWindowFocused(): boolean { + return document.hasFocus(); +} + +/** Check if message content contains @everyone or @here. */ +function containsEveryone(content: string): boolean { + return content.includes("@everyone") || content.includes("@here"); +} + +/** Get the channel name for a given channel ID. */ +function getChannelName(channelId: number): string { + const channels = channelsStore.getState().channels; + const channel = channels.get(channelId); + return channel?.name ?? `Channel ${channelId}`; +} + +/** + * Handle an incoming chat message — fire desktop notification, flash + * taskbar, and play sound based on user preferences. + * + * Should be called from the dispatcher when a chat_message arrives. + * Skips notifications for the current user's own messages and when + * the window is focused on the message's channel. + */ +export function notifyIncomingMessage(payload: ChatMessagePayload): void { + const currentUser = authStore.getState().user; + + // Don't notify for own messages + if (currentUser !== null && payload.user.id === currentUser.id) return; + + // Don't notify if the window is focused AND the message is in the active channel + const activeChannelId = channelsStore.getState().activeChannelId; + if (isWindowFocused() && payload.channel_id === activeChannelId) return; + + // Check @everyone suppression + if (loadPref("suppressEveryone", false) && containsEveryone(payload.content)) { + return; + } + + const channelName = getChannelName(payload.channel_id); + const title = `${payload.user.username} in #${channelName}`; + const body = payload.content.length > 100 + ? payload.content.slice(0, 100) + "..." + : payload.content; + + // Desktop notification + if (loadPref("desktopNotifications", true)) { + fireDesktopNotification(title, body); + } + + // Flash taskbar + if (loadPref("flashTaskbar", true)) { + flashTaskbar(); + } + + // Notification sound + if (loadPref("notificationSounds", true)) { + playNotificationSound(); + } +} + +/** Fire a Tauri desktop notification. Falls back to Web Notification API. */ +function fireDesktopNotification(title: string, body: string): void { + void (async () => { + try { + const { isPermissionGranted, requestPermission, sendNotification } = + await import("@tauri-apps/plugin-notification"); + + let permitted = await isPermissionGranted(); + if (!permitted) { + const result = await requestPermission(); + permitted = result === "granted"; + } + + if (permitted) { + sendNotification({ title, body }); + } + } catch { + // Fallback to Web Notification API (dev mode / non-Tauri) + try { + if (Notification.permission === "granted") { + new Notification(title, { body }); + } else if (Notification.permission !== "denied") { + const result = await Notification.requestPermission(); + if (result === "granted") { + new Notification(title, { body }); + } + } + } catch { + log.debug("Notifications not available"); + } + } + })(); +} + +/** Flash the taskbar icon to attract attention. */ +function flashTaskbar(): void { + void (async () => { + try { + const { getCurrentWindow } = await import("@tauri-apps/api/window"); + const win = getCurrentWindow(); + await win.requestUserAttention(2); // Informational attention + } catch { + log.debug("Taskbar flash not available"); + } + })(); +} + +// Simple notification sound using Web Audio API +let notifAudioCtx: AudioContext | null = null; + +/** Play a brief notification chime. */ +function playNotificationSound(): void { + try { + if (notifAudioCtx === null) { + notifAudioCtx = new AudioContext(); + } + const ctx = notifAudioCtx; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + + osc.frequency.setValueAtTime(800, ctx.currentTime); + osc.frequency.setValueAtTime(600, ctx.currentTime + 0.1); + gain.gain.setValueAtTime(0.3, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2); + + osc.start(ctx.currentTime); + osc.stop(ctx.currentTime + 0.2); + } catch { + log.debug("Notification sound not available"); + } +} diff --git a/Client/tauri-client/src/lib/ptt.ts b/Client/tauri-client/src/lib/ptt.ts new file mode 100644 index 00000000..edb0a921 --- /dev/null +++ b/Client/tauri-client/src/lib/ptt.ts @@ -0,0 +1,112 @@ +/** + * Push-to-Talk service — uses Rust-side GetAsyncKeyState polling so the + * PTT key is NOT consumed/hijacked. Other apps and chat input continue + * to receive the key normally. Works even when OwnCord is unfocused. + */ + +import { loadPref, savePref } from "@components/settings/helpers"; +import { voiceStore } from "@stores/voice.store"; +import { setMuted } from "./voiceSession"; +import { createLogger } from "./logger"; + +const log = createLogger("ptt"); + +let listening = false; + +// Well-known virtual key code names for display +const VK_NAMES: ReadonlyMap = new Map([ + [0x01, "Mouse Left"], [0x02, "Mouse Right"], [0x04, "Mouse Middle"], + [0x05, "Mouse 4"], [0x06, "Mouse 5"], + [0x08, "Backspace"], [0x09, "Tab"], [0x0D, "Enter"], [0x1B, "Escape"], + [0x20, "Space"], [0x21, "Page Up"], [0x22, "Page Down"], + [0x23, "End"], [0x24, "Home"], + [0x25, "Arrow Left"], [0x26, "Arrow Up"], [0x27, "Arrow Right"], [0x28, "Arrow Down"], + [0x2D, "Insert"], [0x2E, "Delete"], + [0x70, "F1"], [0x71, "F2"], [0x72, "F3"], [0x73, "F4"], + [0x74, "F5"], [0x75, "F6"], [0x76, "F7"], [0x77, "F8"], + [0x78, "F9"], [0x79, "F10"], [0x7A, "F11"], [0x7B, "F12"], + [0x7C, "F13"], [0x7D, "F14"], [0x7E, "F15"], [0x7F, "F16"], + [0xC0, "`"], [0xBD, "-"], [0xBB, "="], + [0xDB, "["], [0xDD, "]"], [0xDC, "\\"], + [0xBA, ";"], [0xDE, "'"], [0xBC, ","], [0xBE, "."], [0xBF, "/"], +]); + +/** Get a human-readable name for a virtual key code. */ +export function vkName(vk: number): string { + if (VK_NAMES.has(vk)) return VK_NAMES.get(vk)!; + // 0-9 keys + if (vk >= 0x30 && vk <= 0x39) return String.fromCharCode(vk); + // A-Z keys + if (vk >= 0x41 && vk <= 0x5A) return String.fromCharCode(vk); + // Numpad 0-9 + if (vk >= 0x60 && vk <= 0x69) return `Numpad ${vk - 0x60}`; + return `Key 0x${vk.toString(16).toUpperCase()}`; +} + +/** Start listening for PTT state changes from the Rust backend. */ +export async function initPtt(): Promise { + const vk = loadPref("pttVk", 0); + if (vk === 0) return; + + try { + const { invoke } = await import("@tauri-apps/api/core"); + const { listen } = await import("@tauri-apps/api/event"); + + // Set the key and start the polling loop + await invoke("ptt_set_key", { vkCode: vk }); + await invoke("ptt_start"); + + // Listen for press/release events + await listen("ptt-state", (event) => { + // Only toggle mute when in a voice channel + const channelId = voiceStore.getState().currentChannelId; + if (channelId === null) return; + + setMuted(!event.payload); + log.debug(event.payload ? "PTT pressed — unmuted" : "PTT released — muted"); + }); + + listening = true; + log.info("PTT started", { vk, name: vkName(vk) }); + } catch (err) { + // Not in Tauri environment (dev mode) + log.debug("PTT not available", { error: err }); + } +} + +/** Stop PTT polling. */ +export async function stopPtt(): Promise { + if (!listening) return; + try { + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("ptt_stop"); + listening = false; + log.info("PTT stopped"); + } catch { + // ignore + } +} + +/** Update the PTT key and restart polling. */ +export async function updatePttKey(vk: number): Promise { + savePref("pttVk", vk); + try { + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("ptt_set_key", { vkCode: vk }); + if (!listening && vk !== 0) { + await initPtt(); + } + if (vk === 0) { + await stopPtt(); + } + log.info("PTT key updated", { vk, name: vk !== 0 ? vkName(vk) : "disabled" }); + } catch { + // ignore + } +} + +/** Use Rust-side polling to capture the next key press (for the binding UI). */ +export async function captureKeyPress(): Promise { + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("ptt_listen_for_key"); +} diff --git a/Client/tauri-client/src/lib/tenor.ts b/Client/tauri-client/src/lib/tenor.ts new file mode 100644 index 00000000..788d8c4a --- /dev/null +++ b/Client/tauri-client/src/lib/tenor.ts @@ -0,0 +1,97 @@ +// Tenor API v2 client — provides GIF search and trending. +// Uses the anonymous test key for development. + +const TENOR_API_KEY = "AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCYQ"; +const TENOR_BASE = "https://tenor.googleapis.com/v2"; +const DEFAULT_LIMIT = 20; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface TenorGif { + readonly id: string; + readonly title: string; + /** tinygif URL for preview thumbnails */ + readonly url: string; + /** Full-size gif URL for sending */ + readonly fullUrl: string; +} + +interface TenorMediaFormat { + readonly url: string; +} + +interface TenorResult { + readonly id: string; + readonly title: string; + readonly media_formats: { + readonly tinygif?: TenorMediaFormat; + readonly gif?: TenorMediaFormat; + }; +} + +interface TenorResponse { + readonly results: readonly TenorResult[]; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function parseResults(data: TenorResponse): readonly TenorGif[] { + return data.results + .filter((r) => r.media_formats.tinygif?.url && r.media_formats.gif?.url) + .map((r) => ({ + id: r.id, + title: r.title, + url: r.media_formats.tinygif!.url, + fullUrl: r.media_formats.gif!.url, + })); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Search Tenor for GIFs matching the given query. + */ +export async function searchGifs( + query: string, + limit: number = DEFAULT_LIMIT, +): Promise { + const params = new URLSearchParams({ + q: query, + key: TENOR_API_KEY, + limit: String(limit), + media_filter: "gif,tinygif", + }); + + const res = await fetch(`${TENOR_BASE}/search?${params.toString()}`); + if (!res.ok) { + throw new Error(`Tenor search failed: ${res.status} ${res.statusText}`); + } + const data: TenorResponse = await res.json(); + return parseResults(data); +} + +/** + * Fetch currently trending GIFs from Tenor. + */ +export async function getTrendingGifs( + limit: number = DEFAULT_LIMIT, +): Promise { + const params = new URLSearchParams({ + key: TENOR_API_KEY, + limit: String(limit), + media_filter: "gif,tinygif", + }); + + const res = await fetch(`${TENOR_BASE}/featured?${params.toString()}`); + if (!res.ok) { + throw new Error(`Tenor trending failed: ${res.status} ${res.statusText}`); + } + const data: TenorResponse = await res.json(); + return parseResults(data); +} diff --git a/Client/tauri-client/src/main.ts b/Client/tauri-client/src/main.ts index c498efca..bd52dfb7 100644 --- a/Client/tauri-client/src/main.ts +++ b/Client/tauri-client/src/main.ts @@ -16,6 +16,7 @@ import { leaveVoice as voiceSessionLeave } from "@lib/voiceSession"; import { createConnectPage } from "@pages/ConnectPage"; import { createMainPage } from "@pages/MainPage"; import { applyStoredAppearance } from "@components/SettingsOverlay"; +import { initPtt } from "@lib/ptt"; import { createConnectedOverlay } from "@components/ConnectedOverlay"; import type { ConnectedOverlayControl } from "@components/ConnectedOverlay"; import { createLogger } from "@lib/logger"; @@ -51,6 +52,9 @@ installGlobalErrorHandlers(); // Apply stored theme/font/compact preferences before first render applyStoredAppearance(); +// Start push-to-talk listener (Rust-side polling, non-consuming) +void initPtt(); + const appEl = document.getElementById("app"); if (!appEl) { throw new Error("Missing #app element"); diff --git a/Client/tauri-client/src/pages/MainPage.ts b/Client/tauri-client/src/pages/MainPage.ts index 18743748..32e798d3 100644 --- a/Client/tauri-client/src/pages/MainPage.ts +++ b/Client/tauri-client/src/pages/MainPage.ts @@ -24,6 +24,7 @@ import { createServerBanner } from "@components/ServerBanner"; import type { ServerBannerControl } from "@components/ServerBanner"; import { createSettingsOverlay } from "@components/SettingsOverlay"; import { createToastContainer } from "@components/Toast"; +import { createEmojiPicker } from "@components/EmojiPicker"; import type { ToastContainer } from "@components/Toast"; import { authStore, clearAuth, updateUser } from "@stores/auth.store"; import { closeSettings, toggleMemberList, uiStore } from "@stores/ui.store"; @@ -238,7 +239,72 @@ export function createMainPage(options: MainPageOptions): MountableComponent { } }, onReactionClick: (msgId: number, emoji: string) => { - if (emoji === "") return; + if (emoji === "") { + // Open emoji picker for reaction selection + const reactBtn = document.querySelector(`[data-testid="msg-react-${msgId}"]`); + if (reactBtn === null) return; + + // Close any existing reaction picker + const existingWrap = document.querySelector(".reaction-picker-wrap"); + if (existingWrap !== null) { existingWrap.remove(); return; } + + let pickerDestroy: (() => void) | null = null; + + const wrap = createElement("div", { + class: "reaction-picker-wrap", + }); + + // Backdrop to close on click-outside + const backdrop = createElement("div", { + style: "position: fixed; inset: 0; z-index: 299;", + }); + backdrop.addEventListener("click", () => { + pickerDestroy?.(); + wrap.remove(); + }); + + const picker = createEmojiPicker({ + onSelect: (selectedEmoji: string) => { + pickerDestroy?.(); + wrap.remove(); + if (!limiters.reactions.tryConsume()) { + toast?.show("Slow down! Please wait before reacting again.", "error"); + return; + } + const msgs = getChannelMessages(channelId); + const m = msgs.find((x) => x.id === msgId); + const existing = m?.reactions.find((r) => r.emoji === selectedEmoji); + const type = existing?.me ? "reaction_remove" : "reaction_add"; + ws.send({ type, payload: { message_id: msgId, emoji: selectedEmoji } }); + }, + onClose: () => { pickerDestroy?.(); wrap.remove(); }, + }); + pickerDestroy = picker.destroy; + + // Position the picker to the left of the react button, top-aligned + const rect = reactBtn.getBoundingClientRect(); + const pickerW = 320; + let left = rect.left - pickerW - 8; + let top = rect.top; + if (left < 8) left = rect.right + 8; + if (top + 420 > window.innerHeight - 8) top = window.innerHeight - 420 - 8; + if (top < 8) top = 8; + + // Override the picker's default absolute positioning + picker.element.style.position = "fixed"; + picker.element.style.left = `${left}px`; + picker.element.style.top = `${top}px`; + picker.element.style.bottom = "auto"; + picker.element.style.right = "auto"; + picker.element.style.zIndex = "300"; + picker.element.style.margin = "0"; + + wrap.appendChild(backdrop); + wrap.appendChild(picker.element); + document.body.appendChild(wrap); + + return; + } if (!limiters.reactions.tryConsume()) { toast?.show("Slow down! Please wait before reacting again.", "error"); return; diff --git a/Client/tauri-client/src/styles/app.css b/Client/tauri-client/src/styles/app.css index e48693c3..a98a15ab 100644 --- a/Client/tauri-client/src/styles/app.css +++ b/Client/tauri-client/src/styles/app.css @@ -239,7 +239,14 @@ .input-slot { flex-shrink: 0; } /* ── Messages ── */ -.messages-container { flex: 1; overflow-y: auto; padding: 16px 0; } +.messages-container { + flex: 1; overflow-y: auto; padding: 16px 0; + overscroll-behavior: contain; + contain: strict; +} +/* Scroll anchoring: only the bottom anchor element pins, everything else opts out */ +.messages-container > * { overflow-anchor: none; } +.scroll-anchor { overflow-anchor: auto; height: 1px; } .msg-day-divider { display: flex; align-items: center; gap: 8px; padding: 8px 16px 16px; margin-bottom: 8px; @@ -250,6 +257,7 @@ .message { padding: 2px 48px 2px 72px; position: relative; min-height: 28px; + contain: layout style; } .message:hover { background: rgba(0,0,0,.06); } .message.grouped { min-height: 20px; } @@ -718,6 +726,58 @@ } .ep-emoji:hover { background: var(--bg-hover); } +/* ── GIF Picker ── */ +.gif-picker { + position: absolute; z-index: 200; + bottom: 100%; right: 0; + background: var(--bg-primary); border: 1px solid var(--border); + border-radius: var(--radius-md); width: 320px; height: 400px; + box-shadow: 0 8px 32px rgba(0,0,0,.6); + display: none; overflow: hidden; + margin-bottom: 4px; + flex-direction: column; +} +.gif-picker.open { display: flex; } +.gp-header { + padding: 12px; border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.gp-search { + width: 100%; background: var(--bg-tertiary); + border: none; border-radius: var(--radius-sm); + padding: 8px 10px; color: var(--text-normal); font-size: 13px; +} +.gp-search::placeholder { color: var(--text-micro); } +.gp-attribution { + font-size: 10px; color: var(--text-micro); + padding: 4px 0 0; text-align: center; +} +.gp-grid-area { + flex: 1; overflow-y: auto; padding: 8px; +} +.gp-grid { + display: grid; grid-template-columns: 1fr 1fr; + gap: 6px; +} +.gp-item { + cursor: pointer; border-radius: var(--radius-sm); + overflow: hidden; transition: transform .1s; +} +.gp-item:hover { transform: scale(1.03); } +.gp-img { + width: 100%; display: block; object-fit: cover; + border-radius: var(--radius-sm); + min-height: 80px; background: var(--bg-tertiary); +} +.gp-loading { + padding: 48px 0; text-align: center; + color: var(--text-faint); font-size: 13px; +} +.gp-empty { + padding: 48px 0; text-align: center; + color: var(--text-faint); font-size: 13px; +} + /* ── Settings Overlay (app) ── */ .settings-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; @@ -1175,6 +1235,45 @@ .user-popup.open, .emoji-picker.open { animation: slideIn .15s ease; } .settings-overlay.open { animation: fadeIn .2s ease; } +/* ═══ Compact Mode ═══ */ +/* Messages: hide avatars, inline timestamp + author, tighter spacing */ +.compact-mode .message { padding: 2px 16px 2px 40px; min-height: 20px; } +.compact-mode .message.grouped { min-height: 16px; } +.compact-mode .message .msg-avatar { width: 24px; height: 24px; font-size: 10px; top: 2px; left: 8px; } +.compact-mode .message.grouped .msg-avatar { display: none; } +.compact-mode .message .msg-hover-time { + display: none; +} +.compact-mode .message.grouped .msg-hover-time { display: block; } +.compact-mode .message .msg-header { display: flex; } +.compact-mode .message.grouped .msg-header { display: none; } +.compact-mode .message .msg-text { line-height: 1.25; font-size: 13px; } +.compact-mode .message .msg-time { display: none; } +/* Reactions and embeds */ +.compact-mode .msg-reactions { margin-top: 2px; } +.compact-mode .msg-day-divider { padding: 4px 16px 4px; margin-bottom: 2px; } +/* Channel sidebar */ +.compact-mode .channel-item { padding: 3px 8px; } +.compact-mode .channel-item .ch-name { font-size: 13px; } +.compact-mode .channel-item .ch-icon { font-size: 16px; } +.compact-mode .channel-sidebar-header { height: 40px; } +.compact-mode .category { padding: 10px 8px 2px 16px; } +/* Chat header */ +.compact-mode .chat-header { height: 38px; } +/* Members */ +.compact-mode .member-item { padding: 2px 8px; } +.compact-mode .member-item .mi-avatar { width: 24px; height: 24px; font-size: 10px; } +.compact-mode .member-item .mi-name { font-size: 13px; } +.compact-mode .member-role-group { padding: 12px 16px 2px; } +/* Voice */ +.compact-mode .voice-user-item { padding: 2px 8px; font-size: 12px; } +.compact-mode .voice-user-item .vu-avatar { width: 16px; height: 16px; font-size: 7px; } +/* User bar */ +.compact-mode .user-bar { height: 42px; } +.compact-mode .user-bar .ub-avatar { width: 28px; height: 28px; font-size: 12px; } +/* Messages container */ +.compact-mode .messages-container { padding: 8px 0; } + /* ═══ Responsive ═══ */ @media (max-width: 1200px) { .member-list { width: 0; padding: 0; overflow: hidden; } diff --git a/Client/tauri-client/src/styles/base.css b/Client/tauri-client/src/styles/base.css index 8a0d1860..247cabce 100644 --- a/Client/tauri-client/src/styles/base.css +++ b/Client/tauri-client/src/styles/base.css @@ -61,12 +61,12 @@ a { } ::-webkit-scrollbar-thumb { - background: var(--bg-tertiary); + background: rgba(255, 255, 255, 0.15); border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { - background: var(--bg-hover); + background: rgba(255, 255, 255, 0.25); } /* Utility classes */ diff --git a/Client/tauri-client/tests/unit/keybinds-tab.test.ts b/Client/tauri-client/tests/unit/keybinds-tab.test.ts index 451f709a..364b55d4 100644 --- a/Client/tauri-client/tests/unit/keybinds-tab.test.ts +++ b/Client/tauri-client/tests/unit/keybinds-tab.test.ts @@ -3,20 +3,20 @@ import { buildKeybindsTab } from "../../src/components/settings/KeybindsTab"; describe("KeybindsTab", () => { it("returns a div with settings-pane class", () => { - const el = buildKeybindsTab(); + const el = buildKeybindsTab(new AbortController().signal); expect(el.tagName).toBe("DIV"); expect(el.className).toBe("settings-pane active"); }); it("renders a Keybinds header", () => { - const el = buildKeybindsTab(); + const el = buildKeybindsTab(new AbortController().signal); const h1 = el.querySelector("h1"); expect(h1).not.toBeNull(); expect(h1!.textContent).toBe("Keybinds"); }); it("renders Push to Talk keybind row", () => { - const el = buildKeybindsTab(); + const el = buildKeybindsTab(new AbortController().signal); const rows = el.querySelectorAll(".keybind-row"); expect(rows.length).toBe(2); const pttLabel = rows[0]!.querySelector(".setting-label"); @@ -24,14 +24,14 @@ describe("KeybindsTab", () => { }); it("renders Quick Switcher keybind row with Ctrl + K", () => { - const el = buildKeybindsTab(); + const el = buildKeybindsTab(new AbortController().signal); const rows = el.querySelectorAll(".keybind-row"); const kbd = rows[1]!.querySelector(".kbd"); expect(kbd!.textContent).toBe("Ctrl + K"); }); it("shows fallback for PTT when not configured", () => { - const el = buildKeybindsTab(); + const el = buildKeybindsTab(new AbortController().signal); const rows = el.querySelectorAll(".keybind-row"); const kbd = rows[0]!.querySelector(".kbd"); expect(kbd!.textContent).toBe("Not set"); diff --git a/Client/tauri-client/tests/unit/notifications.test.ts b/Client/tauri-client/tests/unit/notifications.test.ts new file mode 100644 index 00000000..7a776331 --- /dev/null +++ b/Client/tauri-client/tests/unit/notifications.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { notifyIncomingMessage } from "../../src/lib/notifications"; +import { authStore } from "../../src/stores/auth.store"; +import { channelsStore } from "../../src/stores/channels.store"; +import type { ChatMessagePayload } from "../../src/lib/types"; + +// Track prefs in a shared map we can reset +const testPrefs = new Map(); + +// Mock the settings helpers +vi.mock("../../src/components/settings/helpers", () => ({ + STORAGE_PREFIX: "owncord:settings:", + loadPref: (key: string, fallback: unknown) => testPrefs.get(key) ?? fallback, + savePref: (key: string, value: unknown) => testPrefs.set(key, value), + THEMES: { dark: {}, midnight: {}, light: {} }, + applyTheme: vi.fn(), +})); + +// Mock Tauri notification plugin (not available in test env) +vi.mock("@tauri-apps/plugin-notification", () => ({ + isPermissionGranted: vi.fn().mockResolvedValue(true), + requestPermission: vi.fn().mockResolvedValue("granted"), + sendNotification: vi.fn(), +})); + +// Mock Tauri window API +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: vi.fn().mockReturnValue({ + requestUserAttention: vi.fn().mockResolvedValue(undefined), + }), +})); + +function makePayload(overrides: Partial = {}): ChatMessagePayload { + return { + id: 1, + channel_id: 1, + user: { id: 2, username: "TestUser", avatar: null }, + content: "Hello world", + reply_to: null, + attachments: [], + timestamp: new Date().toISOString(), + ...overrides, + } as ChatMessagePayload; +} + +describe("notifyIncomingMessage", () => { + beforeEach(() => { + testPrefs.clear(); + + // Set up auth store with a different user + authStore.setState(() => ({ + token: "test", + user: { id: 1, username: "Me", avatar: null, role: "member" }, + serverName: null, + motd: null, + isAuthenticated: true, + })); + + // Set up channels store + channelsStore.setState(() => ({ + channels: new Map([[1, { id: 1, name: "general", type: "text" as const, category: null, position: 0, unreadCount: 0, lastMessageId: null }]]), + activeChannelId: 1, + })); + + // Ensure document.hasFocus returns false (simulating unfocused window) + vi.spyOn(document, "hasFocus").mockReturnValue(false); + }); + + it("does not notify for own messages", () => { + const payload = makePayload({ user: { id: 1, username: "Me", avatar: null } }); + notifyIncomingMessage(payload); + }); + + it("does not notify when window is focused and message is in active channel", () => { + vi.spyOn(document, "hasFocus").mockReturnValue(true); + channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 })); + const payload = makePayload({ channel_id: 1 }); + notifyIncomingMessage(payload); + }); + + it("notifies when window is focused but message is in a different channel", () => { + vi.spyOn(document, "hasFocus").mockReturnValue(true); + channelsStore.setState((prev) => ({ ...prev, activeChannelId: 2 })); + const payload = makePayload({ channel_id: 1 }); + notifyIncomingMessage(payload); + }); + + it("suppresses @everyone when toggle is enabled", () => { + testPrefs.set("suppressEveryone", true); + const payload = makePayload({ content: "Hey @everyone check this out" }); + notifyIncomingMessage(payload); + }); + + it("does not suppress @everyone when toggle is disabled", () => { + testPrefs.set("suppressEveryone", false); + const payload = makePayload({ content: "Hey @everyone check this out" }); + notifyIncomingMessage(payload); + }); + + it("handles long messages by truncating", () => { + const longContent = "A".repeat(200); + const payload = makePayload({ content: longContent }); + notifyIncomingMessage(payload); + }); + + it("handles @here the same as @everyone", () => { + testPrefs.set("suppressEveryone", true); + const payload = makePayload({ content: "Hey @here important update" }); + notifyIncomingMessage(payload); + }); + + it("skips desktop notification when toggle is off", () => { + testPrefs.set("desktopNotifications", false); + const payload = makePayload(); + notifyIncomingMessage(payload); + }); + + it("skips taskbar flash when toggle is off", () => { + testPrefs.set("flashTaskbar", false); + const payload = makePayload(); + notifyIncomingMessage(payload); + }); + + it("skips notification sound when toggle is off", () => { + testPrefs.set("notificationSounds", false); + const payload = makePayload(); + notifyIncomingMessage(payload); + }); +}); diff --git a/Server/api/middleware.go b/Server/api/middleware.go index 6d7ea7ce..956cd1d2 100644 --- a/Server/api/middleware.go +++ b/Server/api/middleware.go @@ -234,6 +234,28 @@ func isTrustedProxy(remoteIP string, cidrList []string) (bool, error) { return false, nil } +// AdminIPRestrict returns middleware that blocks requests from IPs not in the +// allowed CIDR list. Returns 403 Forbidden for disallowed IPs. If the CIDR +// list is empty, all requests are allowed (no restriction). +func AdminIPRestrict(allowedCIDRs []string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(allowedCIDRs) == 0 { + next.ServeHTTP(w, r) + return + } + + ip := clientIP(r) + allowed, _ := isTrustedProxy(ip, allowedCIDRs) + if !allowed { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) + } +} + // SecurityHeaders sets a standard suite of defensive HTTP response headers on // every response. It must be added to the router-level middleware stack so that // all routes, including error responses, carry these headers. diff --git a/Server/api/router.go b/Server/api/router.go index ace2057e..a1613574 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -81,8 +81,13 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri r.Get("/api/v1/ws", ws.ServeWS(hub, database, cfg.Server.AllowedOrigins)) // Admin panel: static files + REST API (Phase 6). + // Restrict /admin to configured CIDRs (default: private networks only). u := updater.NewUpdater(ver, cfg.GitHub.Token, "J3vb", "OwnCord") - r.Mount("/admin", admin.NewHandler(database, ver, hub, u, logBuf)) + adminHandler := admin.NewHandler(database, ver, hub, u, logBuf) + r.Group(func(r chi.Router) { + r.Use(AdminIPRestrict(cfg.Server.AdminAllowedCIDRs)) + r.Mount("/admin", adminHandler) + }) // Client auto-update endpoint (unauthenticated). MountClientUpdateRoute(r, u) diff --git a/Server/config/config.go b/Server/config/config.go index 9e2f5b31..4db616a5 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -45,11 +45,12 @@ type VoiceConfig struct { // ServerConfig holds HTTP server settings. type ServerConfig struct { - Port int `koanf:"port"` - Name string `koanf:"name"` - DataDir string `koanf:"data_dir"` - AllowedOrigins []string `koanf:"allowed_origins"` - TrustedProxies []string `koanf:"trusted_proxies"` + Port int `koanf:"port"` + Name string `koanf:"name"` + DataDir string `koanf:"data_dir"` + AllowedOrigins []string `koanf:"allowed_origins"` + TrustedProxies []string `koanf:"trusted_proxies"` + AdminAllowedCIDRs []string `koanf:"admin_allowed_cidrs"` } // DatabaseConfig holds database settings. @@ -81,6 +82,14 @@ func defaults() Config { DataDir: "data", AllowedOrigins: []string{"*"}, TrustedProxies: []string{}, + AdminAllowedCIDRs: []string{ + "127.0.0.0/8", // localhost IPv4 + "::1/128", // localhost IPv6 + "10.0.0.0/8", // private class A + "172.16.0.0/12", // private class B + "192.168.0.0/16", // private class C + "fc00::/7", // IPv6 unique local + }, }, Database: DatabaseConfig{ Path: "data/chatserver.db", @@ -117,6 +126,12 @@ server: data_dir: "data" # allowed_origins: ["*"] # restrict WebSocket origins, e.g. ["https://example.com"] # trusted_proxies: [] # CIDRs of trusted reverse proxies, e.g. ["10.0.0.0/8"] + # admin_allowed_cidrs: # CIDRs allowed to access /admin (default: private networks only) + # - "127.0.0.0/8" + # - "::1/128" + # - "10.0.0.0/8" + # - "172.16.0.0/12" + # - "192.168.0.0/16" database: path: "data/chatserver.db" @@ -199,9 +214,41 @@ func Load(cfgPath string) (*Config, error) { return nil, fmt.Errorf("unmarshalling config: %w", err) } + // Apply voice defaults for zero-value fields (koanf loses defaults when + // the YAML section is present but fields are commented out / omitted). + applyVoiceDefaults(&cfg.Voice) + return &cfg, nil } +// applyVoiceDefaults fills in zero-value voice fields with sensible defaults. +// This guards against the koanf merge behaviour where an empty YAML section +// overwrites struct defaults with Go zero values. +func applyVoiceDefaults(v *VoiceConfig) { + def := defaults().Voice + if v.STUNPort == 0 { + v.STUNPort = def.STUNPort + } + if v.TURNPort == 0 { + v.TURNPort = def.TURNPort + } + if v.Quality == "" { + v.Quality = def.Quality + } + if v.MediaPortMin == 0 { + v.MediaPortMin = def.MediaPortMin + } + if v.MediaPortMax == 0 { + v.MediaPortMax = def.MediaPortMax + } + if v.MixingThreshold == 0 { + v.MixingThreshold = def.MixingThreshold + } + if v.TopSpeakers == 0 { + v.TopSpeakers = def.TopSpeakers + } +} + // validateYAML checks that raw bytes are valid YAML. func validateYAML(raw []byte) error { var v any