feat: settings overhaul, GIF picker, notifications, PTT, scroll fixes

Client:
- Wire up all 4 notification toggles (desktop, taskbar flash, sounds, @everyone)
- Add compact mode CSS with visible layout differences
- Add GIF picker with Tenor API (trending + search)
- Render inline images/GIFs instead of link previews for direct URLs
- Add push-to-talk via Rust GetAsyncKeyState polling (non-consuming)
- Add key capture UI for PTT keybinds (supports mouse buttons)
- Add error/success feedback on account settings (password, username)
- Fix mic stream cleanup on tab switch (VoiceAudioTab factory pattern)
- Fix message timestamps using UTC with proper timezone conversion
- Fix emoji reaction picker (was returning early on empty emoji)
- Fix chat scroll jumpiness with Discord-style overflow-anchor + ResizeObserver
- Pre-measure all message heights on load to prevent first-scroll jump
- Improve scrollbar visibility with semi-transparent white thumb
- Show client version on Logs tab

Server:
- Add admin_allowed_cidrs config to restrict /admin to private networks
- Fix voice config defaults lost when YAML section has omitted fields
- Add negative caching to updater (5min error cache)
This commit is contained in:
jevb
2026-03-19 12:23:24 +01:00
parent b5b8be8370
commit b2038cecdb
24 changed files with 1330 additions and 66 deletions
+1 -1
View File
@@ -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"] }
+6
View File
@@ -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())?;
+91
View File
@@ -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<R: Runtime>(app: AppHandle<R>) {
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));
}
}
@@ -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<typeof setTimeout> | 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<void> {
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 };
}
@@ -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();
@@ -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(); }));
@@ -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<TabName, HTMLButtonElement>();
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();
@@ -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));
@@ -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 });
@@ -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<string>("pttKey", "Not set"));
appendChildren(pttRow, pttLabel, pttValue);
const savedVk = loadPref<number>("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");
@@ -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<string>("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 },
@@ -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);
}),
);
@@ -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<boolean>("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<boolean>("desktopNotifications", true)) {
fireDesktopNotification(title, body);
}
// Flash taskbar
if (loadPref<boolean>("flashTaskbar", true)) {
flashTaskbar();
}
// Notification sound
if (loadPref<boolean>("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");
}
}
+112
View File
@@ -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<number, string> = 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<void> {
const vk = loadPref<number>("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<boolean>("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<void> {
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<void> {
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<number> {
const { invoke } = await import("@tauri-apps/api/core");
return invoke<number>("ptt_listen_for_key");
}
+97
View File
@@ -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<readonly TenorGif[]> {
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<readonly TenorGif[]> {
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);
}
+4
View File
@@ -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");
+67 -1
View File
@@ -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;
+100 -1
View File
@@ -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; }
+2 -2
View File
@@ -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 */
@@ -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");
@@ -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<string, unknown>();
// 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> = {}): 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);
});
});
+22
View File
@@ -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.
+6 -1
View File
@@ -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)
+52 -5
View File
@@ -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