diff --git a/CODE_REVIEW.md b/CODE_REVIEW.md deleted file mode 100644 index f188b2f3..00000000 --- a/CODE_REVIEW.md +++ /dev/null @@ -1,59 +0,0 @@ -# Code Review Findings (2026-03-16) - -Scope: server (Go), Tauri client (TS/Rust), spec/docs. -No tests run. - -> **Status: ALL RESOLVED** (2026-03-16). -> Commits: `0680a32`, `0bd9165`, `642cd54`. - -## Critical - -1. Auth bypass: any client can `channel_focus` any channel - and REST returns data without permission checks. - Impact: cross-channel data exposure. - Fix: enforce `READ_MESSAGES` on focus, GET channels, - messages, and search; track access in hub routing. -2. WS auth failure mismatch: server sends `type: "error"` - with `AUTH_ERROR`; client expects `auth_error` type. - Impact: infinite reconnect with bad token. - Fix: emit `auth_error` per spec; client stops on it. -3. Member/role protocol mismatch: server sends flat - `member_join` with `role_id`; client expects nested - `payload.user` with role string. - Impact: runtime crash on join, wrong role display. - Fix: align payloads to `UserWithRole` shape. -4. `chat_message` omits `attachments`; client iterates - it unconditionally. Impact: crash on render. - Fix: always include `attachments: []` in payloads. - -## High - -1. REST message/search responses diverge from API spec. - Missing `user` object, attachments, reactions, pinned, - deleted fields. Impact: clients mis-parse data. - Fix: update handlers/queries to match spec shapes. -2. Health endpoint mismatch: server `/health`, client - `/api/v1/health`, docs `/api/health`. - Impact: health checks always fail. - Fix: pick one canonical path, update all. -3. Attachments parsed on `chat_send` but never persisted. - Impact: attachments silently dropped. - Fix: validate IDs, persist, include in responses. - -## Medium - -1. WS heartbeat `ping` treated as unknown by server. - Impact: noisy error logs. - Fix: add `ping` handler or disable client heartbeat. -2. API base path inconsistent: docs say `/api`, code - uses `/api/v1`. Impact: wrong integration URLs. - Fix: pick one path, update code and docs. - -**Low** - -1. `auth_ok` does not include role, but UI expects role-based color coding. Even if `member_join` and `ready` are fixed, initial auth state will still lack role. Impact: inconsistent role display until ready arrives. Evidence: `D:\Local-Lab\Coding\Repos\OwnCord\Server\ws\serve.go:171-191`, `D:\Local-Lab\Coding\Repos\OwnCord\Client\tauri-client\src\lib\types.ts:163-167`, `D:\Local-Lab\Coding\Repos\OwnCord\Client\tauri-client\src\lib\dispatcher.ts:55-63`. Recommendation: include role in `auth_ok` or adjust client to tolerate missing role until ready. - -**Test Gaps** - -1. No automated coverage for authorization of channel read access (REST and WS channel focus). Given the permission system, this should have dedicated tests to prevent regressions. Suggested targets: `D:\Local-Lab\Coding\Repos\OwnCord\Server\api\channel_handler_test.go` and WS tests in `D:\Local-Lab\Coding\Repos\OwnCord\Server\ws\handlers_test.go`. -2. No contract tests asserting server responses match `API.md` and `PROTOCOL.md`. The current drift would have been caught by simple golden tests. diff --git a/Client/tauri-client/src/components/MessageInput.ts b/Client/tauri-client/src/components/MessageInput.ts index 7f95b41e..a0784c48 100644 --- a/Client/tauri-client/src/components/MessageInput.ts +++ b/Client/tauri-client/src/components/MessageInput.ts @@ -24,6 +24,7 @@ export type MessageInputComponent = MountableComponent & { const TYPING_THROTTLE_MS = 3_000; const MAX_TEXTAREA_HEIGHT = 200; +const SEND_DEBOUNCE_MS = 200; export function createMessageInput( options: MessageInputOptions, @@ -34,6 +35,7 @@ export function createMessageInput( let state = { replyTo: null as { messageId: number; username: string } | null, editing: null as { messageId: number } | null }; let lastTypingTime = 0; + let lastSendTime = 0; let textarea: HTMLTextAreaElement | null = null; let replyBar: HTMLDivElement | null = null; @@ -69,6 +71,11 @@ export function createMessageInput( const content = textarea.value.trim(); if (content.length === 0) return; + // Debounce to prevent double-click duplicate sends + const now = Date.now(); + if (now - lastSendTime < SEND_DEBOUNCE_MS) return; + lastSendTime = now; + if (state.editing !== null) { options.onEditMessage(state.editing.messageId, content); cancelEdit(); @@ -112,7 +119,7 @@ export function createMessageInput( } function mount(container: Element): void { - root = createElement("div", { class: "message-input-wrap" }); + root = createElement("div", { class: "message-input-wrap", "data-testid": "message-input" }); replyBar = createElement("div", { class: "reply-bar" }); const replyInner = createElement("div", { class: "reply-bar-inner" }); @@ -137,11 +144,12 @@ export function createMessageInput( { class: "input-btn attach-btn", "aria-label": "Attach file" }, "+"); textarea = createElement("textarea", { class: "msg-textarea", placeholder: `Message #${options.channelName}`, rows: "1", + "data-testid": "msg-textarea", }); const emojiBtn = createElement("button", { class: "input-btn emoji-btn", "aria-label": "Emoji" }, "\uD83D\uDE00"); const sendBtn = createElement("button", - { class: "input-btn send-btn", "aria-label": "Send message" }, "\u27A4"); + { class: "input-btn send-btn", "aria-label": "Send message", "data-testid": "send-btn" }, "\u27A4"); textarea.addEventListener("input", () => { autoResize(); maybeEmitTyping(); }, { signal }); textarea.addEventListener("keydown", (e: KeyboardEvent) => { diff --git a/Client/tauri-client/src/components/MessageList.ts b/Client/tauri-client/src/components/MessageList.ts index 288cd338..5780e89f 100644 --- a/Client/tauri-client/src/components/MessageList.ts +++ b/Client/tauri-client/src/components/MessageList.ts @@ -11,7 +11,7 @@ import { } from "@lib/dom"; import type { MountableComponent } from "@lib/safe-render"; import type { Attachment } from "@lib/types"; -import { messagesStore, getChannelMessages } from "@stores/messages.store"; +import { messagesStore, getChannelMessages, hasMoreMessages } from "@stores/messages.store"; import type { Message } from "@stores/messages.store"; import { membersStore } from "@stores/members.store"; @@ -274,6 +274,7 @@ function renderMessage( const el = createElement("div", { class: isGrouped ? "message grouped" : "message", + "data-testid": `message-${msg.id}`, }); // Avatar (hidden for grouped messages via CSS) @@ -336,25 +337,25 @@ function renderMessage( if (!msg.deleted) { const actionsBar = createElement("div", { class: "msg-actions-bar" }); - const reactBtn = createElement("button", {}, "\uD83D\uDE04"); + const reactBtn = createElement("button", { "data-testid": `msg-react-${msg.id}` }, "\uD83D\uDE04"); reactBtn.title = "React"; reactBtn.addEventListener("click", () => opts.onReactionClick(msg.id, ""), { signal }); actionsBar.appendChild(reactBtn); - const replyBtn = createElement("button", {}, "\u21A9"); + const replyBtn = createElement("button", { "data-testid": `msg-reply-${msg.id}` }, "\u21A9"); replyBtn.title = "Reply"; replyBtn.addEventListener("click", () => opts.onReplyClick(msg.id), { signal }); actionsBar.appendChild(replyBtn); if (msg.user.id === opts.currentUserId) { - const editBtn = createElement("button", {}, "\u270E"); + const editBtn = createElement("button", { "data-testid": `msg-edit-${msg.id}` }, "\u270E"); editBtn.title = "Edit"; editBtn.addEventListener("click", () => opts.onEditClick(msg.id), { signal }); actionsBar.appendChild(editBtn); } if (msg.user.id === opts.currentUserId) { - const deleteBtn = createElement("button", {}, "\uD83D\uDDD1"); + const deleteBtn = createElement("button", { "data-testid": `msg-delete-${msg.id}` }, "\uD83D\uDDD1"); deleteBtn.title = "Delete"; deleteBtn.addEventListener("click", () => opts.onDeleteClick(msg.id), { signal }); actionsBar.appendChild(deleteBtn); @@ -414,14 +415,26 @@ export function createMessageList(options: MessageListOptions): MountableCompone } let loadingOlder = false; + let prevMessageCount = 0; + + // Reset loadingOlder when the store updates with new messages (fetch completed) + const unsubLoadingReset = messagesStore.subscribe(() => { + const msgs = getChannelMessages(options.channelId); + if (msgs.length !== prevMessageCount) { + prevMessageCount = msgs.length; + loadingOlder = false; + } + }); function handleScroll(): void { if (messagesContainer === null) return; - if (messagesContainer.scrollTop < SCROLL_TOP_THRESHOLD && !loadingOlder) { + if ( + messagesContainer.scrollTop < SCROLL_TOP_THRESHOLD + && !loadingOlder + && hasMoreMessages(options.channelId) + ) { loadingOlder = true; options.onScrollTop(); - // Reset after a short delay to allow the fetch to land - setTimeout(() => { loadingOlder = false; }, 500); } } @@ -448,6 +461,7 @@ export function createMessageList(options: MessageListOptions): MountableCompone function destroy(): void { ac.abort(); + unsubLoadingReset(); for (const unsub of unsubscribers) { unsub(); } unsubscribers.length = 0; if (root !== null) { root.remove(); root = null; } diff --git a/Client/tauri-client/src/components/SettingsOverlay.ts b/Client/tauri-client/src/components/SettingsOverlay.ts index 33d4e99e..d2c6c198 100644 --- a/Client/tauri-client/src/components/SettingsOverlay.ts +++ b/Client/tauri-client/src/components/SettingsOverlay.ts @@ -72,6 +72,22 @@ function applyTheme(name: ThemeName): void { } } +/** + * Apply stored appearance preferences (theme, font size, compact mode). + * Call at app startup so the UI doesn't flash default styles. + */ +export function applyStoredAppearance(): void { + applyTheme(loadPref("theme", "dark")); + document.documentElement.style.setProperty( + "--font-size", + `${loadPref("fontSize", 16)}px`, + ); + document.documentElement.classList.toggle( + "compact-mode", + loadPref("compactMode", false), + ); +} + // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- @@ -590,7 +606,7 @@ export function createSettingsOverlay( // ---- MountableComponent --------------------------------------------------- function mount(container: Element): void { - root = createElement("div", { class: "settings-overlay" }); + root = createElement("div", { class: "settings-overlay", "data-testid": "settings-overlay" }); // Sidebar const sidebar = createElement("div", { class: "settings-sidebar" }); diff --git a/Client/tauri-client/src/lib/dispatcher.ts b/Client/tauri-client/src/lib/dispatcher.ts index 92208ae1..0e1317d6 100644 --- a/Client/tauri-client/src/lib/dispatcher.ts +++ b/Client/tauri-client/src/lib/dispatcher.ts @@ -35,6 +35,7 @@ import { setVoiceConfig, setSpeakers, joinVoiceChannel, + leaveVoiceChannel, } from "@stores/voice.store"; import { createLogger } from "./logger"; @@ -217,6 +218,11 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup { unsubs.push( ws.on("voice_leave", (payload) => { removeVoiceUser(payload); + // Clear local voice state if the current user was removed (kick/disconnect) + const currentUserId = authStore.getState().user?.id ?? 0; + if (payload.user_id === currentUserId) { + leaveVoiceChannel(); + } }), ); diff --git a/Client/tauri-client/src/lib/ws.ts b/Client/tauri-client/src/lib/ws.ts index 8a587f32..0f219ab5 100644 --- a/Client/tauri-client/src/lib/ws.ts +++ b/Client/tauri-client/src/lib/ws.ts @@ -234,6 +234,7 @@ export function createWsClient() { await ensureTauriApis(); if (tauriInvoke === null) { log.error("Tauri APIs not available, cannot connect WebSocket"); + setState("disconnected"); return; } diff --git a/Client/tauri-client/src/main.ts b/Client/tauri-client/src/main.ts index adb8b83e..e9e144d6 100644 --- a/Client/tauri-client/src/main.ts +++ b/Client/tauri-client/src/main.ts @@ -1,5 +1,10 @@ // OwnCord Tauri v2 Client — Entry Point +import "@styles/tokens.css"; +import "@styles/base.css"; +import "@styles/login.css"; +import "@styles/app.css"; + import { installGlobalErrorHandlers, safeMount } from "@lib/safe-render"; import { createRouter } from "@lib/router"; import { createApiClient } from "@lib/api"; @@ -8,6 +13,7 @@ import { wireDispatcher } from "@lib/dispatcher"; import { authStore, setAuth, clearAuth } from "@stores/auth.store"; import { createConnectPage } from "@pages/ConnectPage"; import { createMainPage } from "@pages/MainPage"; +import { applyStoredAppearance } from "@components/SettingsOverlay"; import { createConnectedOverlay } from "@components/ConnectedOverlay"; import type { ConnectedOverlayControl } from "@components/ConnectedOverlay"; import { createLogger } from "@lib/logger"; @@ -19,6 +25,9 @@ const log = createLogger("main"); // Install global error handlers first installGlobalErrorHandlers(); +// Apply stored theme/font/compact preferences before first render +applyStoredAppearance(); + const appEl = document.getElementById("app"); if (!appEl) { throw new Error("Missing #app element"); @@ -26,7 +35,10 @@ if (!appEl) { // Create core services const router = createRouter("connect"); -const api = createApiClient({ host: "" }); +const api = createApiClient({ host: "" }, () => { + log.warn("Session expired (401), clearing auth"); + clearAuth(); +}); const ws = createWsClient(); let dispatcherCleanup: (() => void) | null = null; let connectedOverlay: ConnectedOverlayControl | null = null; @@ -44,6 +56,8 @@ function renderPage(pageId: "connect" | "main"): void { // Shared helper for post-auth WS connect + overlay flow function wirePostAuth(host: string, token: string, username: string): void { api.setConfig({ token }); + // Store token in authStore so the dispatcher's auth_ok handler has it + authStore.setState((prev) => ({ ...prev, token })); ws.connect({ host, token }); dispatcherCleanup = wireDispatcher(ws); diff --git a/Client/tauri-client/src/pages/MainPage.ts b/Client/tauri-client/src/pages/MainPage.ts index 52e95041..4c7e4fb5 100644 --- a/Client/tauri-client/src/pages/MainPage.ts +++ b/Client/tauri-client/src/pages/MainPage.ts @@ -20,8 +20,15 @@ import { createServerBanner } from "@components/ServerBanner"; import type { ServerBannerControl } from "@components/ServerBanner"; import { createSettingsOverlay } from "@components/SettingsOverlay"; import { createQuickSwitcher } from "@components/QuickSwitcher"; +import { createInviteManager } from "@components/InviteManager"; +import type { InviteItem } from "@components/InviteManager"; +import type { InviteResponse } from "@lib/types"; +import { createToastContainer } from "@components/Toast"; +import type { ToastContainer } from "@components/Toast"; +import { createPinnedMessages } from "@components/PinnedMessages"; +import type { PinnedMessage } from "@components/PinnedMessages"; import { authStore, clearAuth } from "@stores/auth.store"; -import { closeSettings } from "@stores/ui.store"; +import { closeSettings, toggleMemberList, uiStore } from "@stores/ui.store"; import { channelsStore, getActiveChannel, setActiveChannel } from "@stores/channels.store"; import { voiceStore, @@ -82,6 +89,12 @@ export function createMainPage(options: MainPageOptions): MountableComponent { // Abort controller for channel-scoped async operations (e.g. message fetch) let channelAbort: AbortController | null = null; + // Toast container for user-facing error feedback + let toast: ToastContainer | null = null; + + // Pinned panel toggle — assigned inside mount(), called from buildChatHeader() + let togglePinnedPanel: () => Promise = async () => {}; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -104,6 +117,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent { } catch (err) { if (!signal.aborted) { log.error("Failed to load messages", { channelId, error: String(err) }); + toast?.show("Failed to load messages", "error"); } } } @@ -125,6 +139,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent { } catch (err) { if (!signal.aborted) { log.error("Failed to load older messages", { channelId, error: String(err) }); + toast?.show("Failed to load older messages", "error"); } } } @@ -134,13 +149,21 @@ export function createMainPage(options: MainPageOptions): MountableComponent { // --------------------------------------------------------------------------- function buildChatHeader(): HTMLDivElement { - const header = createElement("div", { class: "chat-header" }); + const header = createElement("div", { class: "chat-header", "data-testid": "chat-header" }); const hash = createElement("span", { class: "ch-hash" }, "#"); - chatHeaderName = createElement("span", { class: "ch-name" }, "general"); + chatHeaderName = createElement("span", { class: "ch-name", "data-testid": "chat-header-name" }, "general"); const divider = createElement("div", { class: "ch-divider" }); chatHeaderTopic = createElement("span", { class: "ch-topic" }, ""); const tools = createElement("div", { class: "ch-tools" }); + const pinBtn = createElement("button", { + type: "button", + class: "pin-btn", + title: "Pins", + "aria-label": "Pins", + "data-testid": "pin-btn", + }, "\uD83D\uDCCC"); + pinBtn.addEventListener("click", () => { void togglePinnedPanel(); }); const searchInput = createElement("input", { class: "search-input", type: "text", @@ -149,8 +172,10 @@ export function createMainPage(options: MainPageOptions): MountableComponent { const membersToggle = createElement("button", { type: "button", "aria-label": "Toggle member list", + "data-testid": "members-toggle", }, "\uD83D\uDC65"); - appendChildren(tools, searchInput, membersToggle); + membersToggle.addEventListener("click", () => toggleMemberList()); + appendChildren(tools, searchInput, pinBtn, membersToggle); appendChildren(header, hash, chatHeaderName, divider, chatHeaderTopic, tools); return header; @@ -163,11 +188,13 @@ export function createMainPage(options: MainPageOptions): MountableComponent { function mountChannelComponents(channelId: number, channelName: string): void { // Skip if already mounted for this channel if (currentChannelId === channelId) return; - currentChannelId = channelId; // Tear down previous instances destroyChannelComponents(); + // Set after destroy (which resets currentChannelId to null) + currentChannelId = channelId; + // New abort controller for this channel's async work channelAbort = new AbortController(); const signal = channelAbort.signal; @@ -234,6 +261,11 @@ export function createMainPage(options: MainPageOptions): MountableComponent { channelId, channelName, onSend: (content: string, replyTo: number | null) => { + if (ws.getState() !== "connected") { + log.warn("Cannot send message: not connected"); + toast?.show("Not connected — message not sent", "error"); + return; + } ws.send({ type: "chat_send", payload: { @@ -342,7 +374,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent { ); // --- Main .app row --- - const app = createElement("div", { class: "app" }); + const app = createElement("div", { class: "app", "data-testid": "app-layout" }); // Server strip const serverStripSlot = createElement("div", {}); @@ -351,7 +383,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent { children.push(serverStrip); // Channel sidebar (composed: sidebar + voice widget + user bar) - const sidebarWrapper = createElement("div", { class: "channel-sidebar" }); + const sidebarWrapper = createElement("div", { class: "channel-sidebar", "data-testid": "channel-sidebar" }); const channelSidebarSlot = createElement("div", {}); const channelSidebar = createChannelSidebar(); @@ -366,6 +398,19 @@ export function createMainPage(options: MainPageOptions): MountableComponent { } } + // Invite button in sidebar header + const sidebarHeader = sidebarWrapper.querySelector(".channel-sidebar-header"); + if (sidebarHeader !== null) { + const inviteBtn = createElement("button", { + class: "invite-btn", + title: "Invite", + }, "Invite"); + inviteBtn.addEventListener("click", () => { + void openInviteManager(); + }); + sidebarHeader.appendChild(inviteBtn); + } + // Voice widget (hidden when not in voice) const voiceWidgetSlot = createElement("div", {}); const voiceWidget = createVoiceWidget({ @@ -404,12 +449,12 @@ export function createMainPage(options: MainPageOptions): MountableComponent { sidebarWrapper.appendChild(userBarSlot); // Chat area - const chatArea = createElement("div", { class: "chat-area" }); + const chatArea = createElement("div", { class: "chat-area", "data-testid": "chat-area" }); chatArea.appendChild(buildChatHeader()); - messagesSlot = createElement("div", { class: "messages-slot" }); - typingSlot = createElement("div", { class: "typing-slot" }); - inputSlot = createElement("div", { class: "input-slot" }); + messagesSlot = createElement("div", { class: "messages-slot", "data-testid": "messages-slot" }); + typingSlot = createElement("div", { class: "typing-slot", "data-testid": "typing-slot" }); + inputSlot = createElement("div", { class: "input-slot", "data-testid": "input-slot" }); appendChildren(chatArea, messagesSlot, typingSlot, inputSlot); // Member list @@ -418,6 +463,15 @@ export function createMainPage(options: MainPageOptions): MountableComponent { memberList.mount(memberListSlot); children.push(memberList); + // Wire member list visibility to uiStore + const memberListEl = memberListSlot.querySelector(".member-list"); + const unsubMemberList = uiStore.subscribe((state) => { + if (memberListEl !== null) { + memberListEl.classList.toggle("hidden", !state.memberListVisible); + } + }); + unsubscribers.push(unsubMemberList); + appendChildren(app, serverStripSlot, sidebarWrapper, chatArea, memberListSlot); root.appendChild(app); @@ -469,6 +523,136 @@ export function createMainPage(options: MainPageOptions): MountableComponent { closeQuickSwitcher(); }); + // Invite manager overlay + let inviteManager: MountableComponent | null = null; + + function closeInviteManager(): void { + if (inviteManager !== null) { + inviteManager.destroy?.(); + inviteManager = null; + } + } + + function mapInviteResponse(r: InviteResponse): InviteItem { + // Server may include extra fields (e.g. created_by) beyond the typed response + const extra = r as unknown as Record; + const createdBy = typeof extra["created_by"] === "object" + && extra["created_by"] !== null + ? (extra["created_by"] as { username?: string }).username ?? "unknown" + : "unknown"; + const uses = r.use_count + ?? (typeof extra["uses"] === "number" ? (extra["uses"] as number) : 0); + return { + code: r.code, + createdBy, + createdAt: r.expires_at ?? "", + uses, + maxUses: r.max_uses, + expiresAt: r.expires_at, + }; + } + + async function openInviteManager(): Promise { + if (inviteManager !== null || root === null) return; + try { + const raw = await api.getInvites(); + const invites = raw.map(mapInviteResponse); + inviteManager = createInviteManager({ + invites, + onCreateInvite: async () => { + const created = await api.createInvite({}); + return mapInviteResponse(created); + }, + onRevokeInvite: async (code: string) => { + const raw2 = await api.getInvites(); + const match = raw2.find((i) => i.code === code); + if (match !== undefined) { + await api.revokeInvite(match.id); + } + }, + onCopyLink: (code: string) => { + void navigator.clipboard.writeText(code); + }, + onClose: closeInviteManager, + }); + if (root !== null) { + inviteManager.mount(root); + } + } catch (err) { + log.error("Failed to open invite manager", { error: String(err) }); + toast?.show("Failed to load invites", "error"); + } + } + + unsubscribers.push(() => { + closeInviteManager(); + }); + + // Pinned messages panel + let pinnedPanel: MountableComponent | null = null; + + function closePinnedPanel(): void { + if (pinnedPanel !== null) { + pinnedPanel.destroy?.(); + pinnedPanel = null; + } + } + + function mapToPinnedMessage(msg: { + readonly id: number; + readonly user: { readonly username: string }; + readonly content: string; + readonly created_at?: string; + readonly timestamp?: string; + }): PinnedMessage { + return { + id: msg.id, + author: msg.user.username, + content: msg.content, + timestamp: msg.created_at ?? msg.timestamp ?? "", + }; + } + + togglePinnedPanel = async (): Promise => { + if (pinnedPanel !== null) { + closePinnedPanel(); + return; + } + if (root === null || currentChannelId === null) return; + const channelId = currentChannelId; + try { + const resp = await api.getPins(channelId); + const pins = resp.messages.map(mapToPinnedMessage); + pinnedPanel = createPinnedMessages({ + channelId, + pinnedMessages: pins, + onJumpToMessage: (_msgId: number) => { + closePinnedPanel(); + }, + onUnpin: (msgId: number) => { + void api.unpinMessage(channelId, msgId); + closePinnedPanel(); + }, + onClose: closePinnedPanel, + }); + if (root !== null) { + pinnedPanel.mount(root); + } + } catch (err) { + log.error("Failed to load pinned messages", { error: String(err) }); + toast?.show("Failed to load pinned messages", "error"); + } + }; + + unsubscribers.push(() => { + closePinnedPanel(); + }); + + // Toast container for error feedback + toast = createToastContainer(); + toast.mount(root); + children.push(toast); + container.appendChild(root); // --- Subscribe to channel changes --- diff --git a/Client/tauri-client/src/stores/messages.store.ts b/Client/tauri-client/src/stores/messages.store.ts index deeed9af..75962bd4 100644 --- a/Client/tauri-client/src/stores/messages.store.ts +++ b/Client/tauri-client/src/stores/messages.store.ts @@ -303,3 +303,8 @@ export function getChannelMessages(channelId: number): readonly Message[] { export function isChannelLoaded(channelId: number): boolean { return messagesStore.select((s) => s.loadedChannels.has(channelId)); } + +/** Check whether a channel has more older messages to fetch. */ +export function hasMoreMessages(channelId: number): boolean { + return messagesStore.select((s) => s.hasMore.get(channelId) ?? false); +} diff --git a/Server/db/attachment_queries.go b/Server/db/attachment_queries.go index 9690b9c8..ed754d28 100644 --- a/Server/db/attachment_queries.go +++ b/Server/db/attachment_queries.go @@ -90,7 +90,7 @@ func (d *DB) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]AttachmentI return nil, fmt.Errorf("GetAttachmentsByMessageIDs scan: %w", scanErr) } ai.ID = id - ai.URL = "/api/files/" + id + ai.URL = "/api/v1/files/" + id result[msgID] = append(result[msgID], ai) } return result, nil diff --git a/Server/ws/handlers.go b/Server/ws/handlers.go index f0fa7a85..c44896b0 100644 --- a/Server/ws/handlers.go +++ b/Server/ws/handlers.go @@ -118,7 +118,7 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) { // Rate limit. ratKey := fmt.Sprintf("chat:%d", c.userID) if !h.limiter.Allow(ratKey, chatRateLimit, chatWindow) { - c.sendMsg(buildErrorMsg("RATE_LIMITED", "too many messages")) + c.sendMsg(buildRateLimitError("too many messages", chatWindow.Seconds())) return } @@ -146,8 +146,7 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) { } // Permission check. - if !h.hasChannelPerm(c, channelID, permissions.ReadMessages|permissions.SendMessages) { - c.sendMsg(buildErrorMsg("FORBIDDEN", "missing SEND_MESSAGES permission")) + if !h.requireChannelPerm(c, channelID, permissions.ReadMessages|permissions.SendMessages, "SEND_MESSAGES") { return } @@ -171,6 +170,13 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) { return } + // Check attachment permission before persisting anything. + if len(p.Attachments) > 0 { + if !h.requireChannelPerm(c, channelID, permissions.AttachFiles, "ATTACH_FILES") { + return + } + } + // Persist message. msgID, err := h.db.CreateMessage(channelID, c.userID, content, p.ReplyTo) if err != nil { @@ -182,17 +188,15 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) { // Link attachments if provided. var attachments []map[string]any if len(p.Attachments) > 0 { - if !h.hasChannelPerm(c, channelID, permissions.AttachFiles) { - c.sendMsg(buildErrorMsg("FORBIDDEN", "missing ATTACH_FILES permission")) - return - } linked, linkErr := h.db.LinkAttachmentsToMessage(msgID, p.Attachments) if linkErr != nil { slog.Error("ws handleChatSend LinkAttachments", "err", linkErr) } if linked > 0 { attMap, attErr := h.db.GetAttachmentsByMessageIDs([]int64{msgID}) - if attErr == nil { + if attErr != nil { + slog.Error("ws handleChatSend GetAttachments", "err", attErr) + } else { for _, ai := range attMap[msgID] { attachments = append(attachments, map[string]any{ "id": ai.ID, @@ -227,7 +231,7 @@ func (h *Hub) handleChatSend(c *Client, reqID string, payload json.RawMessage) { c.sendMsg(buildChatSendOK(reqID, msgID, msg.Timestamp)) // Broadcast to channel. - broadcast := buildChatMessage(msgID, channelID, c.userID, username, avatar, content, msg.Timestamp, p.ReplyTo, attachments) + broadcast := buildChatMessage(msgID, channelID, c.userID, username, avatar, c.roleName, content, msg.Timestamp, p.ReplyTo, attachments) h.BroadcastToChannel(channelID, broadcast) } @@ -310,7 +314,7 @@ func (h *Hub) handleChatDelete(c *Client, _ string, payload json.RawMessage) { func (h *Hub) handleReaction(c *Client, add bool, payload json.RawMessage) { ratKey := fmt.Sprintf("reaction:%d", c.userID) if !h.limiter.Allow(ratKey, reactionRateLimit, reactionWindow) { - c.sendMsg(buildErrorMsg("RATE_LIMITED", "too many reactions")) + c.sendMsg(buildRateLimitError("too many reactions", reactionWindow.Seconds())) return } @@ -335,6 +339,13 @@ func (h *Hub) handleReaction(c *Client, add bool, payload json.RawMessage) { c.sendMsg(buildErrorMsg("BAD_REQUEST", "emoji too long")) return } + // Reject control characters (U+0000–U+001F, U+007F) to prevent injection. + for _, r := range p.Emoji { + if r < 0x20 || r == 0x7F { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "emoji contains invalid characters")) + return + } + } msg, err := h.db.GetMessage(msgID) if err != nil || msg == nil { @@ -342,8 +353,7 @@ func (h *Hub) handleReaction(c *Client, add bool, payload json.RawMessage) { return } - if !h.hasChannelPerm(c, msg.ChannelID, permissions.AddReactions) { - c.sendMsg(buildErrorMsg("FORBIDDEN", "missing ADD_REACTIONS permission")) + if !h.requireChannelPerm(c, msg.ChannelID, permissions.AddReactions, "ADD_REACTIONS") { return } @@ -388,7 +398,7 @@ func (h *Hub) handleTyping(c *Client, payload json.RawMessage) { func (h *Hub) handlePresence(c *Client, payload json.RawMessage) { ratKey := fmt.Sprintf("presence:%d", c.userID) if !h.limiter.Allow(ratKey, presenceRateLimit, presenceWindow) { - c.sendMsg(buildErrorMsg("RATE_LIMITED", "too many presence updates")) + c.sendMsg(buildRateLimitError("too many presence updates", presenceWindow.Seconds())) return } @@ -434,6 +444,17 @@ func (h *Hub) hasChannelPerm(c *Client, channelID int64, perm int64) bool { return effective&perm == perm } +// requireChannelPerm checks whether the client has the given permission on the +// channel. If not, it sends a FORBIDDEN error to the client and returns false. +// The permLabel should be the human-readable permission name (e.g. "SEND_MESSAGES"). +func (h *Hub) requireChannelPerm(c *Client, channelID int64, perm int64, permLabel string) bool { + if h.hasChannelPerm(c, channelID, perm) { + return true + } + c.sendMsg(buildErrorMsg("FORBIDDEN", "missing "+permLabel+" permission")) + return false +} + // broadcastExclude sends msg to all channel members except excludeUserID. func (h *Hub) broadcastExclude(channelID, excludeUserID int64, msg []byte) { h.mu.RLock() @@ -454,6 +475,7 @@ func (h *Hub) broadcastExclude(channelID, excludeUserID int64, msg []byte) { // handleChannelFocus sets which channel the client is currently viewing, // so channel-scoped broadcasts (chat messages, typing) reach them. +// Also updates read_states so unread counts decrease when the user views a channel. func (h *Hub) handleChannelFocus(c *Client, payload json.RawMessage) { chID, err := parseChannelID(payload) if err != nil || chID <= 0 { @@ -461,12 +483,19 @@ func (h *Hub) handleChannelFocus(c *Client, payload json.RawMessage) { } // Permission check: user must have READ_MESSAGES on the target channel. - if !h.hasChannelPerm(c, chID, permissions.ReadMessages) { - c.sendMsg(buildErrorMsg("FORBIDDEN", "no permission to view this channel")) + if !h.requireChannelPerm(c, chID, permissions.ReadMessages, "READ_MESSAGES") { return } c.mu.Lock() c.channelID = chID c.mu.Unlock() + + // Mark channel as read by updating read_states to the latest message. + latestID, latestErr := h.db.GetLatestMessageID(chID) + if latestErr == nil && latestID > 0 { + if rsErr := h.db.UpdateReadState(c.userID, chID, latestID); rsErr != nil { + slog.Warn("handleChannelFocus UpdateReadState", "err", rsErr, "user_id", c.userID, "channel_id", chID) + } + } } diff --git a/Server/ws/handlers_test.go b/Server/ws/handlers_test.go index 88fd7bf7..4070e3fa 100644 --- a/Server/ws/handlers_test.go +++ b/Server/ws/handlers_test.go @@ -9,6 +9,7 @@ import ( "github.com/owncord/server/auth" "github.com/owncord/server/db" + "github.com/owncord/server/permissions" "github.com/owncord/server/ws" ) @@ -36,6 +37,16 @@ CREATE TABLE IF NOT EXISTS audit_log ( detail TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + +CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + stored_as TEXT NOT NULL, + mime_type TEXT NOT NULL, + size INTEGER NOT NULL, + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) +); `)...) func openHandlerDB(t *testing.T) *db.DB { @@ -468,6 +479,68 @@ func TestSlowMode_DifferentChannels_IndependentWindows(t *testing.T) { } } +// ─── Attachment permission ordering ─────────────────────────────────────────── + +// chatSendMsgWithAttachments constructs a raw chat_send envelope with attachment IDs. +func chatSendMsgWithAttachments(channelID int64, content string, attachmentIDs []string) []byte { + raw, _ := json.Marshal(map[string]any{ + "type": "chat_send", + "payload": map[string]any{ + "channel_id": channelID, + "content": content, + "attachments": attachmentIDs, + }, + }) + return raw +} + +// denyAttachOnChannel inserts a channel_override that denies ATTACH_FILES. +func denyAttachOnChannel(t *testing.T, database *db.DB, channelID, roleID int64) { + t.Helper() + _, err := database.Exec( + `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, 0, ?)`, + channelID, roleID, permissions.AttachFiles, + ) + if err != nil { + t.Fatalf("denyAttachOnChannel: %v", err) + } +} + +// TestChatSend_AttachmentsDeniedNoMessageCreated verifies that when ATTACH_FILES +// is denied, the message is NOT persisted (permission check before CreateMessage). +func TestChatSend_AttachmentsDeniedNoMessageCreated(t *testing.T) { + hub, database := newHandlerHub(t) + user := seedMemberUser(t, database, "attach-denied") + chID := seedTestChannel(t, database, "attach-chan") + + // Deny ATTACH_FILES for Member role on this channel. + denyAttachOnChannel(t, database, chID, permissions.MemberRoleID) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + // Send a message with attachments — should be rejected before persisting. + hub.HandleMessageForTest(c, chatSendMsgWithAttachments(chID, "has attachment", []string{"fake-attach-id"})) + time.Sleep(50 * time.Millisecond) + + code := receiveErrorCode(send, 300*time.Millisecond) + if code != "FORBIDDEN" { + t.Errorf("expected FORBIDDEN for denied ATTACH_FILES, got %q", code) + } + + // Verify no message was persisted in the database. + var count int + err := database.QueryRow("SELECT COUNT(*) FROM messages WHERE channel_id = ?", chID).Scan(&count) + if err != nil { + t.Fatalf("count query: %v", err) + } + if count != 0 { + t.Errorf("expected 0 messages in DB (permission denied before persist), got %d", count) + } +} + // TestSlowMode_ErrorMessageContainsSlowModeDuration verifies the error payload // describes the slow mode duration. func TestSlowMode_ErrorMessageContainsSlowModeDuration(t *testing.T) { diff --git a/Server/ws/serve.go b/Server/ws/serve.go index 657af64d..4b8a5d47 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "net/http" + "sync" "time" "nhooyr.io/websocket" @@ -16,6 +17,59 @@ import ( const authDeadline = 10 * time.Second const writeTimeout = 10 * time.Second +const settingsCacheTTL = 30 * time.Second + +// cachedSettings holds server_name and motd to avoid per-connection DB queries. +var ( + settingsMu sync.RWMutex + settingsName = "OwnCord Server" + settingsMotd = "Welcome!" + settingsLastUpdate time.Time + settingsDB *db.DB +) + +// InitSettingsCache sets the DB reference for the settings cache. +// Must be called once during server startup. +func InitSettingsCache(database *db.DB) { + settingsMu.Lock() + defer settingsMu.Unlock() + settingsDB = database + refreshSettingsLocked() +} + +func refreshSettingsLocked() { + if settingsDB == nil { + return + } + var name, motd string + if err := settingsDB.QueryRow("SELECT value FROM settings WHERE key='server_name'").Scan(&name); err == nil { + settingsName = name + } + if err := settingsDB.QueryRow("SELECT value FROM settings WHERE key='motd'").Scan(&motd); err == nil { + settingsMotd = motd + } + settingsLastUpdate = time.Now() +} + +// getCachedSettings returns server_name and motd, refreshing the cache if stale. +func getCachedSettings() (string, string) { + settingsMu.RLock() + if time.Since(settingsLastUpdate) < settingsCacheTTL { + name, motd := settingsName, settingsMotd + settingsMu.RUnlock() + return name, motd + } + settingsMu.RUnlock() + + settingsMu.Lock() + defer settingsMu.Unlock() + // Double-check after acquiring write lock. + if time.Since(settingsLastUpdate) < settingsCacheTTL { + return settingsName, settingsMotd + } + refreshSettingsLocked() + return settingsName, settingsMotd +} // ServeWS upgrades an HTTP connection to WebSocket, performs in-band auth, // then drives the client's read/write loops. @@ -32,6 +86,7 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun slog.Warn("ws upgrade failed", "err", err) return } + conn.SetReadLimit(1 << 20) // 1 MB — match client-side limit user, tokenHash, err := authenticateConn(conn, database) if err != nil { @@ -43,11 +98,12 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun c := newClient(hub, conn, user, tokenHash) hub.Register(c) - // Look up role name for protocol-compliant payloads. + // Look up role name for protocol-compliant payloads and cache on client. roleName := "member" if role, roleErr := database.GetRoleByID(user.RoleID); roleErr == nil && role != nil { roleName = role.Name } + c.roleName = roleName slog.Info("websocket connected", "username", user.Username, "user_id", user.ID, "remote", r.RemoteAddr) _ = database.LogAudit(user.ID, "ws_connect", "user", user.ID, @@ -59,8 +115,8 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun // Send auth_ok followed by the ready payload. ctx := r.Context() - _ = conn.Write(ctx, websocket.MessageText, buildAuthOK(database, user, roleName)) - if ready, readyErr := buildReady(database); readyErr == nil { + _ = conn.Write(ctx, websocket.MessageText, buildAuthOK(user, roleName)) + if ready, readyErr := buildReady(database, user.ID); readyErr == nil { _ = conn.Write(ctx, websocket.MessageText, ready) } @@ -175,17 +231,15 @@ func authenticateConn(conn *websocket.Conn, database *db.DB) (*db.User, string, } // buildAuthOK constructs the auth_ok server→client message. -func buildAuthOK(database *db.DB, user *db.User, roleName string) []byte { - serverName := "OwnCord Server" - motd := "Welcome!" - _ = database.QueryRow("SELECT value FROM settings WHERE key='server_name'").Scan(&serverName) - _ = database.QueryRow("SELECT value FROM settings WHERE key='motd'").Scan(&motd) - +// Per PROTOCOL.md, user object contains only id, username, avatar, role (no status). +func buildAuthOK(user *db.User, roleName string) []byte { var avatarVal any if user.Avatar != nil { avatarVal = *user.Avatar } + serverName, motd := getCachedSettings() + return buildJSON(map[string]any{ "type": "auth_ok", "payload": map[string]any{ @@ -193,7 +247,6 @@ func buildAuthOK(database *db.DB, user *db.User, roleName string) []byte { "id": user.ID, "username": user.Username, "avatar": avatarVal, - "status": user.Status, "role": roleName, }, "server_name": serverName, @@ -203,7 +256,9 @@ func buildAuthOK(database *db.DB, user *db.User, roleName string) []byte { } // buildReady constructs the ready server→client message. -func buildReady(database *db.DB) ([]byte, error) { +// Per PROTOCOL.md, channels include unread_count and last_message_id per user, +// and only protocol-specified fields (no slow_mode, archived, voice_* extras). +func buildReady(database *db.DB, userID int64) ([]byte, error) { channels, err := database.ListChannels() if err != nil { return nil, fmt.Errorf("buildReady ListChannels: %w", err) @@ -219,6 +274,35 @@ func buildReady(database *db.DB) ([]byte, error) { members = []db.MemberSummary{} } + // Per-user unread counts. + unreadMap, err := database.GetChannelUnreadCounts(userID) + if err != nil { + slog.Warn("buildReady GetChannelUnreadCounts", "err", err) + unreadMap = map[int64]db.ChannelUnread{} + } + + // Build protocol-compliant channel objects (strip extra fields). + channelPayloads := make([]map[string]any, 0, len(channels)) + for _, ch := range channels { + entry := map[string]any{ + "id": ch.ID, + "name": ch.Name, + "type": ch.Type, + "category": ch.Category, + "position": ch.Position, + } + if ch.Type == "text" { + if u, ok := unreadMap[ch.ID]; ok { + entry["unread_count"] = u.UnreadCount + entry["last_message_id"] = u.LastMessageID + } else { + entry["unread_count"] = 0 + entry["last_message_id"] = 0 + } + } + channelPayloads = append(channelPayloads, entry) + } + // Collect all active voice states across every voice channel. voiceStates, err := collectAllVoiceStates(database, channels) if err != nil { @@ -227,13 +311,17 @@ func buildReady(database *db.DB) ([]byte, error) { voiceStates = []db.VoiceState{} } + serverName, motd := getCachedSettings() + return buildJSON(map[string]any{ "type": "ready", "payload": map[string]any{ - "channels": channels, + "channels": channelPayloads, "members": members, "voice_states": voiceStates, "roles": roles, + "server_name": serverName, + "motd": motd, }, }), nil } diff --git a/Server/ws/voice_handlers.go b/Server/ws/voice_handlers.go index 6759ea3d..b4ec5415 100644 --- a/Server/ws/voice_handlers.go +++ b/Server/ws/voice_handlers.go @@ -71,8 +71,7 @@ func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) { return } - if !h.hasChannelPerm(c, channelID, permissions.ConnectVoice) { - c.sendMsg(buildErrorMsg("FORBIDDEN", "missing CONNECT_VOICE permission")) + if !h.requireChannelPerm(c, channelID, permissions.ConnectVoice, "CONNECT_VOICE") { return } @@ -279,7 +278,7 @@ func (h *Hub) handleVoiceDeafen(c *Client, payload json.RawMessage) { func (h *Hub) handleVoiceCamera(c *Client, payload json.RawMessage) { ratKey := fmt.Sprintf("voice_camera:%d", c.userID) if !h.limiter.Allow(ratKey, voiceCameraRateLimit, voiceCameraWindow) { - c.sendMsg(buildErrorMsg("RATE_LIMITED", "too many camera toggles")) + c.sendMsg(buildRateLimitError("too many camera toggles", voiceCameraWindow.Seconds())) return } @@ -289,8 +288,7 @@ func (h *Hub) handleVoiceCamera(c *Client, payload json.RawMessage) { return } - if !h.hasChannelPerm(c, voiceChID, permissions.UseVideo) { - c.sendMsg(buildErrorMsg("FORBIDDEN", "missing USE_VIDEO permission")) + if !h.requireChannelPerm(c, voiceChID, permissions.UseVideo, "USE_VIDEO") { return } @@ -320,7 +318,7 @@ func (h *Hub) handleVoiceCamera(c *Client, payload json.RawMessage) { func (h *Hub) handleVoiceScreenshare(c *Client, payload json.RawMessage) { ratKey := fmt.Sprintf("voice_screenshare:%d", c.userID) if !h.limiter.Allow(ratKey, voiceScreenshareRateLimit, voiceScreenshareWindow) { - c.sendMsg(buildErrorMsg("RATE_LIMITED", "too many screenshare toggles")) + c.sendMsg(buildRateLimitError("too many screenshare toggles", voiceScreenshareWindow.Seconds())) return } @@ -330,8 +328,7 @@ func (h *Hub) handleVoiceScreenshare(c *Client, payload json.RawMessage) { return } - if !h.hasChannelPerm(c, voiceChID, permissions.ShareScreen) { - c.sendMsg(buildErrorMsg("FORBIDDEN", "missing SHARE_SCREEN permission")) + if !h.requireChannelPerm(c, voiceChID, permissions.ShareScreen, "SHARE_SCREEN") { return } @@ -358,7 +355,7 @@ func (h *Hub) handleVoiceScreenshare(c *Client, payload json.RawMessage) { func (h *Hub) handleVoiceOffer(c *Client, payload json.RawMessage) { ratKey := fmt.Sprintf("voice_signal:%d", c.userID) if !h.limiter.Allow(ratKey, voiceSignalRateLimit, voiceSignalWindow) { - c.sendMsg(buildErrorMsg("RATE_LIMITED", "too many signaling messages")) + c.sendMsg(buildRateLimitError("too many signaling messages", voiceSignalWindow.Seconds())) return } @@ -415,7 +412,7 @@ func (h *Hub) handleVoiceOffer(c *Client, payload json.RawMessage) { func (h *Hub) handleVoiceAnswer(c *Client, payload json.RawMessage) { ratKey := fmt.Sprintf("voice_signal:%d", c.userID) if !h.limiter.Allow(ratKey, voiceSignalRateLimit, voiceSignalWindow) { - c.sendMsg(buildErrorMsg("RATE_LIMITED", "too many signaling messages")) + c.sendMsg(buildRateLimitError("too many signaling messages", voiceSignalWindow.Seconds())) return } @@ -454,7 +451,7 @@ func (h *Hub) handleVoiceAnswer(c *Client, payload json.RawMessage) { func (h *Hub) handleVoiceICE(c *Client, payload json.RawMessage) { ratKey := fmt.Sprintf("voice_signal:%d", c.userID) if !h.limiter.Allow(ratKey, voiceSignalRateLimit, voiceSignalWindow) { - c.sendMsg(buildErrorMsg("RATE_LIMITED", "too many signaling messages")) + c.sendMsg(buildRateLimitError("too many signaling messages", voiceSignalWindow.Seconds())) return } @@ -491,8 +488,7 @@ func (h *Hub) handleSoundboard(c *Client, payload json.RawMessage) { return } - if !h.hasChannelPerm(c, 0, permissions.UseSoundboard) { - c.sendMsg(buildErrorMsg("FORBIDDEN", "missing USE_SOUNDBOARD permission")) + if !h.requireChannelPerm(c, 0, permissions.UseSoundboard, "USE_SOUNDBOARD") { return } diff --git a/TODOS.md b/TODOS.md new file mode 100644 index 00000000..1acd6d4c --- /dev/null +++ b/TODOS.md @@ -0,0 +1,192 @@ +# TODOS + +Items deferred from CEO plan review of `tauri-migration` branch +(2026-03-16). Ordered by priority. + +## P1 — Must fix soon + +### ~~1. Attachment permission ordering bug~~ DONE + +Moved `ATTACH_FILES` permission check before `CreateMessage()` +in `Server/ws/handlers.go`. Added test +`TestChatSend_AttachmentsDeniedNoMessageCreated`. + +--- + +### ~~2. Hardcoded `/api/files/` URL~~ DONE + +Changed to `/api/v1/files/` in +`Server/db/attachment_queries.go:93`. + +--- + +### ~~3. Missing `onUnauthorized` handler~~ DONE + +Wired `api.onUnauthorized` callback at creation in `main.ts` +to call `clearAuth()`, which triggers navigation back to +connect page via existing authStore subscription. + +--- + +## P2 — Should fix next + +### ~~4. Silent API failure toasts~~ DONE + +Added `ToastContainer` to `MainPage.ts`. Wired toast to 5 +catch blocks: `loadMessages`, `loadOlderMessages`, +`openInviteManager`, `togglePinnedPanel`, and the connectivity +guard on message send. + +--- + +### ~~5. Message send connectivity guard + debounce~~ DONE + +Added `ws.getState() !== "connected"` guard in `MainPage.ts` +`onSend` callback with toast feedback. Added 200ms send +debounce in `MessageInput.ts` to prevent double-click +duplicates. + +--- + +### ~~6. WebSocket frame size limit on server~~ DONE + +Added `conn.SetReadLimit(1 << 20)` (1MB) in +`Server/ws/serve.go` after WebSocket accept. + +--- + +### ~~7. Wrap dispatcher store operations in try/catch~~ N/A + +Already handled: `ws.ts` dispatch function wraps every +listener call in try/catch with `log.error`. No additional +wrapping needed in `dispatcher.ts`. + +--- + +### ~~8. `GetAttachmentsByMessageIDs` error silently swallowed~~ DONE + +Added `slog.Error("ws handleChatSend GetAttachments", ...)` in +`Server/ws/handlers.go` inside the error check. + +--- + +## P3 — Tech debt / polish + +### 9. Split oversized files + +**What:** Break these files into smaller modules: + +- `Server/admin/api.go` (788 lines) into + `handlers_users.go`, `handlers_channels.go`, + `handlers_backup.go` +- `Client/tauri-client/src/pages/MainPage.ts` (683 lines) + into extracted helpers +- `Client/tauri-client/src/components/SettingsOverlay.ts` + (670 lines) into per-tab components + +**Why:** All exceed the 400-line target. Larger files are +harder to navigate, review, and test. They will grow as +features are added. + +**Context:** Pure refactor, no behavior change. Best done +when no other PRs touch these files. + +**Effort:** M per file (3 files) + +--- + +### ~~10. Extract permission check helper (server DRY)~~ DONE + +Created `requireChannelPerm(c, channelID, perm, permLabel)` +helper in `handlers.go`. Replaced 8 instances across +`handlers.go` and `voice_handlers.go`. + +--- + +### 11. Virtual scrolling for MessageList + +**What:** Implement DOM windowing/recycling in +`MessageList.ts` so only visible messages (plus buffer) are +in the DOM. + +**Why:** Channels with 10K+ messages will cause initial +render hang and high memory usage. + +**Context:** Phase 5 of MIGRATION-PLAN.md mentions this. +Consider a lightweight virtual scroll library or custom +implementation using IntersectionObserver. + +**Effort:** L + +--- + +### 12. WS message render batching + +**What:** Batch store subscription callbacks using +`requestAnimationFrame` or `queueMicrotask` so 100 rapid +WS messages don't trigger 100 full re-renders. + +**Why:** Burst activity (e.g., reconnect with backlog) +causes jank from unbatched DOM updates. + +**Effort:** M + +--- + +### 13. E2E test improvement plan (Phases 4-6) + +**What:** Complete the remaining phases of the E2E +improvement plan: + +- Phase 4: Strengthen assertions, fix quality +- Phase 5: Toast coverage +- Phase 6: Migrate all selectors to data-testid + +**Why:** Phases 1-3 are complete. Remaining phases improve +test reliability and coverage. + +**Context:** See `project_e2e_improvement_plan.md` in +Claude memory for full plan. + +**Effort:** M (per phase) + +**Depends on:** TODO #4 (toast wiring) for Phase 5 — DONE + +--- + +## CLIENT-REVIEW.md findings + +### ~~Auth token never set in authStore~~ DONE + +Fixed in `main.ts:wirePostAuth` — store token in authStore +before WS connect so dispatcher's `auth_ok` handler has it. + +--- + +### ~~WS connect hangs in "connecting" state~~ DONE + +Fixed in `ws.ts` — set state to "disconnected" when Tauri +APIs are unavailable. + +--- + +### ~~Server-driven voice disconnect doesn't clear currentChannelId~~ DONE + +Fixed in `dispatcher.ts` — `voice_leave` handler now calls +`leaveVoiceChannel()` when the current user is removed. + +--- + +### ~~Theme/font not applied on app start~~ DONE + +Extracted `applyStoredAppearance()` from `SettingsOverlay.ts` +and call it at app startup in `main.ts`. + +--- + +### ~~Infinite scroll throttle~~ DONE + +Fixed in `MessageList.ts` — replaced fixed 500ms timeout +with store subscription that resets `loadingOlder` when +message count changes. Also checks `hasMoreMessages` before +triggering scroll load.