diff --git a/Client/tauri-client/src/components/message-list/renderers.ts b/Client/tauri-client/src/components/message-list/renderers.ts index 07b16ce5..f5986a61 100644 --- a/Client/tauri-client/src/components/message-list/renderers.ts +++ b/Client/tauri-client/src/components/message-list/renderers.ts @@ -151,8 +151,17 @@ function isImageMime(mime: string): boolean { return mime.startsWith("image/"); } +function isSafeUrl(url: string): boolean { + try { + const parsed = new URL(url, window.location.origin); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + function renderAttachment(att: Attachment): HTMLDivElement { - if (isImageMime(att.mime)) { + if (isImageMime(att.mime) && isSafeUrl(att.url)) { const wrap = createElement("div", { class: "msg-image" }); const img = createElement("img", { src: att.url, diff --git a/Client/tauri-client/src/lib/voiceSession.ts b/Client/tauri-client/src/lib/voiceSession.ts index ad644b60..1f55e782 100644 --- a/Client/tauri-client/src/lib/voiceSession.ts +++ b/Client/tauri-client/src/lib/voiceSession.ts @@ -164,6 +164,11 @@ export function setOnError(cb: (message: string) => void): void { onErrorCallback = cb; } +/** Clear the error callback (call on component destroy to avoid stale refs). */ +export function clearOnError(): void { + onErrorCallback = null; +} + /** * Fetch ICE servers (TURN/STUN credentials) for WebRTC. * Falls back to empty array on failure so voice still works on LAN. @@ -321,6 +326,11 @@ export function setDeafened(deafened: boolean): void { /** Switch the input (microphone) device on an active session. */ export async function switchInputDevice(deviceId: string): Promise { + // Don't acquire microphone if there's no active voice session + if (webrtcService === null) { + log.debug("Skipping input device switch — no active voice session"); + return; + } if (audioManager === null) { audioManager = createAudioManager(); } @@ -363,17 +373,20 @@ export async function switchInputDevice(deviceId: string): Promise { /** Switch the output (speaker) device on an active session. */ export async function switchOutputDevice(deviceId: string): Promise { + let hadError = false; for (const el of audioElements.values()) { if (typeof el.setSinkId === "function") { try { await el.setSinkId(deviceId); } catch (err) { log.error("Failed to set output device on audio element", err); - onErrorCallback?.("Failed to switch speaker"); - return; + hadError = true; } } } + if (hadError) { + onErrorCallback?.("Failed to switch some audio to new speaker"); + } log.info("Switched output device", { deviceId }); } diff --git a/Client/tauri-client/src/pages/MainPage.ts b/Client/tauri-client/src/pages/MainPage.ts index af6a3135..eba6d967 100644 --- a/Client/tauri-client/src/pages/MainPage.ts +++ b/Client/tauri-client/src/pages/MainPage.ts @@ -39,6 +39,7 @@ import { setDeafened as voiceSessionSetDeafened, setWsClient, setOnError as setVoiceOnError, + clearOnError as clearVoiceOnError, } from "@lib/voiceSession"; import { setMessages, @@ -98,6 +99,9 @@ export function createMainPage(options: MainPageOptions): MountableComponent { // Track currently mounted channel to avoid redundant rebuilds let currentChannelId: number | null = null; + // Pending delete confirmations (double-click to delete pattern) + const pendingDeletes = new Map(); + // Abort controller for channel-scoped async operations (e.g. message fetch) let channelAbort: AbortController | null = null; @@ -208,12 +212,19 @@ export function createMainPage(options: MainPageOptions): MountableComponent { } }, onDeleteClick: (msgId: number) => { - if (!confirm("Delete this message?")) return; - ws.send({ - type: "chat_delete", - payload: { message_id: msgId }, - }); - toast?.show("Message deleted", "success"); + if (pendingDeletes.has(msgId)) { + window.clearTimeout(pendingDeletes.get(msgId)); + pendingDeletes.delete(msgId); + ws.send({ + type: "chat_delete", + payload: { message_id: msgId }, + }); + toast?.show("Message deleted", "success"); + } else { + toast?.show("Click delete again to confirm", "info"); + const tid = window.setTimeout(() => pendingDeletes.delete(msgId), 5000); + pendingDeletes.set(msgId, tid); + } }, onReactionClick: (msgId: number, emoji: string) => { if (emoji === "") return; @@ -301,6 +312,11 @@ export function createMainPage(options: MainPageOptions): MountableComponent { } function destroyChannelComponents(): void { + for (const tid of pendingDeletes.values()) { + window.clearTimeout(tid); + } + pendingDeletes.clear(); + if (channelAbort !== null) { channelAbort.abort(); channelAbort = null; @@ -596,6 +612,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent { function destroy(): void { log.info("MainPage destroying"); + clearVoiceOnError(); destroyChannelComponents(); for (const child of children) { diff --git a/Client/tauri-client/tests/unit/auth.store.test.ts b/Client/tauri-client/tests/unit/auth.store.test.ts index f8a08de1..c8f9d1df 100644 --- a/Client/tauri-client/tests/unit/auth.store.test.ts +++ b/Client/tauri-client/tests/unit/auth.store.test.ts @@ -5,6 +5,7 @@ import { clearAuth, getToken, getCurrentUser, + updateUser, } from "../../src/stores/auth.store"; import type { UserWithRole } from "../../src/lib/types"; @@ -128,7 +129,46 @@ describe("auth store", () => { }); }); - // 5. getCurrentUser returns current user + // 5. updateUser patches user fields + describe("updateUser", () => { + it("updates username on authenticated user", () => { + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + updateUser({ username: "newname" }); + expect(authStore.getState().user?.username).toBe("newname"); + }); + + it("preserves other user fields when patching", () => { + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + updateUser({ username: "newname" }); + const user = authStore.getState().user; + expect(user?.id).toBe(42); + expect(user?.avatar).toBe("avatar.png"); + expect(user?.role).toBe("member"); + }); + + it("is a no-op when user is null", () => { + updateUser({ username: "newname" }); + expect(authStore.getState().user).toBeNull(); + }); + + it("produces a new state object", () => { + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + const before = authStore.getState(); + updateUser({ username: "changed" }); + expect(authStore.getState()).not.toBe(before); + }); + + it("produces a new user object (immutable)", () => { + setAuth(TEST_TOKEN, TEST_USER, TEST_SERVER_NAME, TEST_MOTD); + const userBefore = authStore.getState().user; + updateUser({ avatar: "new-avatar.png" }); + const userAfter = authStore.getState().user; + expect(userBefore).not.toBe(userAfter); + expect(userAfter?.avatar).toBe("new-avatar.png"); + }); + }); + + // 6. getCurrentUser returns current user describe("getCurrentUser", () => { it("returns null when unauthenticated", () => { expect(getCurrentUser()).toBeNull(); diff --git a/Client/tauri-client/tests/unit/user-bar.test.ts b/Client/tauri-client/tests/unit/user-bar.test.ts index 632be143..79d7df82 100644 --- a/Client/tauri-client/tests/unit/user-bar.test.ts +++ b/Client/tauri-client/tests/unit/user-bar.test.ts @@ -101,6 +101,51 @@ describe("UserBar", () => { expect(openSettings).toHaveBeenCalledOnce(); }); + it("calls onMuteToggle when mute button clicked", () => { + setAuthState({ username: "alice" }, true); + const onMuteToggle = vi.fn(); + comp = createUserBar({ onMuteToggle }); + comp.mount(container); + + const muteBtn = container.querySelector('[title="Mute"]') as HTMLButtonElement; + muteBtn.click(); + expect(onMuteToggle).toHaveBeenCalledOnce(); + }); + + it("calls onDeafenToggle when deafen button clicked", () => { + setAuthState({ username: "alice" }, true); + const onDeafenToggle = vi.fn(); + comp = createUserBar({ onDeafenToggle }); + comp.mount(container); + + const deafenBtn = container.querySelector('[title="Deafen"]') as HTMLButtonElement; + deafenBtn.click(); + expect(onDeafenToggle).toHaveBeenCalledOnce(); + }); + + it("does not throw when mute/deafen clicked without callbacks", () => { + setAuthState({ username: "alice" }, true); + comp = createUserBar(); + comp.mount(container); + + const muteBtn = container.querySelector('[title="Mute"]') as HTMLButtonElement; + const deafenBtn = container.querySelector('[title="Deafen"]') as HTMLButtonElement; + expect(() => muteBtn.click()).not.toThrow(); + expect(() => deafenBtn.click()).not.toThrow(); + }); + + it("stops responding to clicks after destroy", () => { + setAuthState({ username: "alice" }, true); + const onMuteToggle = vi.fn(); + comp = createUserBar({ onMuteToggle }); + comp.mount(container); + + const muteBtn = container.querySelector('[title="Mute"]') as HTMLButtonElement; + comp.destroy?.(); + muteBtn.click(); + expect(onMuteToggle).not.toHaveBeenCalled(); + }); + it("destroy removes DOM and unsubscribes", () => { setAuthState({ username: "alice" }, true); comp = createUserBar(); diff --git a/Client/tauri-client/tests/unit/voice.store.test.ts b/Client/tauri-client/tests/unit/voice.store.test.ts index 125c8277..8b0e029f 100644 --- a/Client/tauri-client/tests/unit/voice.store.test.ts +++ b/Client/tauri-client/tests/unit/voice.store.test.ts @@ -8,6 +8,9 @@ import { leaveVoiceChannel, setLocalMuted, setLocalDeafened, + setLocalCamera, + setLocalScreenshare, + setLocalSpeaking, getChannelVoiceUsers, } from "../../src/stores/voice.store"; import type { @@ -15,6 +18,7 @@ import type { VoiceStatePayload, VoiceLeavePayload, } from "../../src/lib/types"; +import { authStore } from "../../src/stores/auth.store"; function resetStore(): void { voiceStore.setState(() => ({ @@ -23,6 +27,8 @@ function resetStore(): void { voiceConfigs: new Map(), localMuted: false, localDeafened: false, + localCamera: false, + localScreenshare: false, })); } @@ -79,6 +85,14 @@ describe("voice store", () => { it("has localDeafened false", () => { expect(voiceStore.getState().localDeafened).toBe(false); }); + + it("has localCamera false", () => { + expect(voiceStore.getState().localCamera).toBe(false); + }); + + it("has localScreenshare false", () => { + expect(voiceStore.getState().localScreenshare).toBe(false); + }); }); describe("setVoiceStates", () => { @@ -228,6 +242,68 @@ describe("voice store", () => { }); }); + describe("setLocalCamera / setLocalScreenshare", () => { + it("setLocalCamera sets camera to true", () => { + setLocalCamera(true); + expect(voiceStore.getState().localCamera).toBe(true); + }); + + it("setLocalCamera sets camera to false", () => { + setLocalCamera(true); + setLocalCamera(false); + expect(voiceStore.getState().localCamera).toBe(false); + }); + + it("setLocalScreenshare sets screenshare to true", () => { + setLocalScreenshare(true); + expect(voiceStore.getState().localScreenshare).toBe(true); + }); + + it("setLocalScreenshare sets screenshare to false", () => { + setLocalScreenshare(true); + setLocalScreenshare(false); + expect(voiceStore.getState().localScreenshare).toBe(false); + }); + }); + + describe("setLocalSpeaking", () => { + it("updates speaking state for current user in active channel", () => { + // Set up: current user (id=1) in channel 10 + authStore.setState(() => ({ + token: "t", + user: { id: 1, username: "me", avatar: "", role: "member" }, + serverName: "s", + motd: "", + isAuthenticated: true, + })); + setVoiceStates([VOICE_STATE_1]); + joinVoiceChannel(10); + + setLocalSpeaking(true); + const user = voiceStore.getState().voiceUsers.get(10)?.get(1); + expect(user?.speaking).toBe(true); + + setLocalSpeaking(false); + const userAfter = voiceStore.getState().voiceUsers.get(10)?.get(1); + expect(userAfter?.speaking).toBe(false); + + // Cleanup + authStore.setState(() => ({ + token: null, + user: null, + serverName: null, + motd: null, + isAuthenticated: false, + })); + }); + + it("is a no-op when not in a voice channel", () => { + const before = voiceStore.getState(); + setLocalSpeaking(true); + expect(voiceStore.getState()).toBe(before); + }); + }); + describe("getChannelVoiceUsers", () => { it("returns all voice users for a channel", () => { setVoiceStates([VOICE_STATE_1, VOICE_STATE_2]); diff --git a/docs/brain/05-Bugs/Open Bugs.md b/docs/brain/05-Bugs/Open Bugs.md new file mode 100644 index 00000000..26cfbcba --- /dev/null +++ b/docs/brain/05-Bugs/Open Bugs.md @@ -0,0 +1,125 @@ +# Open Bugs + +Bug tracker for the OwnCord project. + +## Active + +### Critical + +(none) + +### High + +(none) + +### Medium + +(none) + +## Resolved + +- **BUG-039**: `switchOutputDevice` early return on partial failure — fixed 2026-03-18 + - Replaced `return` with error tracking; all elements attempted before reporting +- **BUG-040**: Stale `onErrorCallback` after MainPage destroy — fixed 2026-03-18 + - Added `clearOnError()` export; MainPage calls it on destroy to prevent stale refs +- **BUG-041**: Voice store `resetStore` missing new fields in tests — fixed 2026-03-18 + - Added `localCamera`/`localScreenshare` to resetStore; tests for setLocalCamera, + setLocalScreenshare, setLocalSpeaking +- **BUG-042**: `updateUser` and UserBar option callbacks untested — fixed 2026-03-18 + - Added updateUser tests to auth.store.test.ts; added mute/deafen callback tests + to user-bar.test.ts +- **BUG-043**: `switchInputDevice` triggers `getUserMedia` with no session — fixed 2026-03-18 + - Added `webrtcService === null` guard; skips mic acquisition when not in voice +- **BUG-044**: `confirm()` blocks Tauri WebView renderer — fixed 2026-03-18 + - Replaced synchronous `confirm()` with double-click-to-delete pattern using toast +- **BUG-045**: Image `att.url` not scheme-validated — fixed 2026-03-18 + - Added `isSafeUrl()` check; only http/https URLs render as images + +- **BUG-031**: VoiceAudioTab device selection not applied to WebRTC — fixed 2026-03-18 + - Added `switchInputDevice`/`switchOutputDevice` to voiceSession; VoiceAudioTab + calls on change +- **BUG-032**: No WS handlers for channel_create/update/delete — closed 2026-03-18 + - Handlers wired in dispatcher.ts:173-200; `wireDispatcher` called in main.ts:141 +- **BUG-033**: No WS handlers for member_update/member_ban — closed 2026-03-18 + - Handlers wired in dispatcher.ts:219-229; `wireDispatcher` called in main.ts:141 +- **BUG-034**: InviteManager mutates state before API resolves — closed 2026-03-18 + - Filter is inside `.then()` — only runs after promise resolves +- **BUG-035**: DmSidebar active highlight never updates — fixed 2026-03-18 + - Click handler now removes `.active` from siblings and adds to clicked item +- **BUG-036**: WebRTC failure silently disconnects user — fixed 2026-03-18 + - Added `setOnError` callback pattern; MainPage wires it to toast + +- **BUG-026**: Image attachments render placeholder, not actual images — fixed 2026-03-18 + - Replaced placeholder `
` with `` + error fallback +- **BUG-030**: Orphaned MessageActionsBar + ReactionBar components — fixed 2026-03-18 + - Deleted dead code: both components and their tests (never imported anywhere) + +- **BUG-024**: Reactions cannot be removed — fixed 2026-03-18 + - Toggles `reaction_add`/`reaction_remove` based on `me` field per PROTOCOL.md +- **BUG-028**: Message delete fires with no confirmation — fixed 2026-03-18 + - Added `confirm()` guard before sending `chat_delete`; success toast added +- **BUG-029**: Message edit sends without validation — fixed 2026-03-18 + - Added empty-check, no-op detection, and toast feedback +- **BUG-037**: Reaction rate limit silently swallows clicks — fixed 2026-03-18 + - Shows error toast when rate limited +- **BUG-038**: No toasts for chat edit/delete/reaction operations — fixed 2026-03-18 + - Added success toasts for delete and edit; error toast for rate-limited reactions + +- **BUG-021**: Camera toggle hardcoded to `enabled: false` — fixed 2026-03-18 + - Added `localCamera` state to voice store; toggle reads actual state +- **BUG-022**: Screenshare handler completely empty — fixed 2026-03-18 + - Added `localScreenshare` state; sends `voice_screenshare` WS message +- **BUG-023**: UserBar mute/deafen buttons have no event listeners — fixed 2026-03-18 + - Added `UserBarOptions` interface; MainPage passes mute/deafen handlers +- **BUG-027**: VAD speaking state never sent to server — fixed 2026-03-18 + - Wired `vadDetector.onSpeakingChange` → `setLocalSpeaking` in voice store + +- **BUG-020**: Account settings do nothing — fixed 2026-03-18 + - Wired `api.changePassword()` and `api.updateProfile()` into MainPage callbacks + - Added `updateUser()` to auth store for username sync after profile edit + - Added toast feedback for success/error on both operations +- **BUG-025**: Theme changes don't sync to uiStore — fixed 2026-03-18 + - Added `setTheme(name)` call in AppearanceTab click handler + - Store now stays in sync with localStorage and applied CSS + +- **BUG-001**: NilHub tests pass mockHub not nil — fixed 2026-03-18 (#12) + - Added nil hub tests for PatchUser ban and role change paths +- **BUG-002**: window-state.ts untyped `any` — fixed (already resolved) (#10) + - Code already uses proper types (`Record`, `typeof import(...)`) + - No `any` or `getInvoke()` pattern found — was fixed in a prior refactor + +- **BUG-003**: Hub double-close panic — fixed 2026-03-17 (issue #3) + - Added `sync.Once` guard on quit channel close +- **BUG-004**: golangci-lint version incompatibility — fixed 2026-03-17 (issue #4) + - Pinned compatible linter version in CI +- **BUG-005**: SearchMessages missing validation — fixed 2026-03-17 (issue #5) + - Added input length and channel access checks +- **BUG-006**: InviteManager unhandled rejections — fixed 2026-03-17 (issue #6) + - Wrapped async calls with proper error handling +- **BUG-007**: Test schema missing columns — fixed 2026-03-17 (issue #7) + - Synced test fixtures with production schema +- **BUG-008**: Capacity over-allocation in + getReactionsBatch — fixed 2026-03-17 (#9) + - Corrected slice capacity to match actual batch size +- **BUG-009**: golangci-lint violations blocking CI — fixed 2026-03-17 (issue #13) + - Resolved all outstanding lint errors +- **BUG-010**: buildReady() silent hang — fixed 2026-03-17 (T-038) + - Server now sends INTERNAL error to client on buildReady failure +- **BUG-011**: Banned user keeps chatting — fixed 2026-03-17 (T-044) + - Added ban check to periodic session validation in WS handler +- **BUG-012**: Reaction error DB leak — fixed 2026-03-17 (T-039) + - Sanitized error messages, raw DB errors logged server-side only +- **BUG-013**: WS proxy no connect timeout — fixed 2026-03-17 (T-046) + - Added 10s connect timeout to Rust WS proxy +- **BUG-014**: Channel delete stale view — fixed 2026-03-17 (T-045) + - Client auto-redirects to first text channel on active channel deletion +- **BUG-015**: Missing rate limits on chat_edit/chat_delete — fixed 2026-03-17 (#18) + - Added rate limiting to edit and delete message endpoints +- **BUG-016**: Cert mismatch event not handled — fixed 2026-03-17 (#19) + - TOFU flow now properly handles certificate mismatch events +- **BUG-017**: SHA-256 fingerprint validation incorrect — fixed 2026-03-17 (#20) + - Fixed fingerprint comparison logic in cert pinning +- **BUG-018**: Session+ban query N+1 — fixed 2026-03-17 (#21) + - Optimized with JOIN query instead of separate lookups +- **BUG-019**: Channel position sorting broken — fixed 2026-03-17 (#22) + - Channels now sort correctly by position field