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