diff --git a/Client/tauri-client/src/components/ChannelSidebar.ts b/Client/tauri-client/src/components/ChannelSidebar.ts index 88abbae3..afccf706 100644 --- a/Client/tauri-client/src/components/ChannelSidebar.ts +++ b/Client/tauri-client/src/components/ChannelSidebar.ts @@ -242,7 +242,7 @@ function renderVoiceChannelItem( const rowClasses = user.speaking ? "voice-user-item speaking" : "voice-user-item"; - const row = createElement("div", { class: rowClasses }); + const row = createElement("div", { class: rowClasses, "data-voice-uid": String(user.userId) }); const initial = user.username.length > 0 ? user.username.charAt(0).toUpperCase() @@ -702,29 +702,66 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC // Initial render renderChannels(); - // Subscribe to channels store changes - const unsubChannels = channelsStore.subscribe(() => { - renderChannels(); - }); - unsubscribers.push(unsubChannels); + // Subscribe to channels store changes (channels map OR active channel) + const unsubChannelsMap = channelsStore.subscribeSelector( + (s) => s.channels, + () => renderChannels(), + ); + unsubscribers.push(unsubChannelsMap); + const unsubActiveChannel = channelsStore.subscribeSelector( + (s) => s.activeChannelId, + () => renderChannels(), + ); + unsubscribers.push(unsubActiveChannel); // Subscribe to auth store for server name updates - const unsubAuth = authStore.subscribe((state) => { - if (serverNameEl !== null) { - setText(serverNameEl, state.serverName ?? "Server Name"); - } - }); + const unsubAuth = authStore.subscribeSelector( + (s) => s.serverName, + (serverName) => { + if (serverNameEl !== null) { + setText(serverNameEl, serverName ?? "Server Name"); + } + }, + ); unsubscribers.push(unsubAuth); // Subscribe to UI store for category collapse changes - const unsubUi = uiStore.subscribe(() => { - renderChannels(); - }); + const unsubUi = uiStore.subscribeSelector( + (s) => s.collapsedCategories, + () => renderChannels(), + ); unsubscribers.push(unsubUi); - // Subscribe to voice store for connected user updates - const unsubVoice = voiceStore.subscribe(() => { - renderChannels(); + // Subscribe to voice store — only full re-render when users join/leave + // or mute/deafen/camera changes. Speaking state is patched in-place via + // CSS class toggle to avoid destroying DOM elements (which kills hover). + let prevVoiceStructureSig = ""; + const unsubVoice = voiceStore.subscribe((state) => { + // Structural signature: who is in which channel + mute/deafen/camera. + // Excludes speaking — that's patched in-place below. + let structSig = String(state.currentChannelId ?? ""); + for (const [chId, users] of state.voiceUsers) { + structSig += `|${chId}`; + for (const [uid, u] of users) { + structSig += `:${uid}${u.muted ? "m" : ""}${u.deafened ? "d" : ""}${u.camera ? "c" : ""}`; + } + } + if (structSig !== prevVoiceStructureSig) { + prevVoiceStructureSig = structSig; + renderChannels(); + return; + } + + // Patch speaking state in-place — toggle CSS class without re-rendering. + if (channelList === null) return; + for (const [, users] of state.voiceUsers) { + for (const [uid, u] of users) { + const row = channelList.querySelector(`.voice-user-item[data-voice-uid="${uid}"]`); + if (row !== null) { + row.classList.toggle("speaking", u.speaking); + } + } + } }); unsubscribers.push(unsubVoice); } diff --git a/Client/tauri-client/src/components/MemberList.ts b/Client/tauri-client/src/components/MemberList.ts index c1e6065a..7dae5fb3 100644 --- a/Client/tauri-client/src/components/MemberList.ts +++ b/Client/tauri-client/src/components/MemberList.ts @@ -103,11 +103,14 @@ export function createMemberList(): MountableComponent { root = createElement("div", { class: "member-list", "data-testid": "member-list" }); renderList(root); - unsubscribe = membersStore.subscribe(() => { - if (root !== null) { - renderList(root); - } - }); + unsubscribe = membersStore.subscribeSelector( + (s) => s.members, + () => { + if (root !== null) { + renderList(root); + } + }, + ); container.appendChild(root); } diff --git a/Client/tauri-client/src/components/MessageList.ts b/Client/tauri-client/src/components/MessageList.ts index ca07ce49..f652b786 100644 --- a/Client/tauri-client/src/components/MessageList.ts +++ b/Client/tauri-client/src/components/MessageList.ts @@ -291,13 +291,16 @@ export function createMessageList(options: MessageListOptions): MessageListCompo let loadingOlder = false; let prevMessageCount = 0; - const unsubLoadingReset = messagesStore.subscribe(() => { - const msgs = getChannelMessages(options.channelId); - if (msgs.length !== prevMessageCount) { - prevMessageCount = msgs.length; - loadingOlder = false; - } - }); + const unsubLoadingReset = messagesStore.subscribeSelector( + (s) => s.messagesByChannel, + () => { + const msgs = getChannelMessages(options.channelId); + if (msgs.length !== prevMessageCount) { + prevMessageCount = msgs.length; + loadingOlder = false; + } + }, + ); let scrollRafId = 0; @@ -377,16 +380,16 @@ export function createMessageList(options: MessageListOptions): MessageListCompo scrollToBottom(); requestAnimationFrame(() => scrollToBottom()); - unsubscribers.push(messagesStore.subscribe(() => { renderAll(); })); + unsubscribers.push(messagesStore.subscribeSelector( + (s) => s.messagesByChannel, + () => { renderAll(); }, + )); // Only re-render when member roles change, not on typing updates - let prevMembers = membersStore.getState().members; - unsubscribers.push(membersStore.subscribe((state) => { - if (state.members !== prevMembers) { - prevMembers = state.members; - renderAll(); - } - })); + unsubscribers.push(membersStore.subscribeSelector( + (s) => s.members, + () => { renderAll(); }, + )); } function destroy(): void { diff --git a/Client/tauri-client/src/components/QuickSwitcher.ts b/Client/tauri-client/src/components/QuickSwitcher.ts index c8419e7e..3dd41011 100644 --- a/Client/tauri-client/src/components/QuickSwitcher.ts +++ b/Client/tauri-client/src/components/QuickSwitcher.ts @@ -173,7 +173,10 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom document.addEventListener("keydown", handleGlobalKeydown, { signal }); // Subscribe to store changes - unsubscribe = channelsStore.subscribe(refreshFromStore); + unsubscribe = channelsStore.subscribeSelector( + (s) => s.channels, + refreshFromStore, + ); // Auto-focus requestAnimationFrame(() => input.focus()); diff --git a/Client/tauri-client/src/components/SettingsOverlay.ts b/Client/tauri-client/src/components/SettingsOverlay.ts index cfe4d843..bbdb1e99 100644 --- a/Client/tauri-client/src/components/SettingsOverlay.ts +++ b/Client/tauri-client/src/components/SettingsOverlay.ts @@ -155,13 +155,16 @@ export function createSettingsOverlay( renderActiveTab(); // Subscribe to uiStore for open/close - unsubUi = uiStore.subscribe((state) => { - if (state.settingsOpen) { - show(); - } else { - hide(); - } - }); + unsubUi = uiStore.subscribeSelector( + (s) => s.settingsOpen, + (settingsOpen) => { + if (settingsOpen) { + show(); + } else { + hide(); + } + }, + ); // Sync initial state if (uiStore.getState().settingsOpen) { diff --git a/Client/tauri-client/src/components/TypingIndicator.ts b/Client/tauri-client/src/components/TypingIndicator.ts index ae552916..7e5e76a6 100644 --- a/Client/tauri-client/src/components/TypingIndicator.ts +++ b/Client/tauri-client/src/components/TypingIndicator.ts @@ -61,9 +61,10 @@ export function createTypingIndicator( updateFromState(); - unsubscribe = membersStore.subscribe(() => { - updateFromState(); - }); + unsubscribe = membersStore.subscribeSelector( + (s) => s.typingUsers, + () => { updateFromState(); }, + ); container.appendChild(root); } diff --git a/Client/tauri-client/src/components/UserBar.ts b/Client/tauri-client/src/components/UserBar.ts index e0a210b1..de7bb0c8 100644 --- a/Client/tauri-client/src/components/UserBar.ts +++ b/Client/tauri-client/src/components/UserBar.ts @@ -81,9 +81,10 @@ export function createUserBar(options?: UserBarOptions): MountableComponent { updateFromState(); // Subscribe to auth changes - unsubscribe = authStore.subscribe(() => { - updateFromState(); - }); + unsubscribe = authStore.subscribeSelector( + (s) => s.user, + () => updateFromState(), + ); container.appendChild(root); } diff --git a/Client/tauri-client/src/components/VideoGrid.ts b/Client/tauri-client/src/components/VideoGrid.ts index 2cbee92e..b4d4cc86 100644 --- a/Client/tauri-client/src/components/VideoGrid.ts +++ b/Client/tauri-client/src/components/VideoGrid.ts @@ -46,6 +46,11 @@ export function createVideoGrid(): VideoGridComponent { video.srcObject = stream; } } + // Update username label in case it changed + const label = existing.querySelector(".video-username"); + if (label !== null) { + label.textContent = username; + } return; } @@ -60,7 +65,7 @@ export function createVideoGrid(): VideoGridComponent { const cell = createElement("div", { class: "video-cell", - "data-userId": String(userId), + "data-user-id": String(userId), }); appendChildren(cell, video, label); diff --git a/Client/tauri-client/src/components/VoiceChannel.ts b/Client/tauri-client/src/components/VoiceChannel.ts index fde27da0..e84425f2 100644 --- a/Client/tauri-client/src/components/VoiceChannel.ts +++ b/Client/tauri-client/src/components/VoiceChannel.ts @@ -218,8 +218,8 @@ export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelRe // Initial render and subscribe update(); - unsubs.push(voiceStore.subscribe(() => update())); - unsubs.push(membersStore.subscribe(() => update())); + unsubs.push(voiceStore.subscribeSelector((s) => s.voiceUsers, () => update())); + unsubs.push(membersStore.subscribeSelector((s) => s.members, () => update())); function destroy(): void { closeContextMenu(); diff --git a/Client/tauri-client/src/components/VoiceWidget.ts b/Client/tauri-client/src/components/VoiceWidget.ts index 67c3c9dc..a16fece7 100644 --- a/Client/tauri-client/src/components/VoiceWidget.ts +++ b/Client/tauri-client/src/components/VoiceWidget.ts @@ -87,8 +87,24 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone render(); - unsubs.push(voiceStore.subscribe(() => render())); - unsubs.push(channelsStore.subscribe(() => render())); + unsubs.push(voiceStore.subscribeSelector( + (s) => ({ + channelId: s.currentChannelId, + muted: s.localMuted, + deafened: s.localDeafened, + camera: s.localCamera, + }), + () => render(), + (a, b) => + a.channelId === b.channelId && + a.muted === b.muted && + a.deafened === b.deafened && + a.camera === b.camera, + )); + unsubs.push(channelsStore.subscribeSelector( + (s) => s.channels, + () => render(), + )); container.appendChild(root); } diff --git a/Client/tauri-client/src/components/message-list/attachments.ts b/Client/tauri-client/src/components/message-list/attachments.ts new file mode 100644 index 00000000..c3c2bf7b --- /dev/null +++ b/Client/tauri-client/src/components/message-list/attachments.ts @@ -0,0 +1,271 @@ +/** + * File attachment rendering and image caching (memory + IndexedDB). + * Also owns the server host state and URL resolution used by other modules. + */ + +import { + createElement, + setText, + appendChildren, +} from "@lib/dom"; +import { observeMedia } from "@lib/media-visibility"; +import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; +import { save } from "@tauri-apps/plugin-dialog"; +import { writeFile } from "@tauri-apps/plugin-fs"; +import type { Attachment } from "@lib/types"; +import { openImageLightbox } from "./media"; + +// -- Server host state -------------------------------------------------------- + +/** Module-level server host for resolving relative attachment URLs. */ +let _serverHost: string | null = null; + +/** Set the server host (called once from MainPage on connect). */ +export function setServerHost(host: string): void { + _serverHost = host; +} + +/** Resolve a potentially relative URL to a full URL using the server host. */ +export function resolveServerUrl(url: string): string { + if (url.startsWith("http://") || url.startsWith("https://")) { + return url; + } + if (_serverHost !== null) { + return `https://${_serverHost}${url}`; + } + return url; +} + +// -- Helpers ------------------------------------------------------------------ + +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function isImageMime(mime: string): boolean { + return mime.startsWith("image/"); +} + +export function isSafeUrl(url: string): boolean { + try { + const parsed = new URL(url, window.location.origin); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Image cache: memory + IndexedDB for persistence across restarts +// --------------------------------------------------------------------------- + +/** In-memory cache for instant re-render. */ +const memoryCache = new Map(); + +/** In-flight fetch promises to prevent duplicate concurrent requests. */ +const inFlight = new Map>(); + +/** IndexedDB database name and store. */ +const IDB_NAME = "owncord-image-cache"; +const IDB_STORE = "images"; +const IDB_VERSION = 1; + +/** Open (or create) the IndexedDB database. */ +export function openCacheDb(): Promise { + return new Promise((resolve) => { + try { + const req = indexedDB.open(IDB_NAME, IDB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(IDB_STORE)) { + db.createObjectStore(IDB_STORE); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => resolve(null); + } catch { + resolve(null); + } + }); +} + +/** Read a cached data URL from IndexedDB. */ +async function idbGet(url: string): Promise { + const db = await openCacheDb(); + if (db === null) return null; + return new Promise((resolve) => { + try { + const tx = db.transaction(IDB_STORE, "readonly"); + const store = tx.objectStore(IDB_STORE); + const req = store.get(url); + req.onsuccess = () => resolve(typeof req.result === "string" ? req.result : null); + req.onerror = () => resolve(null); + } catch { + resolve(null); + } + }); +} + +/** Write a data URL to IndexedDB. */ +async function idbPut(url: string, dataUrl: string): Promise { + const db = await openCacheDb(); + if (db === null) return; + try { + const tx = db.transaction(IDB_STORE, "readwrite"); + tx.objectStore(IDB_STORE).put(dataUrl, url); + } catch { + // IndexedDB full or unavailable — ignore + } +} + +/** Convert a Uint8Array to a base64 string. */ +export function uint8ToBase64(bytes: Uint8Array): string { + // Process in chunks to avoid call stack overflow on large files + const CHUNK = 8192; + let binary = ""; + for (let i = 0; i < bytes.length; i += CHUNK) { + const slice = bytes.subarray(i, Math.min(i + CHUNK, bytes.length)); + binary += String.fromCharCode(...slice); + } + return btoa(binary); +} + +/** Fetch an image and return a data: URI. Uses memory → IndexedDB → network. */ +export function fetchImageAsDataUrl(url: string): Promise { + // 1. Memory cache (instant) + const cached = memoryCache.get(url); + if (cached !== undefined) return Promise.resolve(cached); + + // 2. Deduplicate concurrent requests for the same URL + const existing = inFlight.get(url); + if (existing !== undefined) return existing; + + const promise = (async (): Promise => { + // 3. IndexedDB cache (persists across restarts) + const idbCached = await idbGet(url); + if (idbCached !== null) { + memoryCache.set(url, idbCached); + return idbCached; + } + + // 4. Network fetch via Tauri HTTP plugin + try { + const res = await tauriFetch(url, { + danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false }, + } as RequestInit); + if (!res.ok) return null; + + const contentType = res.headers.get("content-type") ?? "image/png"; + const buffer = await res.arrayBuffer(); + const base64 = uint8ToBase64(new Uint8Array(buffer)); + const dataUrl = `data:${contentType};base64,${base64}`; + + // Store in both caches + memoryCache.set(url, dataUrl); + void idbPut(url, dataUrl); + + return dataUrl; + } catch (err) { + console.error("Failed to fetch attachment image:", url, err); + return null; + } + })(); + + inFlight.set(url, promise); + void promise.finally(() => inFlight.delete(url)); + + return promise; +} + +// -- Attachment rendering ----------------------------------------------------- + +export function renderAttachment(att: Attachment): HTMLDivElement { + const resolvedUrl = resolveServerUrl(att.url); + if (isImageMime(att.mime) && isSafeUrl(resolvedUrl)) { + const wrap = createElement("div", { class: "msg-image" }); + + function attachLightbox(img: HTMLImageElement): void { + img.addEventListener("click", () => { + openImageLightbox(img.src, att.filename); + }); + } + + const isGif = att.mime === "image/gif"; + + // Check cache first for instant render + const cached = memoryCache.get(resolvedUrl); + if (cached !== undefined) { + const img = createElement("img", { + src: cached, + alt: att.filename, + }) as HTMLImageElement; + attachLightbox(img); + if (isGif) { + img.addEventListener("load", () => { observeMedia(img, cached, wrap); }, { once: true }); + } + wrap.appendChild(img); + } else { + // Show loading placeholder, then replace with image + const placeholder = createElement("div", { class: "placeholder-img loading" }, att.filename); + wrap.appendChild(placeholder); + + void fetchImageAsDataUrl(resolvedUrl).then((dataUrl) => { + if (dataUrl !== null) { + const img = createElement("img", { + src: dataUrl, + alt: att.filename, + }) as HTMLImageElement; + attachLightbox(img); + if (isGif) { + img.addEventListener("load", () => { observeMedia(img, dataUrl, wrap); }, { once: true }); + } + placeholder.replaceWith(img); + } + }); + } + + return wrap; + } + const wrap = createElement("div", { class: "msg-file" }); + const inner = createElement("div", { class: "msg-file-inner" }); + const icon = createElement("div", { class: "msg-file-icon" }, "\uD83D\uDCC4"); + const nameEl = createElement("div", { class: "msg-file-name" }, att.filename); + nameEl.addEventListener("click", () => { + void downloadFile(resolvedUrl, att.filename); + }); + const sizeEl = createElement("div", { class: "msg-file-size" }, formatFileSize(att.size)); + const info = createElement("div", {}); + appendChildren(info, nameEl, sizeEl); + const downloadBtn = createElement("button", { + class: "msg-file-download", + title: "Download", + }, "\u2B07"); + downloadBtn.addEventListener("click", () => { + void downloadFile(resolvedUrl, att.filename); + }); + appendChildren(inner, icon, info, downloadBtn); + wrap.appendChild(inner); + return wrap; +} + +/** Download a file via Tauri HTTP plugin and save to disk with native dialog. */ +async function downloadFile(url: string, filename: string): Promise { + try { + // Show native save dialog with suggested filename + const filePath = await save({ defaultPath: filename }); + if (filePath === null) return; // User cancelled + + // Fetch file data + const res = await tauriFetch(url, { + danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false }, + } as RequestInit); + if (!res.ok) return; + + const buffer = await res.arrayBuffer(); + await writeFile(filePath, new Uint8Array(buffer)); + } catch (err) { + console.error("Download failed:", err); + } +} diff --git a/Client/tauri-client/src/components/message-list/content-parser.ts b/Client/tauri-client/src/components/message-list/content-parser.ts new file mode 100644 index 00000000..475548c1 --- /dev/null +++ b/Client/tauri-client/src/components/message-list/content-parser.ts @@ -0,0 +1,122 @@ +/** + * Text content parsing — XSS-safe DOM builders for message text including + * inline code, code blocks, @mentions, and URL linkification. + */ + +import { + createElement, + setText, +} from "@lib/dom"; +import { isSafeUrl } from "./attachments"; + +// -- Regex constants ---------------------------------------------------------- + +export const MENTION_REGEX = /@(\w+)/g; +export const CODE_BLOCK_REGEX = /```([\s\S]*?)```/g; +export const INLINE_CODE_REGEX = /`([^`]+)`/g; +export const URL_REGEX = /https?:\/\/[^\s<>"']+/g; + +// -- Content rendering -------------------------------------------------------- + +export function renderInlineContent(text: string): DocumentFragment { + const fragment = document.createDocumentFragment(); + let lastIndex = 0; + for (const match of text.matchAll(INLINE_CODE_REGEX)) { + const idx = match.index; + if (idx === undefined) continue; + if (idx > lastIndex) { + fragment.appendChild(renderMentions(text.slice(lastIndex, idx))); + } + const code = createElement("code", {}); + setText(code, match[1]!); + fragment.appendChild(code); + lastIndex = idx + match[0].length; + } + if (lastIndex < text.length) { + fragment.appendChild(renderMentions(text.slice(lastIndex))); + } + return fragment; +} + +export function renderMentions(text: string): DocumentFragment { + // First pass: split by URLs, then handle mentions in non-URL segments + const fragment = document.createDocumentFragment(); + let lastIndex = 0; + for (const match of text.matchAll(URL_REGEX)) { + const idx = match.index; + if (idx === undefined) continue; + if (idx > lastIndex) { + fragment.appendChild(renderMentionSegment(text.slice(lastIndex, idx))); + } + const url = match[0]; + if (isSafeUrl(url)) { + const link = createElement("a", { + class: "msg-link", + href: url, + target: "_blank", + rel: "noopener noreferrer", + }); + setText(link, url); + fragment.appendChild(link); + } else { + fragment.appendChild(document.createTextNode(url)); + } + lastIndex = idx + match[0].length; + } + if (lastIndex < text.length) { + fragment.appendChild(renderMentionSegment(text.slice(lastIndex))); + } + return fragment; +} + +/** Render @mentions within a text segment (no URLs). */ +export function renderMentionSegment(text: string): DocumentFragment { + const fragment = document.createDocumentFragment(); + let lastIndex = 0; + for (const match of text.matchAll(MENTION_REGEX)) { + const idx = match.index; + if (idx === undefined) continue; + if (idx > lastIndex) { + fragment.appendChild(document.createTextNode(text.slice(lastIndex, idx))); + } + const span = createElement("span", { class: "mention" }); + setText(span, match[0]); + fragment.appendChild(span); + lastIndex = idx + match[0].length; + } + if (lastIndex < text.length) { + fragment.appendChild(document.createTextNode(text.slice(lastIndex))); + } + return fragment; +} + +export function renderMessageContent(content: string): DocumentFragment { + const fragment = document.createDocumentFragment(); + let lastIndex = 0; + for (const match of content.matchAll(CODE_BLOCK_REGEX)) { + const idx = match.index; + if (idx === undefined) continue; + if (idx > lastIndex) { + const text = createElement("div", { class: "msg-text" }); + text.appendChild(renderInlineContent(content.slice(lastIndex, idx))); + fragment.appendChild(text); + } + const codeBlock = createElement("div", { class: "msg-codeblock" }); + setText(codeBlock, match[1]!.trim()); + fragment.appendChild(codeBlock); + lastIndex = idx + match[0].length; + } + if (lastIndex === 0) { + const text = createElement("div", { class: "msg-text" }); + text.appendChild(renderInlineContent(content)); + fragment.appendChild(text); + } else if (lastIndex < content.length) { + const remaining = content.slice(lastIndex).trim(); + if (remaining.length > 0) { + const text = createElement("div", { class: "msg-text" }); + text.appendChild(renderInlineContent(remaining)); + fragment.appendChild(text); + } + } + return fragment; +} diff --git a/Client/tauri-client/src/components/message-list/embeds.ts b/Client/tauri-client/src/components/message-list/embeds.ts new file mode 100644 index 00000000..b8493d17 --- /dev/null +++ b/Client/tauri-client/src/components/message-list/embeds.ts @@ -0,0 +1,230 @@ +/** + * Link preview / Open Graph tag rendering — fetches and displays OG metadata + * (title, description, image) for generic URLs as compact link cards. + */ + +import { + createElement, + setText, +} from "@lib/dom"; +import { observeMedia } from "@lib/media-visibility"; +import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; +import { isSafeUrl } from "./attachments"; + +// -- OG metadata types -------------------------------------------------------- + +/** Open Graph metadata extracted from a page. */ +export interface OgMeta { + readonly title: string | null; + readonly description: string | null; + readonly image: string | null; + readonly siteName: string | null; +} + +// -- Caches ------------------------------------------------------------------- + +/** Cache for OG metadata to avoid re-fetching on re-render. */ +const ogCache = new Map(); +/** URLs currently being fetched (prevents duplicate requests). */ +const ogInFlight = new Set(); + +// -- OG tag parsing ----------------------------------------------------------- + +/** Extract Open Graph meta tags from raw HTML using regex (no DOM parser needed). */ +export function parseOgTags(html: string): OgMeta { + function getMetaContent(property: string): string | null { + // Match both property="og:X" and name="og:X" patterns + const regex = new RegExp( + `]*(?:property|name)=["']${property}["'][^>]*content=["']([^"']*)["']` + + `|]*content=["']([^"']*)["'][^>]*(?:property|name)=["']${property}["']`, + "i", + ); + const match = html.match(regex); + if (match !== null) { + return match[1] ?? match[2] ?? null; + } + return null; + } + + // Fallback: extract tag if no og:title + function getTitle(): string | null { + const og = getMetaContent("og:title"); + if (og !== null) return og; + const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i); + return titleMatch?.[1]?.trim() ?? null; + } + + // Fallback: extract meta description if no og:description + function getDescription(): string | null { + const og = getMetaContent("og:description"); + if (og !== null) return og; + return getMetaContent("description"); + } + + return { + title: getTitle(), + description: getDescription(), + image: getMetaContent("og:image"), + siteName: getMetaContent("og:site_name"), + }; +} + +// -- OG fetch ----------------------------------------------------------------- + +/** Fetch OG metadata for a URL using the Tauri native HTTP client (no CORS). */ +async function fetchOgMeta(url: string): Promise<OgMeta> { + const cached = ogCache.get(url); + if (cached !== undefined) return cached; + + // Return empty while in-flight to avoid duplicate requests + if (ogInFlight.has(url)) { + return { title: null, description: null, image: null, siteName: null }; + } + + ogInFlight.add(url); + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + const res = await tauriFetch(url, { + signal: controller.signal, + headers: { "User-Agent": "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)" }, + danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false }, + } as RequestInit); + clearTimeout(timer); + + if (!res.ok) { + const empty: OgMeta = { title: null, description: null, image: null, siteName: null }; + ogCache.set(url, empty); + return empty; + } + + // Only parse HTML responses (skip binary, JSON, etc.) + const contentType = res.headers.get("content-type") ?? ""; + if (!contentType.includes("text/html")) { + const empty: OgMeta = { title: null, description: null, image: null, siteName: null }; + ogCache.set(url, empty); + return empty; + } + + const html = await res.text(); + // Only parse the first 50KB to avoid parsing huge pages + const meta = parseOgTags(html.slice(0, 50_000)); + ogCache.set(url, meta); + return meta; + } catch { + const empty: OgMeta = { title: null, description: null, image: null, siteName: null }; + ogCache.set(url, empty); + return empty; + } finally { + ogInFlight.delete(url); + } +} + +// -- Link preview rendering --------------------------------------------------- + +/** Render a link preview card with OG metadata (title, description, image). */ +export function renderGenericLinkPreview(url: string): HTMLDivElement { + const wrap = createElement("div", { class: "msg-embed msg-embed-link" }); + + let displayHost = ""; + try { + displayHost = new URL(url).hostname; + } catch { + displayHost = url; + } + + const content = createElement("div", { class: "msg-embed-link-content" }); + + const hostEl = createElement("div", { class: "msg-embed-host" }, displayHost); + content.appendChild(hostEl); + + const titleEl = createElement("a", { + class: "msg-embed-link-title", + href: url, + target: "_blank", + rel: "noopener noreferrer", + }); + content.appendChild(titleEl); + + const descEl = createElement("div", { class: "msg-embed-link-desc" }); + content.appendChild(descEl); + + wrap.appendChild(content); + + // Image container (shown if og:image exists) + const imageWrap = createElement("div", { class: "msg-embed-link-image" }); + imageWrap.style.display = "none"; + wrap.appendChild(imageWrap); + + // Check cache first for instant render + const cached = ogCache.get(url); + if (cached !== undefined) { + applyOgMeta(cached, titleEl, descEl, hostEl, imageWrap, url, displayHost); + } else { + // Show URL as fallback title while loading + setText(titleEl, displayHost); + void fetchOgMeta(url).then((meta) => { + applyOgMeta(meta, titleEl, descEl, hostEl, imageWrap, url, displayHost); + }); + } + + return wrap; +} + +/** Apply fetched OG metadata to the preview card elements. */ +export function applyOgMeta( + meta: OgMeta, + titleEl: HTMLElement, + descEl: HTMLElement, + hostEl: HTMLElement, + imageWrap: HTMLElement, + url: string, + displayHost: string, +): void { + setText(titleEl, meta.title ?? displayHost); + if (meta.siteName !== null) { + setText(hostEl, meta.siteName); + } + if (meta.description !== null) { + const desc = meta.description.length > 200 + ? meta.description.slice(0, 197) + "..." + : meta.description; + setText(descEl, desc); + descEl.style.display = ""; + } else { + descEl.style.display = "none"; + } + if (meta.image !== null && meta.image.length > 0) { + // Resolve relative image URLs + let imgSrc = meta.image; + if (imgSrc.startsWith("/")) { + try { + const base = new URL(url); + imgSrc = `${base.origin}${imgSrc}`; + } catch { /* keep as-is */ } + } + if (isSafeUrl(imgSrc)) { + const isGif = imgSrc.toLowerCase().endsWith(".gif"); + const attrs: Record<string, string> = { + class: "msg-embed-link-img", + src: imgSrc, + alt: meta.title ?? "", + loading: "lazy", + }; + if (isGif) { + attrs.crossorigin = "anonymous"; + } + const img = createElement("img", attrs); + img.addEventListener("error", () => { + imageWrap.style.display = "none"; + }); + if (isGif) { + (img as HTMLImageElement).addEventListener("load", () => { + observeMedia(img as HTMLImageElement, imgSrc, imageWrap); + }, { once: true }); + } + imageWrap.appendChild(img); + imageWrap.style.display = ""; + } + } +} diff --git a/Client/tauri-client/src/components/message-list/formatting.ts b/Client/tauri-client/src/components/message-list/formatting.ts new file mode 100644 index 00000000..fe75c25e --- /dev/null +++ b/Client/tauri-client/src/components/message-list/formatting.ts @@ -0,0 +1,69 @@ +/** + * Date/time formatting helpers and message grouping logic. + * Pure functions for timestamp parsing, display formatting, and role resolution. + */ + +import { membersStore } from "@stores/members.store"; +import type { Message } from "@stores/messages.store"; + +// -- Constants ---------------------------------------------------------------- + +export const GROUP_THRESHOLD_MS = 5 * 60 * 1000; + +// -- Timestamp helpers -------------------------------------------------------- + +/** Parse a timestamp string, appending 'Z' if no timezone info is present + * so that UTC timestamps from SQLite are correctly interpreted. */ +export 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 = parseTimestamp(iso); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +} + +export function formatFullDate(iso: string): string { + return parseTimestamp(iso).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +export function isSameDay(a: string, b: string): boolean { + const da = parseTimestamp(a); + const db = parseTimestamp(b); + return ( + da.getFullYear() === db.getFullYear() && + da.getMonth() === db.getMonth() && + da.getDate() === db.getDate() + ); +} + +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 = parseTimestamp(curr.timestamp).getTime() - parseTimestamp(prev.timestamp).getTime(); + return dt < GROUP_THRESHOLD_MS; +} + +// -- Role helpers ------------------------------------------------------------- + +export function getUserRole(userId: number): string { + return membersStore.getState().members.get(userId)?.role ?? "member"; +} + +export function roleColorVar(role: string): string { + switch (role) { + case "owner": return "var(--role-owner)"; + case "admin": return "var(--role-admin)"; + case "moderator": return "var(--role-mod)"; + default: return "var(--role-member)"; + } +} diff --git a/Client/tauri-client/src/components/message-list/media.ts b/Client/tauri-client/src/components/message-list/media.ts new file mode 100644 index 00000000..f46f1335 --- /dev/null +++ b/Client/tauri-client/src/components/message-list/media.ts @@ -0,0 +1,371 @@ +/** + * Image and video rendering — YouTube embeds, direct image URLs, + * inline image rendering, lightbox overlay, and URL embed orchestration. + */ + +import { + createElement, + setText, + appendChildren, +} from "@lib/dom"; +import { observeMedia } from "@lib/media-visibility"; +import { isSafeUrl } from "./attachments"; +import { CODE_BLOCK_REGEX, INLINE_CODE_REGEX, URL_REGEX } from "./content-parser"; +import { renderGenericLinkPreview } from "./embeds"; + +/** Check if a URL points to an animated GIF. */ +function isGifUrl(url: string): boolean { + try { + const pathname = new URL(url, "https://placeholder").pathname.toLowerCase(); + return pathname.endsWith(".gif"); + } catch { + return false; + } +} + +// -- YouTube ------------------------------------------------------------------ + +/** Extract YouTube video ID from various YouTube URL formats. */ +export function extractYouTubeId(url: string): string | null { + try { + const parsed = new URL(url); + // youtube.com/watch?v=ID + if ( + (parsed.hostname === "www.youtube.com" || parsed.hostname === "youtube.com") && + parsed.pathname === "/watch" + ) { + return parsed.searchParams.get("v"); + } + // youtu.be/ID + if (parsed.hostname === "youtu.be") { + const id = parsed.pathname.slice(1); + return id.length > 0 ? id : null; + } + // youtube.com/embed/ID + if ( + (parsed.hostname === "www.youtube.com" || parsed.hostname === "youtube.com") && + parsed.pathname.startsWith("/embed/") + ) { + const id = parsed.pathname.slice(7); + return id.length > 0 ? id : null; + } + // youtube.com/shorts/ID + if ( + (parsed.hostname === "www.youtube.com" || parsed.hostname === "youtube.com") && + parsed.pathname.startsWith("/shorts/") + ) { + const id = parsed.pathname.slice(8); + return id.length > 0 ? id : null; + } + } catch { + // Invalid URL + } + return null; +} + +/** Cache for YouTube video titles to avoid re-fetching on every re-render. */ +const ytTitleCache = new Map<string, string>(); + +/** Render a YouTube embed player with title header. */ +export function renderYouTubeEmbed(videoId: string, originalUrl: string): HTMLDivElement { + const wrap = createElement("div", { class: "msg-embed msg-embed-youtube" }); + + // Header: channel name + video title + const header = createElement("div", { class: "msg-embed-yt-header" }); + const channelLabel = createElement("div", { class: "msg-embed-host" }, "YouTube"); + const titleLink = createElement("a", { + class: "msg-embed-yt-title", + href: originalUrl, + target: "_blank", + rel: "noopener noreferrer", + }); + + const cached = ytTitleCache.get(videoId); + if (cached !== undefined) { + setText(titleLink, cached); + } else { + setText(titleLink, "Loading..."); + const oembedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`; + fetch(oembedUrl) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { title?: string } | null) => { + const title = data?.title ?? "YouTube Video"; + ytTitleCache.set(videoId, title); + setText(titleLink, title); + }) + .catch(() => { + ytTitleCache.set(videoId, "YouTube Video"); + setText(titleLink, "YouTube Video"); + }); + } + + appendChildren(header, channelLabel, titleLink); + wrap.appendChild(header); + + // Thumbnail container with play button overlay + const thumbWrap = createElement("div", { class: "msg-embed-yt-player" }); + const thumbUrl = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`; + const thumb = createElement("img", { + class: "msg-embed-thumb", + src: thumbUrl, + alt: "YouTube video", + loading: "lazy", + }); + + const playBtn = createElement("div", { class: "msg-embed-play" }, "\u25B6"); + + appendChildren(thumbWrap, thumb, playBtn); + wrap.appendChild(thumbWrap); + + // On click thumbnail, replace with iframe player + thumbWrap.addEventListener("click", () => { + const iframe = document.createElement("iframe"); + iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1`; + iframe.setAttribute("allowfullscreen", ""); + iframe.setAttribute("allow", "autoplay; encrypted-media"); + iframe.className = "msg-embed-iframe"; + thumbWrap.replaceChildren(iframe); + }, { once: true }); + + return wrap; +} + +// -- Direct images ------------------------------------------------------------ + +/** Check if a URL points directly to an image or GIF file. */ +export 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. */ +export function renderInlineImage(url: string): HTMLDivElement { + const wrap = createElement("div", { + class: "msg-image", + style: "max-width: 400px; contain: layout;", + }); + + const attrs: Record<string, string> = { + src: url, + alt: "Image", + loading: "lazy", + style: "max-width: 100%; max-height: 350px; display: block; border-radius: 4px; cursor: pointer;", + }; + // Enable CORS for GIFs so canvas capture works for freeze/unfreeze + if (isGifUrl(url)) { + attrs.crossorigin = "anonymous"; + } + const img = createElement("img", attrs) as unknown as HTMLImageElement; + + // Observe GIFs for visibility-based freeze/unfreeze + play/pause button + if (isGifUrl(url)) { + img.addEventListener("load", () => { observeMedia(img, url, wrap); }, { once: true }); + } + + 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; +} + +// -- Lightbox ----------------------------------------------------------------- + +/** Open a full-screen lightbox overlay with zoom and pan. */ +export function openImageLightbox(src: string, alt: string): void { + const overlay = createElement("div", { class: "image-lightbox" }); + + const imgWrap = createElement("div", { class: "image-lightbox-wrap" }); + const img = createElement("img", { src, alt }) as HTMLImageElement; + imgWrap.appendChild(img); + overlay.appendChild(imgWrap); + + const closeBtn = createElement("button", { class: "image-lightbox-close" }, "\u2715"); + overlay.appendChild(closeBtn); + + // Zoom & pan state + let scale = 1; + let panX = 0; + let panY = 0; + let isDragging = false; + let dragStartX = 0; + let dragStartY = 0; + let panStartX = 0; + let panStartY = 0; + + function applyTransform(): void { + img.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`; + } + + function resetZoom(): void { + scale = 1; + panX = 0; + panY = 0; + applyTransform(); + } + + function close(): void { + overlay.remove(); + document.removeEventListener("keydown", onKey); + } + + // Mouse wheel zoom + imgWrap.addEventListener("wheel", (e) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.15 : 0.15; + const newScale = Math.max(0.5, Math.min(10, scale + delta * scale)); + // Zoom towards cursor position + const rect = img.getBoundingClientRect(); + const cx = e.clientX - rect.left - rect.width / 2; + const cy = e.clientY - rect.top - rect.height / 2; + const factor = newScale / scale; + panX = panX - cx * (factor - 1); + panY = panY - cy * (factor - 1); + scale = newScale; + applyTransform(); + }); + + // Single click to toggle zoom, with drag detection to avoid zoom on pan + let clickStartX = 0; + let clickStartY = 0; + + img.addEventListener("mousedown", (e) => { + e.preventDefault(); + clickStartX = e.clientX; + clickStartY = e.clientY; + + if (scale > 1.1) { + // Zoomed in — start panning + isDragging = true; + dragStartX = e.clientX; + dragStartY = e.clientY; + panStartX = panX; + panStartY = panY; + overlay.classList.add("dragging"); + } + }); + + img.addEventListener("click", (e) => { + e.stopPropagation(); + // Only toggle zoom if mouse didn't move (not a pan gesture) + const dx = Math.abs(e.clientX - clickStartX); + const dy = Math.abs(e.clientY - clickStartY); + if (dx > 5 || dy > 5) return; + + if (scale > 1.1) { + resetZoom(); + } else { + // Zoom to 3x towards click position + const rect = img.getBoundingClientRect(); + const cx = e.clientX - rect.left - rect.width / 2; + const cy = e.clientY - rect.top - rect.height / 2; + scale = 3; + panX = -cx * 2; + panY = -cy * 2; + applyTransform(); + } + }); + + document.addEventListener("mousemove", function onMove(e) { + if (!isDragging) return; + panX = panStartX + (e.clientX - dragStartX); + panY = panStartY + (e.clientY - dragStartY); + applyTransform(); + }); + + document.addEventListener("mouseup", function onUp() { + if (isDragging) { + isDragging = false; + overlay.classList.remove("dragging"); + } + }); + + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + close(); + }); + + overlay.addEventListener("click", (e) => { + if (e.target === overlay) close(); + }); + + function onKey(e: KeyboardEvent): void { + if (e.key === "Escape") close(); + if (e.key === "+" || e.key === "=") { + scale = Math.min(10, scale * 1.3); + applyTransform(); + } + if (e.key === "-") { + scale = Math.max(0.5, scale / 1.3); + applyTransform(); + } + if (e.key === "0") resetZoom(); + } + document.addEventListener("keydown", onKey); + + document.body.appendChild(overlay); +} + +// -- URL extraction and embed orchestration ----------------------------------- + +/** Extract all URLs from a message content string. */ +export function extractUrls(content: string): string[] { + // Skip URLs inside code blocks + const withoutCodeBlocks = content.replace(CODE_BLOCK_REGEX, "").replace(INLINE_CODE_REGEX, ""); + const matches = withoutCodeBlocks.match(URL_REGEX); + return matches ?? []; +} + +/** Render URL embeds (YouTube players, generic link previews). */ +export function renderUrlEmbeds(content: string): DocumentFragment { + const fragment = document.createDocumentFragment(); + const urls = extractUrls(content); + const seen = new Set<string>(); + + for (const url of urls) { + if (seen.has(url)) continue; + seen.add(url); + + // YouTube embed + const ytId = extractYouTubeId(url); + if (ytId !== null) { + fragment.appendChild(renderYouTubeEmbed(ytId, url)); + 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)); + } + } + + return fragment; +} diff --git a/Client/tauri-client/src/components/message-list/reactions.ts b/Client/tauri-client/src/components/message-list/reactions.ts new file mode 100644 index 00000000..3d7c8d36 --- /dev/null +++ b/Client/tauri-client/src/components/message-list/reactions.ts @@ -0,0 +1,32 @@ +/** + * Reaction pill rendering — emoji reaction chips with counts and toggle behavior. + */ + +import { createElement } from "@lib/dom"; +import type { Message } from "@stores/messages.store"; +import type { MessageListOptions } from "../MessageList"; + +// -- Reaction rendering ------------------------------------------------------- + +export function renderReactions( + msg: Message, + opts: MessageListOptions, + signal: AbortSignal, +): HTMLDivElement { + const container = createElement("div", { class: "msg-reactions" }); + for (const reaction of msg.reactions) { + const chip = createElement("span", { + class: reaction.me ? "reaction-chip me" : "reaction-chip", + }); + const emoji = document.createTextNode(reaction.emoji); + const count = createElement("span", { class: "rc-count" }, String(reaction.count)); + chip.appendChild(emoji); + chip.appendChild(count); + chip.addEventListener("click", () => opts.onReactionClick(msg.id, reaction.emoji), { signal }); + container.appendChild(chip); + } + const addBtn = createElement("span", { class: "reaction-chip add-reaction" }, "+"); + addBtn.addEventListener("click", () => opts.onReactionClick(msg.id, ""), { signal }); + container.appendChild(addBtn); + return container; +} diff --git a/Client/tauri-client/src/components/message-list/renderers.ts b/Client/tauri-client/src/components/message-list/renderers.ts index 458e128a..d964d9c7 100644 --- a/Client/tauri-client/src/components/message-list/renderers.ts +++ b/Client/tauri-client/src/components/message-list/renderers.ts @@ -1,6 +1,7 @@ /** - * Message rendering helpers — pure DOM builders for messages, day dividers, - * reactions, attachments, and content parsing. XSS-safe (no innerHTML). + * Message rendering barrel — re-exports all rendering helpers and contains + * the composite functions (renderMessage, renderDayDivider, renderReplyRef, + * renderSystemMessage) that orchestrate pieces from the split modules. */ import { @@ -8,983 +9,74 @@ import { setText, appendChildren, } from "@lib/dom"; -import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; -import { save } from "@tauri-apps/plugin-dialog"; -import { writeFile } from "@tauri-apps/plugin-fs"; -import type { Attachment } from "@lib/types"; import type { Message } from "@stores/messages.store"; -import { membersStore } from "@stores/members.store"; import type { MessageListOptions } from "../MessageList"; -/** Module-level server host for resolving relative attachment URLs. */ -let _serverHost: string | null = null; - -/** Set the server host (called once from MainPage on connect). */ -export function setServerHost(host: string): void { - _serverHost = host; -} - -/** Resolve a potentially relative URL to a full URL using the server host. */ -function resolveServerUrl(url: string): string { - if (url.startsWith("http://") || url.startsWith("https://")) { - return url; - } - if (_serverHost !== null) { - return `https://${_serverHost}${url}`; - } - return url; -} - -// -- Constants ---------------------------------------------------------------- - -export const GROUP_THRESHOLD_MS = 5 * 60 * 1000; - -const MENTION_REGEX = /@(\w+)/g; -const CODE_BLOCK_REGEX = /```([\s\S]*?)```/g; -const INLINE_CODE_REGEX = /`([^`]+)`/g; -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 = parseTimestamp(iso); - return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; -} - -export function formatFullDate(iso: string): string { - return parseTimestamp(iso).toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - }); -} - -export function isSameDay(a: string, b: string): boolean { - const da = parseTimestamp(a); - const db = parseTimestamp(b); - return ( - da.getFullYear() === db.getFullYear() && - da.getMonth() === db.getMonth() && - da.getDate() === db.getDate() - ); -} - -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 = parseTimestamp(curr.timestamp).getTime() - parseTimestamp(prev.timestamp).getTime(); - return dt < GROUP_THRESHOLD_MS; -} - -function getUserRole(userId: number): string { - return membersStore.getState().members.get(userId)?.role ?? "member"; -} - -function roleColorVar(role: string): string { - switch (role) { - case "owner": return "var(--role-owner)"; - case "admin": return "var(--role-admin)"; - case "moderator": return "var(--role-mod)"; - default: return "var(--role-member)"; - } -} - -// -- Content parsing (XSS-safe, no innerHTML) --------------------------------- - -function renderInlineContent(text: string): DocumentFragment { - const fragment = document.createDocumentFragment(); - let lastIndex = 0; - for (const match of text.matchAll(INLINE_CODE_REGEX)) { - const idx = match.index; - if (idx === undefined) continue; - if (idx > lastIndex) { - fragment.appendChild(renderMentions(text.slice(lastIndex, idx))); - } - const code = createElement("code", {}); - setText(code, match[1]!); - fragment.appendChild(code); - lastIndex = idx + match[0].length; - } - if (lastIndex < text.length) { - fragment.appendChild(renderMentions(text.slice(lastIndex))); - } - return fragment; -} - -export function renderMentions(text: string): DocumentFragment { - // First pass: split by URLs, then handle mentions in non-URL segments - const fragment = document.createDocumentFragment(); - let lastIndex = 0; - for (const match of text.matchAll(URL_REGEX)) { - const idx = match.index; - if (idx === undefined) continue; - if (idx > lastIndex) { - fragment.appendChild(renderMentionSegment(text.slice(lastIndex, idx))); - } - const url = match[0]; - if (isSafeUrl(url)) { - const link = createElement("a", { - class: "msg-link", - href: url, - target: "_blank", - rel: "noopener noreferrer", - }); - setText(link, url); - fragment.appendChild(link); - } else { - fragment.appendChild(document.createTextNode(url)); - } - lastIndex = idx + match[0].length; - } - if (lastIndex < text.length) { - fragment.appendChild(renderMentionSegment(text.slice(lastIndex))); - } - return fragment; -} - -/** Render @mentions within a text segment (no URLs). */ -function renderMentionSegment(text: string): DocumentFragment { - const fragment = document.createDocumentFragment(); - let lastIndex = 0; - for (const match of text.matchAll(MENTION_REGEX)) { - const idx = match.index; - if (idx === undefined) continue; - if (idx > lastIndex) { - fragment.appendChild(document.createTextNode(text.slice(lastIndex, idx))); - } - const span = createElement("span", { class: "mention" }); - setText(span, match[0]); - fragment.appendChild(span); - lastIndex = idx + match[0].length; - } - if (lastIndex < text.length) { - fragment.appendChild(document.createTextNode(text.slice(lastIndex))); - } - return fragment; -} - -function renderMessageContent(content: string): DocumentFragment { - const fragment = document.createDocumentFragment(); - let lastIndex = 0; - for (const match of content.matchAll(CODE_BLOCK_REGEX)) { - const idx = match.index; - if (idx === undefined) continue; - if (idx > lastIndex) { - const text = createElement("div", { class: "msg-text" }); - text.appendChild(renderInlineContent(content.slice(lastIndex, idx))); - fragment.appendChild(text); - } - const codeBlock = createElement("div", { class: "msg-codeblock" }); - setText(codeBlock, match[1]!.trim()); - fragment.appendChild(codeBlock); - lastIndex = idx + match[0].length; - } - if (lastIndex === 0) { - const text = createElement("div", { class: "msg-text" }); - text.appendChild(renderInlineContent(content)); - fragment.appendChild(text); - } else if (lastIndex < content.length) { - const remaining = content.slice(lastIndex).trim(); - if (remaining.length > 0) { - const text = createElement("div", { class: "msg-text" }); - text.appendChild(renderInlineContent(remaining)); - fragment.appendChild(text); - } - } - return fragment; -} - -// -- URL embed rendering ------------------------------------------------------ - -/** Extract YouTube video ID from various YouTube URL formats. */ -function extractYouTubeId(url: string): string | null { - try { - const parsed = new URL(url); - // youtube.com/watch?v=ID - if ( - (parsed.hostname === "www.youtube.com" || parsed.hostname === "youtube.com") && - parsed.pathname === "/watch" - ) { - return parsed.searchParams.get("v"); - } - // youtu.be/ID - if (parsed.hostname === "youtu.be") { - const id = parsed.pathname.slice(1); - return id.length > 0 ? id : null; - } - // youtube.com/embed/ID - if ( - (parsed.hostname === "www.youtube.com" || parsed.hostname === "youtube.com") && - parsed.pathname.startsWith("/embed/") - ) { - const id = parsed.pathname.slice(7); - return id.length > 0 ? id : null; - } - // youtube.com/shorts/ID - if ( - (parsed.hostname === "www.youtube.com" || parsed.hostname === "youtube.com") && - parsed.pathname.startsWith("/shorts/") - ) { - const id = parsed.pathname.slice(8); - return id.length > 0 ? id : null; - } - } catch { - // Invalid URL - } - return null; -} - -/** Cache for YouTube video titles to avoid re-fetching on every re-render. */ -const ytTitleCache = new Map<string, string>(); - -/** Render a YouTube embed player with title header. */ -function renderYouTubeEmbed(videoId: string, originalUrl: string): HTMLDivElement { - const wrap = createElement("div", { class: "msg-embed msg-embed-youtube" }); - - // Header: channel name + video title - const header = createElement("div", { class: "msg-embed-yt-header" }); - const channelLabel = createElement("div", { class: "msg-embed-host" }, "YouTube"); - const titleLink = createElement("a", { - class: "msg-embed-yt-title", - href: originalUrl, - target: "_blank", - rel: "noopener noreferrer", - }); - - const cached = ytTitleCache.get(videoId); - if (cached !== undefined) { - setText(titleLink, cached); - } else { - setText(titleLink, "Loading..."); - const oembedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`; - fetch(oembedUrl) - .then((res) => (res.ok ? res.json() : null)) - .then((data: { title?: string } | null) => { - const title = data?.title ?? "YouTube Video"; - ytTitleCache.set(videoId, title); - setText(titleLink, title); - }) - .catch(() => { - ytTitleCache.set(videoId, "YouTube Video"); - setText(titleLink, "YouTube Video"); - }); - } - - appendChildren(header, channelLabel, titleLink); - wrap.appendChild(header); - - // Thumbnail container with play button overlay - const thumbWrap = createElement("div", { class: "msg-embed-yt-player" }); - const thumbUrl = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`; - const thumb = createElement("img", { - class: "msg-embed-thumb", - src: thumbUrl, - alt: "YouTube video", - loading: "lazy", - }); - - const playBtn = createElement("div", { class: "msg-embed-play" }, "\u25B6"); - - appendChildren(thumbWrap, thumb, playBtn); - wrap.appendChild(thumbWrap); - - // On click thumbnail, replace with iframe player - thumbWrap.addEventListener("click", () => { - const iframe = document.createElement("iframe"); - iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1`; - iframe.setAttribute("allowfullscreen", ""); - iframe.setAttribute("allow", "autoplay; encrypted-media"); - iframe.className = "msg-embed-iframe"; - thumbWrap.replaceChildren(iframe); - }, { once: true }); - - return wrap; -} - -/** Extract all URLs from a message content string. */ -function extractUrls(content: string): string[] { - // Skip URLs inside code blocks - const withoutCodeBlocks = content.replace(CODE_BLOCK_REGEX, "").replace(INLINE_CODE_REGEX, ""); - const matches = withoutCodeBlocks.match(URL_REGEX); - 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(); - const urls = extractUrls(content); - const seen = new Set<string>(); - - for (const url of urls) { - if (seen.has(url)) continue; - seen.add(url); - - // YouTube embed - const ytId = extractYouTubeId(url); - if (ytId !== null) { - fragment.appendChild(renderYouTubeEmbed(ytId, url)); - 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)); - } - } - - return fragment; -} - -/** Open Graph metadata extracted from a page. */ -interface OgMeta { - readonly title: string | null; - readonly description: string | null; - readonly image: string | null; - readonly siteName: string | null; -} - -/** Cache for OG metadata to avoid re-fetching on re-render. */ -const ogCache = new Map<string, OgMeta>(); -/** URLs currently being fetched (prevents duplicate requests). */ -const ogInFlight = new Set<string>(); - -/** Extract Open Graph meta tags from raw HTML using regex (no DOM parser needed). */ -function parseOgTags(html: string): OgMeta { - function getMetaContent(property: string): string | null { - // Match both property="og:X" and name="og:X" patterns - const regex = new RegExp( - `<meta[^>]*(?:property|name)=["']${property}["'][^>]*content=["']([^"']*)["']` + - `|<meta[^>]*content=["']([^"']*)["'][^>]*(?:property|name)=["']${property}["']`, - "i", - ); - const match = html.match(regex); - if (match !== null) { - return match[1] ?? match[2] ?? null; - } - return null; - } - - // Fallback: extract <title> tag if no og:title - function getTitle(): string | null { - const og = getMetaContent("og:title"); - if (og !== null) return og; - const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i); - return titleMatch?.[1]?.trim() ?? null; - } - - // Fallback: extract meta description if no og:description - function getDescription(): string | null { - const og = getMetaContent("og:description"); - if (og !== null) return og; - return getMetaContent("description"); - } - - return { - title: getTitle(), - description: getDescription(), - image: getMetaContent("og:image"), - siteName: getMetaContent("og:site_name"), - }; -} - -/** Fetch OG metadata for a URL using the Tauri native HTTP client (no CORS). */ -async function fetchOgMeta(url: string): Promise<OgMeta> { - const cached = ogCache.get(url); - if (cached !== undefined) return cached; - - // Return empty while in-flight to avoid duplicate requests - if (ogInFlight.has(url)) { - return { title: null, description: null, image: null, siteName: null }; - } - - ogInFlight.add(url); - try { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 5000); - const res = await tauriFetch(url, { - signal: controller.signal, - headers: { "User-Agent": "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)" }, - danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false }, - } as RequestInit); - clearTimeout(timer); - - if (!res.ok) { - const empty: OgMeta = { title: null, description: null, image: null, siteName: null }; - ogCache.set(url, empty); - return empty; - } - - // Only parse HTML responses (skip binary, JSON, etc.) - const contentType = res.headers.get("content-type") ?? ""; - if (!contentType.includes("text/html")) { - const empty: OgMeta = { title: null, description: null, image: null, siteName: null }; - ogCache.set(url, empty); - return empty; - } - - const html = await res.text(); - // Only parse the first 50KB to avoid parsing huge pages - const meta = parseOgTags(html.slice(0, 50_000)); - ogCache.set(url, meta); - return meta; - } catch { - const empty: OgMeta = { title: null, description: null, image: null, siteName: null }; - ogCache.set(url, empty); - return empty; - } finally { - ogInFlight.delete(url); - } -} - -/** Render a link preview card with OG metadata (title, description, image). */ -function renderGenericLinkPreview(url: string): HTMLDivElement { - const wrap = createElement("div", { class: "msg-embed msg-embed-link" }); - - let displayHost = ""; - try { - displayHost = new URL(url).hostname; - } catch { - displayHost = url; - } - - const content = createElement("div", { class: "msg-embed-link-content" }); - - const hostEl = createElement("div", { class: "msg-embed-host" }, displayHost); - content.appendChild(hostEl); - - const titleEl = createElement("a", { - class: "msg-embed-link-title", - href: url, - target: "_blank", - rel: "noopener noreferrer", - }); - content.appendChild(titleEl); - - const descEl = createElement("div", { class: "msg-embed-link-desc" }); - content.appendChild(descEl); - - wrap.appendChild(content); - - // Image container (shown if og:image exists) - const imageWrap = createElement("div", { class: "msg-embed-link-image" }); - imageWrap.style.display = "none"; - wrap.appendChild(imageWrap); - - // Check cache first for instant render - const cached = ogCache.get(url); - if (cached !== undefined) { - applyOgMeta(cached, titleEl, descEl, hostEl, imageWrap, url, displayHost); - } else { - // Show URL as fallback title while loading - setText(titleEl, displayHost); - void fetchOgMeta(url).then((meta) => { - applyOgMeta(meta, titleEl, descEl, hostEl, imageWrap, url, displayHost); - }); - } - - return wrap; -} - -/** Apply fetched OG metadata to the preview card elements. */ -function applyOgMeta( - meta: OgMeta, - titleEl: HTMLElement, - descEl: HTMLElement, - hostEl: HTMLElement, - imageWrap: HTMLElement, - url: string, - displayHost: string, -): void { - setText(titleEl, meta.title ?? displayHost); - if (meta.siteName !== null) { - setText(hostEl, meta.siteName); - } - if (meta.description !== null) { - const desc = meta.description.length > 200 - ? meta.description.slice(0, 197) + "..." - : meta.description; - setText(descEl, desc); - descEl.style.display = ""; - } else { - descEl.style.display = "none"; - } - if (meta.image !== null && meta.image.length > 0) { - // Resolve relative image URLs - let imgSrc = meta.image; - if (imgSrc.startsWith("/")) { - try { - const base = new URL(url); - imgSrc = `${base.origin}${imgSrc}`; - } catch { /* keep as-is */ } - } - if (isSafeUrl(imgSrc)) { - const img = createElement("img", { - class: "msg-embed-link-img", - src: imgSrc, - alt: meta.title ?? "", - loading: "lazy", - }); - img.addEventListener("error", () => { - imageWrap.style.display = "none"; - }); - imageWrap.appendChild(img); - imageWrap.style.display = ""; - } - } -} - -// -- Image lightbox ----------------------------------------------------------- - -/** Open a full-screen lightbox overlay with zoom and pan. */ -function openImageLightbox(src: string, alt: string): void { - const overlay = createElement("div", { class: "image-lightbox" }); - - const imgWrap = createElement("div", { class: "image-lightbox-wrap" }); - const img = createElement("img", { src, alt }) as HTMLImageElement; - imgWrap.appendChild(img); - overlay.appendChild(imgWrap); - - const closeBtn = createElement("button", { class: "image-lightbox-close" }, "\u2715"); - overlay.appendChild(closeBtn); - - // Zoom & pan state - let scale = 1; - let panX = 0; - let panY = 0; - let isDragging = false; - let dragStartX = 0; - let dragStartY = 0; - let panStartX = 0; - let panStartY = 0; - - function applyTransform(): void { - img.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`; - } - - function resetZoom(): void { - scale = 1; - panX = 0; - panY = 0; - applyTransform(); - } - - function close(): void { - overlay.remove(); - document.removeEventListener("keydown", onKey); - } - - // Mouse wheel zoom - imgWrap.addEventListener("wheel", (e) => { - e.preventDefault(); - const delta = e.deltaY > 0 ? -0.15 : 0.15; - const newScale = Math.max(0.5, Math.min(10, scale + delta * scale)); - // Zoom towards cursor position - const rect = img.getBoundingClientRect(); - const cx = e.clientX - rect.left - rect.width / 2; - const cy = e.clientY - rect.top - rect.height / 2; - const factor = newScale / scale; - panX = panX - cx * (factor - 1); - panY = panY - cy * (factor - 1); - scale = newScale; - applyTransform(); - }); - - // Single click to toggle zoom, with drag detection to avoid zoom on pan - let clickStartX = 0; - let clickStartY = 0; - - img.addEventListener("mousedown", (e) => { - e.preventDefault(); - clickStartX = e.clientX; - clickStartY = e.clientY; - - if (scale > 1.1) { - // Zoomed in — start panning - isDragging = true; - dragStartX = e.clientX; - dragStartY = e.clientY; - panStartX = panX; - panStartY = panY; - overlay.classList.add("dragging"); - } - }); - - img.addEventListener("click", (e) => { - e.stopPropagation(); - // Only toggle zoom if mouse didn't move (not a pan gesture) - const dx = Math.abs(e.clientX - clickStartX); - const dy = Math.abs(e.clientY - clickStartY); - if (dx > 5 || dy > 5) return; - - if (scale > 1.1) { - resetZoom(); - } else { - // Zoom to 3x towards click position - const rect = img.getBoundingClientRect(); - const cx = e.clientX - rect.left - rect.width / 2; - const cy = e.clientY - rect.top - rect.height / 2; - scale = 3; - panX = -cx * 2; - panY = -cy * 2; - applyTransform(); - } - }); - - document.addEventListener("mousemove", function onMove(e) { - if (!isDragging) return; - panX = panStartX + (e.clientX - dragStartX); - panY = panStartY + (e.clientY - dragStartY); - applyTransform(); - }); - - document.addEventListener("mouseup", function onUp() { - if (isDragging) { - isDragging = false; - overlay.classList.remove("dragging"); - } - }); - - closeBtn.addEventListener("click", (e) => { - e.stopPropagation(); - close(); - }); - - overlay.addEventListener("click", (e) => { - if (e.target === overlay) close(); - }); - - function onKey(e: KeyboardEvent): void { - if (e.key === "Escape") close(); - if (e.key === "+" || e.key === "=") { - scale = Math.min(10, scale * 1.3); - applyTransform(); - } - if (e.key === "-") { - scale = Math.max(0.5, scale / 1.3); - applyTransform(); - } - if (e.key === "0") resetZoom(); - } - document.addEventListener("keydown", onKey); - - document.body.appendChild(overlay); -} - -// -- Attachment rendering ----------------------------------------------------- - -function formatFileSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -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; - } -} - -// --------------------------------------------------------------------------- -// Image cache: memory + IndexedDB for persistence across restarts -// --------------------------------------------------------------------------- - -/** In-memory cache for instant re-render. */ -const memoryCache = new Map<string, string>(); - -/** In-flight fetch promises to prevent duplicate concurrent requests. */ -const inFlight = new Map<string, Promise<string | null>>(); - -/** IndexedDB database name and store. */ -const IDB_NAME = "owncord-image-cache"; -const IDB_STORE = "images"; -const IDB_VERSION = 1; - -/** Open (or create) the IndexedDB database. */ -function openCacheDb(): Promise<IDBDatabase | null> { - return new Promise((resolve) => { - try { - const req = indexedDB.open(IDB_NAME, IDB_VERSION); - req.onupgradeneeded = () => { - const db = req.result; - if (!db.objectStoreNames.contains(IDB_STORE)) { - db.createObjectStore(IDB_STORE); - } - }; - req.onsuccess = () => resolve(req.result); - req.onerror = () => resolve(null); - } catch { - resolve(null); - } - }); -} - -/** Read a cached data URL from IndexedDB. */ -async function idbGet(url: string): Promise<string | null> { - const db = await openCacheDb(); - if (db === null) return null; - return new Promise((resolve) => { - try { - const tx = db.transaction(IDB_STORE, "readonly"); - const store = tx.objectStore(IDB_STORE); - const req = store.get(url); - req.onsuccess = () => resolve(typeof req.result === "string" ? req.result : null); - req.onerror = () => resolve(null); - } catch { - resolve(null); - } - }); -} - -/** Write a data URL to IndexedDB. */ -async function idbPut(url: string, dataUrl: string): Promise<void> { - const db = await openCacheDb(); - if (db === null) return; - try { - const tx = db.transaction(IDB_STORE, "readwrite"); - tx.objectStore(IDB_STORE).put(dataUrl, url); - } catch { - // IndexedDB full or unavailable — ignore - } -} - -/** Convert a Uint8Array to a base64 string. */ -function uint8ToBase64(bytes: Uint8Array): string { - // Process in chunks to avoid call stack overflow on large files - const CHUNK = 8192; - let binary = ""; - for (let i = 0; i < bytes.length; i += CHUNK) { - const slice = bytes.subarray(i, Math.min(i + CHUNK, bytes.length)); - binary += String.fromCharCode(...slice); - } - return btoa(binary); -} - -/** Fetch an image and return a data: URI. Uses memory → IndexedDB → network. */ -function fetchImageAsDataUrl(url: string): Promise<string | null> { - // 1. Memory cache (instant) - const cached = memoryCache.get(url); - if (cached !== undefined) return Promise.resolve(cached); - - // 2. Deduplicate concurrent requests for the same URL - const existing = inFlight.get(url); - if (existing !== undefined) return existing; - - const promise = (async (): Promise<string | null> => { - // 3. IndexedDB cache (persists across restarts) - const idbCached = await idbGet(url); - if (idbCached !== null) { - memoryCache.set(url, idbCached); - return idbCached; - } - - // 4. Network fetch via Tauri HTTP plugin - try { - const res = await tauriFetch(url, { - danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false }, - } as RequestInit); - if (!res.ok) return null; - - const contentType = res.headers.get("content-type") ?? "image/png"; - const buffer = await res.arrayBuffer(); - const base64 = uint8ToBase64(new Uint8Array(buffer)); - const dataUrl = `data:${contentType};base64,${base64}`; - - // Store in both caches - memoryCache.set(url, dataUrl); - void idbPut(url, dataUrl); - - return dataUrl; - } catch (err) { - console.error("Failed to fetch attachment image:", url, err); - return null; - } - })(); - - inFlight.set(url, promise); - void promise.finally(() => inFlight.delete(url)); - - return promise; -} - -function renderAttachment(att: Attachment): HTMLDivElement { - const resolvedUrl = resolveServerUrl(att.url); - if (isImageMime(att.mime) && isSafeUrl(resolvedUrl)) { - const wrap = createElement("div", { class: "msg-image" }); - - function attachLightbox(img: HTMLImageElement): void { - img.addEventListener("click", () => { - openImageLightbox(img.src, att.filename); - }); - } - - // Check cache first for instant render - const cached = memoryCache.get(resolvedUrl); - if (cached !== undefined) { - const img = createElement("img", { - src: cached, - alt: att.filename, - }) as HTMLImageElement; - attachLightbox(img); - wrap.appendChild(img); - } else { - // Show loading placeholder, then replace with image - const placeholder = createElement("div", { class: "placeholder-img loading" }, att.filename); - wrap.appendChild(placeholder); - - void fetchImageAsDataUrl(resolvedUrl).then((dataUrl) => { - if (dataUrl !== null) { - const img = createElement("img", { - src: dataUrl, - alt: att.filename, - }) as HTMLImageElement; - attachLightbox(img); - placeholder.replaceWith(img); - } - }); - } - - return wrap; - } - const wrap = createElement("div", { class: "msg-file" }); - const inner = createElement("div", { class: "msg-file-inner" }); - const icon = createElement("div", { class: "msg-file-icon" }, "\uD83D\uDCC4"); - const nameEl = createElement("div", { class: "msg-file-name" }, att.filename); - nameEl.addEventListener("click", () => { - void downloadFile(resolvedUrl, att.filename); - }); - const sizeEl = createElement("div", { class: "msg-file-size" }, formatFileSize(att.size)); - const info = createElement("div", {}); - appendChildren(info, nameEl, sizeEl); - const downloadBtn = createElement("button", { - class: "msg-file-download", - title: "Download", - }, "\u2B07"); - downloadBtn.addEventListener("click", () => { - void downloadFile(resolvedUrl, att.filename); - }); - appendChildren(inner, icon, info, downloadBtn); - wrap.appendChild(inner); - return wrap; -} - -/** Download a file via Tauri HTTP plugin and save to disk with native dialog. */ -async function downloadFile(url: string, filename: string): Promise<void> { - try { - // Show native save dialog with suggested filename - const filePath = await save({ defaultPath: filename }); - if (filePath === null) return; // User cancelled - - // Fetch file data - const res = await tauriFetch(url, { - danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false }, - } as RequestInit); - if (!res.ok) return; - - const buffer = await res.arrayBuffer(); - await writeFile(filePath, new Uint8Array(buffer)); - } catch (err) { - console.error("Download failed:", err); - } -} - -// -- Reaction rendering ------------------------------------------------------- - -function renderReactions( - msg: Message, - opts: MessageListOptions, - signal: AbortSignal, -): HTMLDivElement { - const container = createElement("div", { class: "msg-reactions" }); - for (const reaction of msg.reactions) { - const chip = createElement("span", { - class: reaction.me ? "reaction-chip me" : "reaction-chip", - }); - const emoji = document.createTextNode(reaction.emoji); - const count = createElement("span", { class: "rc-count" }, String(reaction.count)); - chip.appendChild(emoji); - chip.appendChild(count); - chip.addEventListener("click", () => opts.onReactionClick(msg.id, reaction.emoji), { signal }); - container.appendChild(chip); - } - const addBtn = createElement("span", { class: "reaction-chip add-reaction" }, "+"); - addBtn.addEventListener("click", () => opts.onReactionClick(msg.id, ""), { signal }); - container.appendChild(addBtn); - return container; -} - -// -- DOM rendering (matches ui-mockup.html structure) ------------------------- +// -- Re-exports (preserve all existing public API) ---------------------------- + +export { + GROUP_THRESHOLD_MS, + parseTimestamp, + formatTime, + formatFullDate, + isSameDay, + shouldGroup, + getUserRole, + roleColorVar, +} from "./formatting"; + +export { + MENTION_REGEX, + CODE_BLOCK_REGEX, + INLINE_CODE_REGEX, + URL_REGEX, + renderInlineContent, + renderMentions, + renderMentionSegment, + renderMessageContent, +} from "./content-parser"; + +export { + extractYouTubeId, + renderYouTubeEmbed, + isDirectImageUrl, + renderInlineImage, + openImageLightbox, + extractUrls, + renderUrlEmbeds, +} from "./media"; + +export type { OgMeta } from "./embeds"; +export { + parseOgTags, + renderGenericLinkPreview, + applyOgMeta, +} from "./embeds"; + +export { + formatFileSize, + isImageMime, + isSafeUrl, + openCacheDb, + uint8ToBase64, + fetchImageAsDataUrl, + renderAttachment, + setServerHost, + resolveServerUrl, +} from "./attachments"; + +export { renderReactions } from "./reactions"; + +// -- Imports for composite functions ------------------------------------------ + +import { formatTime, formatFullDate } from "./formatting"; +import { getUserRole, roleColorVar } from "./formatting"; +import { renderMentions, renderMessageContent } from "./content-parser"; +import { renderUrlEmbeds } from "./media"; +import { renderAttachment } from "./attachments"; +import { renderReactions } from "./reactions"; + +// -- Composite rendering functions -------------------------------------------- export function renderDayDivider(iso: string): HTMLDivElement { const divider = createElement("div", { class: "msg-day-divider" }); diff --git a/Client/tauri-client/src/lib/context-menu.ts b/Client/tauri-client/src/lib/context-menu.ts new file mode 100644 index 00000000..2c632e56 --- /dev/null +++ b/Client/tauri-client/src/lib/context-menu.ts @@ -0,0 +1,90 @@ +/** + * Shared context menu utility. + * Creates a positioned context menu with items, handles click-outside + * dismissal, and cleans up via AbortSignal. + */ + +import { createElement } from "./dom"; + +export interface ContextMenuItem { + readonly label: string; + readonly onClick: () => void; + readonly danger?: boolean; + readonly testId?: string; +} + +export interface ContextMenuOptions { + readonly x: number; + readonly y: number; + readonly items: readonly ContextMenuItem[]; + /** AbortSignal for automatic cleanup when parent component is destroyed. */ + readonly signal: AbortSignal; + /** CSS class added to the menu root (for styling/selection). */ + readonly className?: string; +} + +/** + * Show a context menu at the given coordinates. + * Automatically removes any existing menu with the same className. + * Closes on click outside or when signal is aborted. + */ +export function showContextMenu(opts: ContextMenuOptions): void { + const { x, y, items, signal, className } = opts; + const menuClass = className ?? "context-menu"; + + // Remove any existing context menu with same class + document.querySelectorAll(`.${menuClass}`).forEach((el) => el.remove()); + + const menu = createElement("div", { class: `context-menu ${menuClass}` }); + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + + let hasSeparator = false; + for (const item of items) { + if (hasSeparator && item.danger) { + menu.appendChild(createElement("div", { class: "context-menu-sep" })); + } + + const attrs: Record<string, string> = { + class: item.danger ? "context-menu-item danger" : "context-menu-item", + }; + if (item.testId !== undefined) { + attrs["data-testid"] = item.testId; + } + + const el = createElement("div", attrs, item.label); + el.addEventListener( + "click", + () => { + menu.remove(); + item.onClick(); + }, + { signal }, + ); + menu.appendChild(el); + hasSeparator = !item.danger; + } + + document.body.appendChild(menu); + + // Close on click outside (deferred so the opening click doesn't immediately close) + const dismissAc = new AbortController(); + setTimeout(() => { + document.addEventListener( + "mousedown", + (e: MouseEvent) => { + if (!menu.contains(e.target as Node)) { + menu.remove(); + dismissAc.abort(); + } + }, + { signal: dismissAc.signal }, + ); + }, 0); + + // Clean up if parent component is destroyed + signal.addEventListener("abort", () => { + menu.remove(); + dismissAc.abort(); + }); +} diff --git a/Client/tauri-client/src/lib/dispatcher.ts b/Client/tauri-client/src/lib/dispatcher.ts index 4a49b63a..a2e1ab75 100644 --- a/Client/tauri-client/src/lib/dispatcher.ts +++ b/Client/tauri-client/src/lib/dispatcher.ts @@ -266,7 +266,7 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup { unsubs.push( ws.on("voice_token", (payload) => { - void handleVoiceToken(payload.token, payload.url, payload.channel_id); + void handleVoiceToken(payload.token, payload.url, payload.channel_id, payload.direct_url); }), ); diff --git a/Client/tauri-client/src/lib/dom.ts b/Client/tauri-client/src/lib/dom.ts index 40dbea64..d73e614b 100644 --- a/Client/tauri-client/src/lib/dom.ts +++ b/Client/tauri-client/src/lib/dom.ts @@ -29,10 +29,6 @@ export function createElement<K extends keyof HTMLElementTagNameMap>( for (const [key, value] of Object.entries(attrs)) { if (key === "class") { el.className = value; - } else if (key.startsWith("data-")) { - el.dataset[key.slice(5)] = value; - } else if (key.startsWith("aria-")) { - el.setAttribute(key, value); } else { el.setAttribute(key, value); } diff --git a/Client/tauri-client/src/lib/livekitSession.ts b/Client/tauri-client/src/lib/livekitSession.ts index 12723369..df3d3ad9 100644 --- a/Client/tauri-client/src/lib/livekitSession.ts +++ b/Client/tauri-client/src/lib/livekitSession.ts @@ -416,11 +416,23 @@ export function setServerHost(host: string): void { * construct the full wss:// URL using the server host. This proxies LiveKit * signaling through OwnCord's HTTPS to avoid mixed-content blocks. */ -function resolveLiveKitUrl(url: string): string { - if (url.startsWith("/") && serverHost !== null) { - return `wss://${serverHost}${url}`; +/** + * Resolve which LiveKit URL to use. Prefers the direct URL on localhost + * (avoids self-signed TLS issues with WebView fetch). Falls back to the + * HTTPS proxy path for remote connections. + */ +function resolveLiveKitUrl(proxyPath: string, directUrl?: string): string { + if (serverHost !== null) { + const host = serverHost.split(":")[0] ?? ""; + const isLocal = host === "localhost" || host === "127.0.0.1" || host === "::1"; + if (isLocal && directUrl) { + return directUrl; + } + if (proxyPath.startsWith("/")) { + return `wss://${serverHost}${proxyPath}`; + } } - return url; + return proxyPath; } /** Set error callback for UI feedback (e.g. toast on connection failure). */ @@ -457,6 +469,7 @@ export async function handleVoiceToken( token: string, url: string, channelId: number, + directUrl?: string, ): Promise<void> { // Disconnect existing session first if (room !== null) { @@ -481,7 +494,7 @@ export async function handleVoiceToken( room.on(RoomEvent.Disconnected, handleDisconnected); // Connect to LiveKit server with retry (LiveKit may still be initializing) - const resolvedUrl = resolveLiveKitUrl(url); + const resolvedUrl = resolveLiveKitUrl(url, directUrl); const MAX_RETRIES = 3; const RETRY_DELAY_MS = 2000; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { @@ -492,6 +505,8 @@ export async function handleVoiceToken( if (attempt < MAX_RETRIES) { log.warn("LiveKit connect failed, retrying", { attempt, maxRetries: MAX_RETRIES, error: connectErr }); await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); + // Room may have been nulled by a concurrent leaveVoice() during the delay + if (room === null) throw connectErr; // Recreate room for fresh connection state room.removeAllListeners(); room = new Room({ @@ -697,7 +712,14 @@ export async function switchInputDevice(deviceId: string): Promise<void> { // Re-publish with new device await publishWithNoiseSuppression(); } else { - await room.switchActiveDevice("audioinput", deviceId); + // Empty deviceId means "use system default" — skip switchActiveDevice + // (it throws on empty string). Re-enable mic via LiveKit's native capture. + if (deviceId) { + await room.switchActiveDevice("audioinput", deviceId); + } else { + await room.localParticipant.setMicrophoneEnabled(false); + await room.localParticipant.setMicrophoneEnabled(true); + } } log.info("Switched input device", { deviceId }); } catch (err) { diff --git a/Client/tauri-client/src/lib/media-visibility.ts b/Client/tauri-client/src/lib/media-visibility.ts new file mode 100644 index 00000000..c9698bc5 --- /dev/null +++ b/Client/tauri-client/src/lib/media-visibility.ts @@ -0,0 +1,299 @@ +/** + * Media visibility manager — freezes animated GIFs when they leave + * the viewport, when the window loses focus, or after an auto-pause + * timeout. Provides a play/pause button overlay on each GIF. + * + * Flow: + * GIF loads ──► observeMedia(img, src, wrapper) + * │ + * ├─► Plays for AUTO_PAUSE_MS (10s) + * │ then freezes + shows ▶ button + * │ + * ├─► Leaves viewport → freeze immediately + * │ + * ├─► Window blur/minimize → freeze immediately + * │ + * └─► User clicks ▶ → plays for another 10s + * User clicks ❚❚ → freeze immediately + */ + +import { createElement } from "./dom"; + +/** How long a GIF plays before auto-pausing (ms). */ +const AUTO_PAUSE_MS = 10_000; + +interface MediaEntry { + readonly originalSrc: string; + frozenSrc: string | null; + isIntersecting: boolean; + isPlaying: boolean; + autoTimer: ReturnType<typeof setTimeout> | null; + readonly button: HTMLButtonElement; + readonly wrapper: HTMLElement; +} + +const tracked = new WeakMap<HTMLImageElement, MediaEntry>(); +const allTracked = new Set<WeakRef<HTMLImageElement>>(); + +let observer: IntersectionObserver | null = null; +let visibilityListenerAttached = false; +let documentHidden = false; + +// --------------------------------------------------------------------------- +// Canvas freeze / unfreeze +// --------------------------------------------------------------------------- + +function captureStaticFrame(img: HTMLImageElement): string | null { + try { + const canvas = document.createElement("canvas"); + const w = Math.min(img.naturalWidth, img.width || 400); + const h = Math.min(img.naturalHeight, img.height || 350); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + if (ctx === null) return null; + ctx.drawImage(img, 0, 0, w, h); + return canvas.toDataURL("image/png"); + } catch { + return null; + } +} + +function freezeImage(img: HTMLImageElement, entry: MediaEntry): void { + if (entry.autoTimer !== null) { + clearTimeout(entry.autoTimer); + entry.autoTimer = null; + } + entry.isPlaying = false; + + if (img.src === entry.originalSrc) { + if (entry.frozenSrc === null) { + entry.frozenSrc = captureStaticFrame(img); + } + if (entry.frozenSrc !== null) { + img.src = entry.frozenSrc; + } + } + updateButton(entry); +} + +function unfreezeImage(img: HTMLImageElement, entry: MediaEntry): void { + if (img.src !== entry.originalSrc) { + img.src = entry.originalSrc; + } + entry.isPlaying = true; + updateButton(entry); + startAutoTimer(img, entry); +} + +// --------------------------------------------------------------------------- +// Play/pause button +// --------------------------------------------------------------------------- + +function createPlayPauseButton(): HTMLButtonElement { + const btn = createElement("button", { + class: "gif-play-btn", + type: "button", + "aria-label": "Play/pause GIF", + }); + btn.textContent = "\u25B6"; // ▶ + return btn; +} + +function updateButton(entry: MediaEntry): void { + if (entry.isPlaying) { + entry.button.textContent = "\u275A\u275A"; // ❚❚ + entry.button.classList.add("playing"); + entry.wrapper.classList.remove("gif-paused"); + } else { + entry.button.textContent = "\u25B6"; // ▶ + entry.button.classList.remove("playing"); + entry.wrapper.classList.add("gif-paused"); + } +} + +// --------------------------------------------------------------------------- +// Auto-pause timer +// --------------------------------------------------------------------------- + +function startAutoTimer(img: HTMLImageElement, entry: MediaEntry): void { + if (entry.autoTimer !== null) { + clearTimeout(entry.autoTimer); + } + entry.autoTimer = setTimeout(() => { + entry.autoTimer = null; + if (entry.isPlaying) { + freezeImage(img, entry); + } + }, AUTO_PAUSE_MS); +} + +// --------------------------------------------------------------------------- +// IntersectionObserver +// --------------------------------------------------------------------------- + +function getObserver(): IntersectionObserver | null { + if (typeof IntersectionObserver === "undefined") return null; + if (observer !== null) return observer; + + observer = new IntersectionObserver( + (entries) => { + for (const ioEntry of entries) { + const img = ioEntry.target as HTMLImageElement; + const data = tracked.get(img); + if (data === undefined) continue; + + data.isIntersecting = ioEntry.isIntersecting; + + if (!ioEntry.isIntersecting) { + freezeImage(img, data); + } + // Don't auto-unfreeze on intersection — user controls play via button + // Only resume if the image was playing when it scrolled into view + } + }, + { root: null, rootMargin: "0px", threshold: 0 }, + ); + + return observer; +} + +// --------------------------------------------------------------------------- +// Visibility change (window minimize / blur) +// --------------------------------------------------------------------------- + +function ensureVisibilityListener(): void { + if (visibilityListenerAttached) return; + visibilityListenerAttached = true; + + document.addEventListener("visibilitychange", () => { + documentHidden = document.hidden; + if (documentHidden) { + pauseAllMedia(); + } + // Don't auto-resume on visibility — user controls play via button + }); + + window.addEventListener("blur", () => { + documentHidden = true; + pauseAllMedia(); + }); + + window.addEventListener("focus", () => { + documentHidden = false; + // Don't auto-resume — user clicks play when ready + }); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Start observing a GIF image. Call after the image's `load` event. + * Returns the wrapper element that should replace the bare img in the DOM. + * The wrapper includes the play/pause button overlay. + */ +export function observeMedia( + img: HTMLImageElement, + originalSrc: string, + wrapper: HTMLElement, +): void { + if (tracked.has(img)) return; + + const button = createPlayPauseButton(); + wrapper.style.position = "relative"; + wrapper.appendChild(button); + + const entry: MediaEntry = { + originalSrc, + frozenSrc: null, + isIntersecting: true, + isPlaying: true, + autoTimer: null, + button, + wrapper, + }; + tracked.set(img, entry); + allTracked.add(new WeakRef(img)); + + // Wire button click + button.addEventListener("click", (e) => { + e.stopPropagation(); // don't trigger lightbox + const data = tracked.get(img); + if (data === undefined) return; + + if (data.isPlaying) { + freezeImage(img, data); + } else { + unfreezeImage(img, data); + } + }); + + // Mark as playing, start auto-pause timer + updateButton(entry); + startAutoTimer(img, entry); + + ensureVisibilityListener(); + getObserver()?.observe(img); +} + +/** Stop observing an image element. */ +export function unobserveMedia(img: HTMLImageElement): void { + const entry = tracked.get(img); + if (entry === undefined) return; + + if (entry.autoTimer !== null) { + clearTimeout(entry.autoTimer); + } + unfreezeImage(img, entry); + tracked.delete(img); + observer?.unobserve(img); +} + +/** Freeze all tracked GIFs (called on window hide/blur). */ +export function pauseAllMedia(): void { + for (const ref of allTracked) { + const img = ref.deref(); + if (img === undefined) { + allTracked.delete(ref); + continue; + } + const entry = tracked.get(img); + if (entry !== undefined) { + freezeImage(img, entry); + } + } +} + +/** Unfreeze only GIFs that are currently in the viewport. */ +export function resumeVisibleMedia(): void { + for (const ref of allTracked) { + const img = ref.deref(); + if (img === undefined) { + allTracked.delete(ref); + continue; + } + const entry = tracked.get(img); + if (entry !== undefined && entry.isIntersecting) { + unfreezeImage(img, entry); + } + } +} + +/** Clean up observer (for testing or app teardown). */ +export function destroyObserver(): void { + // Clear all auto-pause timers + for (const ref of allTracked) { + const img = ref.deref(); + if (img !== undefined) { + const entry = tracked.get(img); + if (entry?.autoTimer !== null && entry?.autoTimer !== undefined) { + clearTimeout(entry.autoTimer); + } + } + } + observer?.disconnect(); + observer = null; + allTracked.clear(); +} diff --git a/Client/tauri-client/src/lib/reconcile.ts b/Client/tauri-client/src/lib/reconcile.ts new file mode 100644 index 00000000..dea8c218 --- /dev/null +++ b/Client/tauri-client/src/lib/reconcile.ts @@ -0,0 +1,77 @@ +/** + * DOM list reconciliation utility. + * Efficiently patches a container's children to match a new list of items, + * preserving existing DOM elements where possible (no nuke-and-rebuild). + * + * Algorithm: + * 1. Build a map of existing elements by key + * 2. Walk the new items list: + * - If key exists in map → update in place, move to correct position + * - If key is new → create element, insert at correct position + * 3. Remove any elements whose keys are no longer in the list + * + * This preserves hover states, focus, CSS transitions, and scroll position. + */ + +export interface ReconcileOptions<T> { + /** The container element whose children will be patched. */ + readonly container: Element; + /** The new list of items to render. */ + readonly items: readonly T[]; + /** Extract a unique string key from each item. */ + readonly key: (item: T) => string; + /** Create a new DOM element for an item. */ + readonly create: (item: T) => Element; + /** Update an existing DOM element with new item data. Return the element. */ + readonly update: (el: Element, item: T) => void; +} + +/** + * Reconcile a container's children against a list of keyed items. + * Preserves existing DOM elements, only adding/removing/reordering as needed. + */ +export function reconcileList<T>(opts: ReconcileOptions<T>): void { + const { container, items, key, create, update } = opts; + + // Build map of existing children by data-key attribute + const existingByKey = new Map<string, Element>(); + for (let i = container.children.length - 1; i >= 0; i--) { + const child = container.children[i]!; + const k = child.getAttribute("data-reconcile-key"); + if (k !== null) { + existingByKey.set(k, child); + } + } + + const newKeys = new Set<string>(); + + // Walk new items, create/update/reorder + for (let i = 0; i < items.length; i++) { + const item = items[i]!; + const k = key(item); + newKeys.add(k); + + let el = existingByKey.get(k); + if (el !== undefined) { + // Update existing element + update(el, item); + } else { + // Create new element + el = create(item); + el.setAttribute("data-reconcile-key", k); + } + + // Move/insert to correct position + const currentAtPosition = container.children[i]; + if (currentAtPosition !== el) { + container.insertBefore(el, currentAtPosition ?? null); + } + } + + // Remove elements whose keys are no longer in the list + for (const [k, el] of existingByKey) { + if (!newKeys.has(k)) { + el.remove(); + } + } +} diff --git a/Client/tauri-client/src/lib/store.ts b/Client/tauri-client/src/lib/store.ts index b53009ae..1b80aac8 100644 --- a/Client/tauri-client/src/lib/store.ts +++ b/Client/tauri-client/src/lib/store.ts @@ -2,6 +2,18 @@ * Generic reactive store foundation for OwnCord Tauri client. * Immutable state updates only — setState receives an updater * that must return a NEW state object. + * + * Subscription flow: + * + * setState(updater) + * │ + * ▼ + * queueMicrotask (batch) + * │ + * ├─► subscribe() listeners ← fire on EVERY change + * │ + * └─► subscribeSelector() ← fire only when selected + * listeners slice changes (via ===) */ export interface Store<T> { @@ -21,6 +33,22 @@ export interface Store<T> { */ subscribe(listener: (state: T) => void): () => void; + /** + * Subscribe to a derived slice of state. The listener only fires when + * the selector's return value changes (by default via `===`). + * + * IMPORTANT: Selectors must return stable references for unchanged data. + * A selector like `s => ({ ...s.users })` creates a new object every time + * and will fire on every update, defeating the purpose. Instead use + * `s => s.users` to return the existing reference, or pass a custom + * `isEqual` comparator for value-based comparison. + */ + subscribeSelector<S>( + selector: (state: T) => S, + listener: (selected: S) => void, + isEqual?: (a: S, b: S) => boolean, + ): () => void; + /** Derive a value from the current state using a selector function. */ select<S>(selector: (state: T) => S): S; @@ -57,6 +85,21 @@ export function createStore<T>(initialState: T): Store<T> { }; } + function subscribeSelector<S>( + selector: (state: T) => S, + listener: (selected: S) => void, + isEqual: (a: S, b: S) => boolean = (a, b) => a === b, + ): () => void { + let prev: S = selector(state); + return subscribe((newState) => { + const next = selector(newState); + if (!isEqual(prev, next)) { + prev = next; + listener(next); + } + }); + } + function select<S>(selector: (state: T) => S): S { return selector(state); } @@ -70,5 +113,5 @@ export function createStore<T>(initialState: T): Store<T> { } } - return { getState, setState, subscribe, select, flush }; + return { getState, setState, subscribe, subscribeSelector, select, flush }; } diff --git a/Client/tauri-client/src/lib/types.ts b/Client/tauri-client/src/lib/types.ts index aa67f9e9..32f81a1b 100644 --- a/Client/tauri-client/src/lib/types.ts +++ b/Client/tauri-client/src/lib/types.ts @@ -276,6 +276,7 @@ export interface VoiceTokenPayload { readonly channel_id: number; readonly token: string; readonly url: string; + readonly direct_url?: string; } export interface MemberJoinPayload { diff --git a/Client/tauri-client/src/main.ts b/Client/tauri-client/src/main.ts index af6cca6c..025d679b 100644 --- a/Client/tauri-client/src/main.ts +++ b/Client/tauri-client/src/main.ts @@ -320,28 +320,31 @@ function renderPage(pageId: "connect" | "main"): void { router.onNavigate(renderPage); // Handle logout / disconnect -authStore.subscribe((state) => { - if (!state.isAuthenticated && router.getCurrentPage() === "main") { - // Leave voice channel before disconnecting so other clients see it immediately - const voice = voiceStore.getState(); - if (voice.currentChannelId !== null) { - voiceSessionLeave(false); // false: we send voice_leave below - ws.send({ type: "voice_leave", payload: {} }); - leaveVoiceChannel(); +authStore.subscribeSelector( + (s) => s.isAuthenticated, + (isAuthenticated) => { + if (!isAuthenticated && router.getCurrentPage() === "main") { + // Leave voice channel before disconnecting so other clients see it immediately + const voice = voiceStore.getState(); + if (voice.currentChannelId !== null) { + voiceSessionLeave(false); // false: we send voice_leave below + ws.send({ type: "voice_leave", payload: {} }); + leaveVoiceChannel(); + } + dispatcherCleanup?.(); + dispatcherCleanup = null; + ws.disconnect(); + lastConnectToken = ""; + lastConnectHost = ""; + // Clear stored credential on logout + const host = api.getConfig().host; + if (host) { + void deleteCredential(host); + } + router.navigate("connect"); } - dispatcherCleanup?.(); - dispatcherCleanup = null; - ws.disconnect(); - lastConnectToken = ""; - lastConnectHost = ""; - // Clear stored credential on logout - const host = api.getConfig().host; - if (host) { - void deleteCredential(host); - } - router.navigate("connect"); - } -}); + }, +); // Send voice_leave on window close (best-effort — server readPump defer is the safety net) window.addEventListener("beforeunload", () => { diff --git a/Client/tauri-client/src/pages/ConnectPage.ts b/Client/tauri-client/src/pages/ConnectPage.ts index 9d110342..989cd8ad 100644 --- a/Client/tauri-client/src/pages/ConnectPage.ts +++ b/Client/tauri-client/src/pages/ConnectPage.ts @@ -1,28 +1,22 @@ // ConnectPage — login/register page component. -// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content. +// Thin composition shell that wires ServerPanel and LoginForm together. -import { - createElement, - setText, - appendChildren, - clearChildren, - qs, -} from "@lib/dom"; +import { createElement, appendChildren } from "@lib/dom"; import type { MountableComponent } from "@lib/safe-render"; import { openSettings, closeSettings, uiStore, setTransientError } from "@stores/ui.store"; import { createSettingsOverlay } from "@components/SettingsOverlay"; -import type { HealthStatus, ServerProfile } from "@lib/profiles"; -import { loadCredential } from "@lib/credentials"; +import type { HealthStatus } from "@lib/profiles"; +import { createServerPanel } from "./connect-page/ServerPanel"; +import { createLoginForm } from "./connect-page/LoginForm"; // --------------------------------------------------------------------------- -// Types +// Re-exports (public API must not change) // --------------------------------------------------------------------------- -/** Form state machine states. */ -export type FormState = "idle" | "loading" | "totp" | "connecting" | "error"; +export type { FormState, FormMode } from "./connect-page/LoginForm"; +export type { SimpleProfile } from "./connect-page/ServerPanel"; -/** Form mode: login or register. */ -export type FormMode = "login" | "register"; +import type { SimpleProfile } from "./connect-page/ServerPanel"; /** Callbacks for external wiring (API integration added later). */ export interface ConnectPageCallbacks { @@ -38,40 +32,14 @@ export interface ConnectPageCallbacks { onDeleteProfile?(profileId: string): void; } -/** Minimal profile shape for the default profile list (backward compat). */ -export interface SimpleProfile { - readonly name: string; - readonly host: string; -} - // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -const MIN_PASSWORD_LENGTH = 8; - const DEFAULT_PROFILES: readonly SimpleProfile[] = [ { name: "Local Server", host: "localhost:8443" }, ]; -/** Color palette for server icons. */ -const ICON_COLORS = [ - "#5865F2", "#57F287", "#FEE75C", "#EB459E", "#ED4245", - "#3BA55D", "#FAA61A", "#5865F2", -]; - -function getIconColor(name: string): string { - let hash = 0; - for (let i = 0; i < name.length; i++) { - hash = (hash * 31 + name.charCodeAt(i)) | 0; - } - return ICON_COLORS[Math.abs(hash) % ICON_COLORS.length] ?? "#5865f2"; -} - -function getIconInitials(name: string): string { - return name.slice(0, 2).toUpperCase(); -} - // --------------------------------------------------------------------------- // ConnectPage // --------------------------------------------------------------------------- @@ -90,34 +58,43 @@ export function createConnectPage( /** Re-render the server profile list with updated data. */ refreshProfiles(profiles: readonly SimpleProfile[]): void; } { - // --- internal state (mutable, local to this instance) --- - let formState: FormState = "idle"; - let formMode: FormMode = "login"; - let errorMessage = ""; let container: Element | null = null; + let root: HTMLDivElement; // Cleanup tracking const abortController = new AbortController(); + const { signal } = abortController; - // --- cached DOM references (set during build) --- - let root: HTMLDivElement; - let serverListEl: HTMLDivElement; - let formTitle: HTMLHeadingElement; - let hostInput: HTMLInputElement; - let usernameInput: HTMLInputElement; - let passwordInput: HTMLInputElement; - let inviteGroup: HTMLDivElement; - let inviteInput: HTMLInputElement; - let submitBtn: HTMLButtonElement; - let submitBtnText: HTMLSpanElement; - let toggleModeBtn: HTMLAnchorElement; - let errorBanner: HTMLDivElement; - let totpOverlay: HTMLDivElement; - let totpInput: HTMLInputElement; - let totpSubmitBtn: HTMLButtonElement; - let rememberPasswordCheckbox: HTMLInputElement; - let statusBar: HTMLDivElement; - let statusBarFill: HTMLDivElement; + // --- Create sub-components --- + + const loginForm = createLoginForm({ + signal, + onLogin: callbacks.onLogin, + onRegister: callbacks.onRegister, + onTotpSubmit: callbacks.onTotpSubmit, + onSettingsOpen: () => openSettings(), + }); + + const serverPanel = createServerPanel( + { + signal, + onServerClick(host: string, username?: string) { + loginForm.setHost(host); + if (username) { + loginForm.setCredentials(username); + } + }, + onCredentialLoaded(host: string, username: string, password?: string) { + // Guard: user may have clicked a different profile while loading + if (loginForm.getHost() === host) { + loginForm.setCredentials(username, password); + } + }, + onAddProfile: callbacks.onAddProfile, + onDeleteProfile: callbacks.onDeleteProfile, + }, + initialProfiles, + ); // --------------------------------------------------------------------------- // DOM construction @@ -126,652 +103,21 @@ export function createConnectPage( function buildRoot(): HTMLDivElement { root = createElement("div", { class: "connect-page" }); - const leftPanel = buildServerPanel(); - const rightPanel = buildFormPanel(); + appendChildren(root, serverPanel.element, loginForm.element); - appendChildren(root, leftPanel, rightPanel); + // Status bar at bottom + root.appendChild(loginForm.statusBarElement); - // Status bar at bottom (hidden by default, shown with .visible class) - statusBar = createElement("div", { class: "status-bar" }); - statusBarFill = createElement("div", { class: "status-bar-fill" }); - statusBar.appendChild(statusBarFill); - root.appendChild(statusBar); - - // TOTP overlay (hidden by default) - totpOverlay = buildTotpOverlay(); - root.appendChild(totpOverlay); + // TOTP overlay + root.appendChild(loginForm.totpOverlayElement); return root; } - function buildServerPanel(): HTMLDivElement { - const panel = createElement("div", { class: "server-panel" }); - - const header = createElement("div", { class: "server-panel-header" }); - const heading = createElement("h2", {}, "Servers"); - header.appendChild(heading); - - serverListEl = createElement("div", { class: "server-list" }); - - renderServerProfiles(initialProfiles); - - // Footer with "Add Server" button - const footer = createElement("div", { class: "server-panel-footer" }); - const addBtn = createElement("button", { - class: "btn-add-server", - type: "button", - }); - setText(addBtn, "+ Add Server"); - addBtn.addEventListener("click", handleAddServer, { signal: abortController.signal }); - footer.appendChild(addBtn); - - appendChildren(panel, header, serverListEl, footer); - return panel; - } - - // Map of host -> DOM elements for health status updates - const healthElements = new Map<string, { dot: HTMLDivElement; latency: HTMLSpanElement }>(); - - function renderServerProfiles(profiles: readonly SimpleProfile[]): void { - clearChildren(serverListEl); - healthElements.clear(); - for (const profile of profiles) { - const item = createElement("div", { - class: "server-item", - "data-host": profile.host, - }); - - const icon = createElement("div", { - class: "srv-icon", - style: `background:${getIconColor(profile.name)}`, - }); - setText(icon, getIconInitials(profile.name)); - - // Health status dot on the icon - const statusDot = createElement("div", { class: "srv-status-dot unknown" }); - icon.appendChild(statusDot); - - const info = createElement("div", { class: "srv-info" }); - const name = createElement("div", { class: "srv-name" }, profile.name); - const meta = createElement("div", { class: "srv-meta" }); - const host = createElement("span", { class: "srv-host" }, profile.host); - const latency = createElement("span", { class: "srv-latency" }); - appendChildren(meta, host, latency); - - // Show username if available (full profile has it) - const fullProfile = profile as Partial<ServerProfile>; - if (fullProfile.username) { - const usernameEl = createElement("span", { class: "srv-host" }, fullProfile.username); - appendChildren(meta, usernameEl); - } - - appendChildren(info, name, meta); - - healthElements.set(profile.host, { dot: statusDot, latency }); - - // Delete button (only for full profiles that have an id) - const actions = createElement("div", { class: "srv-actions" }); - if (fullProfile.id && callbacks.onDeleteProfile) { - const deleteBtn = createElement("button", { - class: "srv-btn danger", - type: "button", - "aria-label": "Delete server", - }); - setText(deleteBtn, "\u2715"); - deleteBtn.addEventListener( - "click", - (e) => { - e.stopPropagation(); - callbacks.onDeleteProfile!(fullProfile.id!); - }, - { signal: abortController.signal }, - ); - actions.appendChild(deleteBtn); - } - - appendChildren(item, icon, info, actions); - - item.addEventListener( - "click", - () => { - hostInput.value = profile.host; - // Auto-fill username from profile - if (fullProfile.username) { - usernameInput.value = fullProfile.username; - } - // Auto-fill credentials from credential store - const requestedHost = profile.host; - void (async () => { - const cred = await loadCredential(requestedHost); - // Guard: user may have clicked a different profile while loading - if (cred && hostInput.value === requestedHost) { - usernameInput.value = cred.username; - if (cred.password) { - passwordInput.value = cred.password; - rememberPasswordCheckbox.checked = true; - } - } - })(); - }, - { signal: abortController.signal }, - ); - - serverListEl.appendChild(item); - } - } - - function updateHealthStatus(host: string, status: HealthStatus): void { - const els = healthElements.get(host); - if (!els) return; - - // Update status dot - els.dot.className = `srv-status-dot ${status.status}`; - - // Update latency badge - if (status.latencyMs !== null) { - const ms = status.latencyMs; - setText(els.latency, `${ms}ms`); - els.latency.className = `srv-latency ${ms < 100 ? "good" : ms < 500 ? "warn" : "bad"}`; - } else { - setText(els.latency, ""); - els.latency.className = "srv-latency"; - } - } - - function buildFormPanel(): HTMLDivElement { - const panel = createElement("div", { class: "form-panel" }); - - // Settings gear (top right) - const settingsBtn = createElement("button", { - class: "settings-gear", - type: "button", - "aria-label": "Settings", - }); - setText(settingsBtn, "\u2699"); - settingsBtn.addEventListener("click", () => openSettings(), { signal: abortController.signal }); - - // Form container - const formContainer = createElement("div", { class: "form-container" }); - - // Logo section - const formLogo = createElement("div", { class: "form-logo" }); - const logoMark = createElement("div", { class: "form-logo-mark" }, "OC"); - const logoTitle = createElement("h1", {}, "OwnCord"); - const logoSubtitle = createElement("p", {}, "Connect to your server"); - appendChildren(formLogo, logoMark, logoTitle, logoSubtitle); - - // Form title - formTitle = createElement("h1", {}, "Login"); - - // Error banner (hidden by default via CSS display:none, shown with .visible) - errorBanner = createElement("div", { - class: "error-banner", - role: "alert", - }); - - // Form - const form = createElement("form", { class: "connect-form" }); - form.setAttribute("novalidate", ""); - - // Host - const hostGroup = buildFormGroup("host", "Server Address", "text", "localhost:8443"); - hostInput = qs("input", hostGroup) as HTMLInputElement; - - // Username - const usernameGroup = buildFormGroup("username", "Username", "text", ""); - usernameInput = qs("input", usernameGroup) as HTMLInputElement; - - // Password - const passwordGroup = buildFormGroup("password", "Password", "password", ""); - passwordInput = qs("input", passwordGroup) as HTMLInputElement; - - // Remember password checkbox - const rememberGroup = createElement("div", { class: "form-group remember-password-group" }); - rememberPasswordCheckbox = createElement("input", { - type: "checkbox", - id: "remember-password", - }); - const rememberLabel = createElement("label", { - for: "remember-password", - class: "remember-password-label", - }, "Remember password"); - appendChildren(rememberGroup, rememberPasswordCheckbox, rememberLabel); - - // Invite code (register only, hidden by default) - inviteGroup = buildFormGroup("invite", "Invite Code", "text", ""); - inviteGroup.classList.add("form-group--hidden"); - inviteInput = qs("input", inviteGroup) as HTMLInputElement; - - // Submit button - submitBtn = createElement("button", { - class: "btn-primary", - type: "submit", - }); - submitBtnText = createElement("span", { class: "btn-text" }, "Login"); - const spinnerWrapper = createElement("span", { class: "btn-spinner" }); - const spinner = createElement("div", { class: "spinner" }); - spinnerWrapper.appendChild(spinner); - appendChildren(submitBtn, spinnerWrapper, submitBtnText); - - // Toggle mode link - const formSwitch = createElement("div", { class: "form-switch" }); - toggleModeBtn = createElement("a", {}, "Need an account? Register") as HTMLAnchorElement; - formSwitch.appendChild(toggleModeBtn); - - appendChildren(form, hostGroup, usernameGroup, passwordGroup, rememberGroup, inviteGroup, submitBtn, formSwitch); - - // Wire form events - form.addEventListener("submit", handleFormSubmit, { signal: abortController.signal }); - toggleModeBtn.addEventListener("click", handleToggleMode, { signal: abortController.signal }); - - appendChildren(formContainer, formLogo, errorBanner, form); - appendChildren(panel, settingsBtn, formContainer); - return panel; - } - - function buildFormGroup( - id: string, - labelText: string, - inputType: string, - placeholder: string, - ): HTMLDivElement { - const group = createElement("div", { class: "form-group" }); - const label = createElement("label", { class: "form-label", for: id }, labelText); - const input = createElement("input", { - class: "form-input", - id, - name: id, - type: inputType, - placeholder, - autocomplete: inputType === "password" ? "current-password" : "off", - }); - if (id === "host") { - input.setAttribute("required", ""); - } - if (id === "username" || id === "password") { - input.setAttribute("required", ""); - } - - if (inputType === "password") { - const wrapper = createElement("div", { class: "password-wrapper" }); - const toggle = createElement("button", { - class: "password-toggle", - type: "button", - "aria-label": "Toggle password visibility", - }, "\uD83D\uDC41"); - toggle.addEventListener( - "click", - () => { - const isPassword = input.getAttribute("type") === "password"; - input.setAttribute("type", isPassword ? "text" : "password"); - }, - { signal: abortController.signal }, - ); - appendChildren(wrapper, input, toggle); - appendChildren(group, label, wrapper); - } else { - appendChildren(group, label, input); - } - - return group; - } - - function buildTotpOverlay(): HTMLDivElement { - const overlay = createElement("div", { class: "totp-overlay totp-overlay--hidden" }); - const card = createElement("div", { class: "totp-card" }); - const title = createElement("h2", { class: "totp-title" }, "Two-Factor Authentication"); - const description = createElement("p", { - class: "totp-subtitle", - }, "Enter the 6-digit code from your authenticator app."); - - totpInput = createElement("input", { - class: "form-input", - type: "text", - maxlength: "6", - placeholder: "000000", - inputmode: "numeric", - pattern: "[0-9]{6}", - autocomplete: "one-time-code", - }); - - totpSubmitBtn = createElement("button", { - class: "btn-primary", - type: "button", - }, "Verify"); - - const cancelBtn = createElement("button", { - class: "totp-back", - type: "button", - }, "Cancel"); - - totpSubmitBtn.addEventListener("click", handleTotpSubmit, { signal: abortController.signal }); - cancelBtn.addEventListener("click", handleTotpCancel, { signal: abortController.signal }); - - // Allow Enter key in TOTP input - totpInput.addEventListener( - "keydown", - (e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleTotpSubmit(); - } - }, - { signal: abortController.signal }, - ); - - appendChildren(card, title, description, totpInput, totpSubmitBtn, cancelBtn); - overlay.appendChild(card); - return overlay; - } - - // --------------------------------------------------------------------------- - // Add Server modal - // --------------------------------------------------------------------------- - - function handleAddServer(): void { - if (!callbacks.onAddProfile) return; - - const overlay = createElement("div", { class: "modal-overlay visible" }); - const modal = createElement("div", { class: "modal" }); - - const header = createElement("div", { class: "modal-header" }); - const title = createElement("h3", {}, "Add Server"); - const closeBtn = createElement("button", { class: "modal-close", type: "button" }); - setText(closeBtn, "\u2715"); - appendChildren(header, title, closeBtn); - - const body = createElement("div", { class: "modal-body" }); - const nameGroup = createElement("div", { class: "form-group" }); - const nameLabel = createElement("label", { class: "form-label" }, "Server Name"); - const nameInput = createElement("input", { - class: "form-input", - type: "text", - placeholder: "My Server", - }); - appendChildren(nameGroup, nameLabel, nameInput); - - const hostGroup = createElement("div", { class: "form-group" }); - const hostLabel = createElement("label", { class: "form-label" }, "Host Address"); - const hostAddrInput = createElement("input", { - class: "form-input", - type: "text", - placeholder: "example.com:8443", - }); - appendChildren(hostGroup, hostLabel, hostAddrInput); - - appendChildren(body, nameGroup, hostGroup); - - const footer = createElement("div", { class: "modal-footer" }); - const cancelBtn = createElement("button", { class: "btn-ghost", type: "button" }); - setText(cancelBtn, "Cancel"); - const saveBtn = createElement("button", { class: "btn-primary", type: "button" }); - setText(saveBtn, "Add Server"); - appendChildren(footer, cancelBtn, saveBtn); - - appendChildren(modal, header, body, footer); - overlay.appendChild(modal); - - function closeModal(): void { - overlay.remove(); - } - - function handleSave(): void { - const name = (nameInput as HTMLInputElement).value.trim(); - const addr = (hostAddrInput as HTMLInputElement).value.trim(); - if (!name || !addr) return; - callbacks.onAddProfile!(name, addr); - closeModal(); - } - - closeBtn.addEventListener("click", closeModal, { signal: abortController.signal }); - cancelBtn.addEventListener("click", closeModal, { signal: abortController.signal }); - saveBtn.addEventListener("click", handleSave, { signal: abortController.signal }); - overlay.addEventListener("click", (e) => { - if (e.target === overlay) closeModal(); - }, { signal: abortController.signal }); - - // Allow backdrop stop propagation on modal body - modal.addEventListener("click", (e) => e.stopPropagation(), { signal: abortController.signal }); - - // Enter key submits - hostAddrInput.addEventListener("keydown", (e) => { - if ((e as KeyboardEvent).key === "Enter") handleSave(); - }, { signal: abortController.signal }); - - root.appendChild(overlay); - (nameInput as HTMLInputElement).focus(); - } - - // --------------------------------------------------------------------------- - // State transitions - // --------------------------------------------------------------------------- - - function transitionTo(state: FormState, error?: string): void { - formState = state; - errorMessage = error ?? ""; - - // Update UI based on state - updateSubmitButton(); - updateErrorBanner(); - updateStatusBar(); - updateTotpOverlay(); - updateFormInputsDisabled(); - } - - function updateSubmitButton(): void { - const isLoading = formState === "loading" || formState === "connecting"; - submitBtn.disabled = isLoading; - submitBtn.classList.toggle("loading", isLoading); - - if (formState === "connecting") { - setText(submitBtnText, "Connecting\u2026"); - } else if (formState === "loading") { - setText(submitBtnText, formMode === "login" ? "Logging in\u2026" : "Registering\u2026"); - } else { - setText(submitBtnText, formMode === "login" ? "Login" : "Register"); - } - } - - function updateErrorBanner(): void { - if (formState === "error" && errorMessage) { - setText(errorBanner, errorMessage); - errorBanner.classList.add("visible"); - // The shakeX animation plays automatically via CSS on .error-banner - // Re-trigger animation by removing and re-adding the element - errorBanner.style.animation = "none"; - // Force reflow to restart animation - void errorBanner.offsetWidth; - errorBanner.style.animation = ""; - } else { - errorBanner.classList.remove("visible"); - } - } - - function updateStatusBar(): void { - switch (formState) { - case "idle": - statusBar.classList.remove("visible", "indeterminate"); - break; - case "loading": - statusBar.classList.add("visible", "indeterminate"); - break; - case "totp": - statusBar.classList.remove("visible", "indeterminate"); - break; - case "connecting": - statusBar.classList.add("visible", "indeterminate"); - break; - case "error": - statusBar.classList.remove("visible", "indeterminate"); - break; - } - } - - function updateTotpOverlay(): void { - if (formState === "totp") { - totpOverlay.classList.remove("totp-overlay--hidden"); - totpInput.value = ""; - totpInput.focus(); - } else { - totpOverlay.classList.add("totp-overlay--hidden"); - } - } - - function updateFormInputsDisabled(): void { - const disable = formState === "loading" || formState === "connecting"; - hostInput.disabled = disable; - usernameInput.disabled = disable; - passwordInput.disabled = disable; - inviteInput.disabled = disable; - } - - // --------------------------------------------------------------------------- - // Event handlers - // --------------------------------------------------------------------------- - - function handleToggleMode(): void { - formMode = formMode === "login" ? "register" : "login"; - - setText(formTitle, formMode === "login" ? "Login" : "Register"); - setText(submitBtnText, formMode === "login" ? "Login" : "Register"); - setText( - toggleModeBtn, - formMode === "login" ? "Need an account? Register" : "Already have an account? Login", - ); - - inviteGroup.classList.toggle("form-group--hidden", formMode === "login"); - - // Clear any existing error - if (formState === "error") { - transitionTo("idle"); - } - } - - function validateForm(): string | null { - const host = hostInput.value.trim(); - const username = usernameInput.value.trim(); - const password = passwordInput.value; - - if (!host) { - return "Server address is required."; - } - if (!username) { - return "Username is required."; - } - if (!password) { - return "Password is required."; - } - if (password.length < MIN_PASSWORD_LENGTH) { - return `Password must be at least ${MIN_PASSWORD_LENGTH} characters.`; - } - if (formMode === "register") { - const inviteCode = inviteInput.value.trim(); - if (!inviteCode) { - return "Invite code is required for registration."; - } - } - return null; - } - - async function handleFormSubmit(e: Event): Promise<void> { - e.preventDefault(); - - if (formState === "loading" || formState === "connecting") { - return; - } - - const validationError = validateForm(); - if (validationError !== null) { - transitionTo("error", validationError); - return; - } - - const host = hostInput.value.trim(); - const username = usernameInput.value.trim(); - const password = passwordInput.value; - - transitionTo("loading"); - - try { - if (formMode === "login") { - await callbacks.onLogin(host, username, password); - } else { - const inviteCode = inviteInput.value.trim(); - await callbacks.onRegister(host, username, password, inviteCode); - } - // If the callback didn't throw, the caller handles navigation. - // The caller may also call showTotp() or showError() on this page. - } catch (err: unknown) { - let message: string; - if (err instanceof Error) { - message = err.message; - } else if (typeof err === "string") { - message = err; - } else if (err !== null && typeof err === "object" && "message" in err) { - message = String((err as { message: unknown }).message); - } else { - message = String(err); - } - transitionTo("error", message); - } - } - - async function handleTotpSubmit(): Promise<void> { - const code = totpInput.value.trim(); - if (code.length !== 6 || !/^\d{6}$/.test(code)) { - // Simple inline feedback — add error class to the input - totpInput.classList.add("error"); - setTimeout(() => totpInput.classList.remove("error"), 500); - return; - } - - totpSubmitBtn.disabled = true; - setText(totpSubmitBtn, "Verifying\u2026"); - - try { - await callbacks.onTotpSubmit(code); - } catch (err) { - const message = err instanceof Error ? err.message : "Verification failed."; - transitionTo("error", message); - } finally { - totpSubmitBtn.disabled = false; - setText(totpSubmitBtn, "Verify"); - } - } - - function handleTotpCancel(): void { - transitionTo("idle"); - } - - // --------------------------------------------------------------------------- - // Public API for external state control - // --------------------------------------------------------------------------- - - /** Called externally when login returns requires_2fa. */ - function showTotp(): void { - transitionTo("totp"); - } - - /** Called externally to show a connection-in-progress state. */ - function showConnecting(): void { - transitionTo("connecting"); - } - - /** Called externally to display an error. */ - function showError(message: string): void { - transitionTo("error", message); - } - - /** Reset form to idle state. */ - function resetToIdle(): void { - transitionTo("idle"); - } - // --------------------------------------------------------------------------- // MountableComponent // --------------------------------------------------------------------------- - // Settings overlay instance let settingsOverlay: ReturnType<typeof createSettingsOverlay> | null = null; function mount(target: Element): void { @@ -791,12 +137,12 @@ export function createConnectPage( // Show any pending auth error (e.g. "already connected from another client") const pendingError = uiStore.getState().transientError; if (pendingError) { - transitionTo("error", pendingError); + loginForm.showError(pendingError); setTransientError(null); } // Focus the first input - hostInput.focus(); + loginForm.focusHost(); } function destroy(): void { @@ -814,23 +160,16 @@ export function createConnectPage( return { mount, destroy, - // Extended API for external control - showTotp, - showConnecting, - showError, - resetToIdle, - updateHealthStatus, - /** Whether the "Remember Password" checkbox is checked. */ - getRememberPassword(): boolean { - return rememberPasswordCheckbox?.checked ?? false; - }, - /** Get the current password input value (for saving when remember is checked). */ - getPassword(): string { - return passwordInput?.value ?? ""; - }, - /** Re-render the server profile list with updated data. */ + showTotp: () => loginForm.showTotp(), + showConnecting: () => loginForm.showConnecting(), + showError: (message: string) => loginForm.showError(message), + resetToIdle: () => loginForm.resetToIdle(), + updateHealthStatus: (host: string, status: HealthStatus) => + serverPanel.updateHealthStatus(host, status), + getRememberPassword: () => loginForm.getRememberPassword(), + getPassword: () => loginForm.getPassword(), refreshProfiles(profiles: readonly SimpleProfile[]): void { - renderServerProfiles(profiles); + serverPanel.renderProfiles(profiles); }, }; } diff --git a/Client/tauri-client/src/pages/MainPage.ts b/Client/tauri-client/src/pages/MainPage.ts index b869da48..399c9c16 100644 --- a/Client/tauri-client/src/pages/MainPage.ts +++ b/Client/tauri-client/src/pages/MainPage.ts @@ -395,11 +395,14 @@ export function createMainPage(options: MainPageOptions): MountableComponent { children.push(memberList); const memberListEl = memberListSlot.querySelector(".member-list"); - const unsubMemberList = uiStore.subscribe((state) => { - if (memberListEl !== null) { - memberListEl.classList.toggle("hidden", !state.memberListVisible); - } - }); + const unsubMemberList = uiStore.subscribeSelector( + (s) => s.memberListVisible, + (visible) => { + if (memberListEl !== null) { + memberListEl.classList.toggle("hidden", !visible); + } + }, + ); unsubscribers.push(unsubMemberList); appendChildren(app, serverStripSlot, sidebarWrapper, chatArea, memberListSlot); @@ -529,12 +532,15 @@ export function createMainPage(options: MainPageOptions): MountableComponent { container.appendChild(root); // --- Subscribe to channel changes --- - const unsubChannels = channelsStore.subscribe(() => { - const active = getActiveChannel(); - if (active !== null) { - channelCtrl!.mountChannel(active.id, active.name); - } - }); + const unsubChannels = channelsStore.subscribeSelector( + (s) => s.activeChannelId, + () => { + const active = getActiveChannel(); + if (active !== null) { + channelCtrl!.mountChannel(active.id, active.name); + } + }, + ); unsubscribers.push(unsubChannels); const active = getActiveChannel(); diff --git a/Client/tauri-client/src/pages/connect-page/LoginForm.ts b/Client/tauri-client/src/pages/connect-page/LoginForm.ts new file mode 100644 index 00000000..4630f56b --- /dev/null +++ b/Client/tauri-client/src/pages/connect-page/LoginForm.ts @@ -0,0 +1,560 @@ +// LoginForm — login/register form sub-component for ConnectPage. +// Pure extraction from ConnectPage.ts. No behavior changes. + +import { + createElement, + setText, + appendChildren, + qs, +} from "@lib/dom"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Form state machine states. */ +export type FormState = "idle" | "loading" | "totp" | "connecting" | "error"; + +/** Form mode: login or register. */ +export type FormMode = "login" | "register"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const MIN_PASSWORD_LENGTH = 8; + +// --------------------------------------------------------------------------- +// Options & Return type +// --------------------------------------------------------------------------- + +export interface LoginFormOptions { + readonly signal: AbortSignal; + readonly onLogin: (host: string, username: string, password: string) => Promise<void>; + readonly onRegister: ( + host: string, + username: string, + password: string, + inviteCode: string, + ) => Promise<void>; + readonly onTotpSubmit: (code: string) => Promise<void>; + readonly onSettingsOpen: () => void; +} + +export interface LoginFormApi { + /** The form panel DOM element. */ + readonly element: HTMLDivElement; + /** The status bar element (mounted separately at bottom of page). */ + readonly statusBarElement: HTMLDivElement; + /** The TOTP overlay element (mounted separately). */ + readonly totpOverlayElement: HTMLDivElement; + showTotp(): void; + showConnecting(): void; + showError(message: string): void; + resetToIdle(): void; + getRememberPassword(): boolean; + getPassword(): string; + /** Set the host input value (called when ServerPanel clicks a server). */ + setHost(host: string): void; + /** Set credentials (called for auto-fill from profile or credential store). */ + setCredentials(username: string, password?: string): void; + /** Get host input value (for guard checks). */ + getHost(): string; + /** Focus the host input. */ + focusHost(): void; + destroy(): void; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createLoginForm(opts: LoginFormOptions): LoginFormApi { + const { signal, onLogin, onRegister, onTotpSubmit, onSettingsOpen } = opts; + + // --- internal state --- + let formState: FormState = "idle"; + let formMode: FormMode = "login"; + let errorMessage = ""; + + // --- cached DOM references --- + let formTitle: HTMLHeadingElement; + let hostInput: HTMLInputElement; + let usernameInput: HTMLInputElement; + let passwordInput: HTMLInputElement; + let inviteGroup: HTMLDivElement; + let inviteInput: HTMLInputElement; + let submitBtn: HTMLButtonElement; + let submitBtnText: HTMLSpanElement; + let toggleModeBtn: HTMLAnchorElement; + let errorBanner: HTMLDivElement; + let totpOverlay: HTMLDivElement; + let totpInput: HTMLInputElement; + let totpSubmitBtn: HTMLButtonElement; + let rememberPasswordCheckbox: HTMLInputElement; + let statusBar: HTMLDivElement; + let statusBarFill: HTMLDivElement; + + // --------------------------------------------------------------------------- + // DOM construction + // --------------------------------------------------------------------------- + + function buildFormPanel(): HTMLDivElement { + const panel = createElement("div", { class: "form-panel" }); + + // Settings gear (top right) + const settingsBtn = createElement("button", { + class: "settings-gear", + type: "button", + "aria-label": "Settings", + }); + setText(settingsBtn, "\u2699"); + settingsBtn.addEventListener("click", () => onSettingsOpen(), { signal }); + + // Form container + const formContainer = createElement("div", { class: "form-container" }); + + // Logo section + const formLogo = createElement("div", { class: "form-logo" }); + const logoMark = createElement("div", { class: "form-logo-mark" }, "OC"); + const logoTitle = createElement("h1", {}, "OwnCord"); + const logoSubtitle = createElement("p", {}, "Connect to your server"); + appendChildren(formLogo, logoMark, logoTitle, logoSubtitle); + + // Form title + formTitle = createElement("h1", {}, "Login"); + + // Error banner (hidden by default via CSS display:none, shown with .visible) + errorBanner = createElement("div", { + class: "error-banner", + role: "alert", + }); + + // Form + const form = createElement("form", { class: "connect-form" }); + form.setAttribute("novalidate", ""); + + // Host + const hostGroup = buildFormGroup("host", "Server Address", "text", "localhost:8443"); + hostInput = qs("input", hostGroup) as HTMLInputElement; + + // Username + const usernameGroup = buildFormGroup("username", "Username", "text", ""); + usernameInput = qs("input", usernameGroup) as HTMLInputElement; + + // Password + const passwordGroup = buildFormGroup("password", "Password", "password", ""); + passwordInput = qs("input", passwordGroup) as HTMLInputElement; + + // Remember password checkbox + const rememberGroup = createElement("div", { class: "form-group remember-password-group" }); + rememberPasswordCheckbox = createElement("input", { + type: "checkbox", + id: "remember-password", + }); + const rememberLabel = createElement("label", { + for: "remember-password", + class: "remember-password-label", + }, "Remember password"); + appendChildren(rememberGroup, rememberPasswordCheckbox, rememberLabel); + + // Invite code (register only, hidden by default) + inviteGroup = buildFormGroup("invite", "Invite Code", "text", ""); + inviteGroup.classList.add("form-group--hidden"); + inviteInput = qs("input", inviteGroup) as HTMLInputElement; + + // Submit button + submitBtn = createElement("button", { + class: "btn-primary", + type: "submit", + }); + submitBtnText = createElement("span", { class: "btn-text" }, "Login"); + const spinnerWrapper = createElement("span", { class: "btn-spinner" }); + const spinner = createElement("div", { class: "spinner" }); + spinnerWrapper.appendChild(spinner); + appendChildren(submitBtn, spinnerWrapper, submitBtnText); + + // Toggle mode link + const formSwitch = createElement("div", { class: "form-switch" }); + toggleModeBtn = createElement("a", {}, "Need an account? Register") as HTMLAnchorElement; + formSwitch.appendChild(toggleModeBtn); + + appendChildren(form, hostGroup, usernameGroup, passwordGroup, rememberGroup, inviteGroup, submitBtn, formSwitch); + + // Wire form events + form.addEventListener("submit", handleFormSubmit, { signal }); + toggleModeBtn.addEventListener("click", handleToggleMode, { signal }); + + appendChildren(formContainer, formLogo, errorBanner, form); + appendChildren(panel, settingsBtn, formContainer); + return panel; + } + + function buildFormGroup( + id: string, + labelText: string, + inputType: string, + placeholder: string, + ): HTMLDivElement { + const group = createElement("div", { class: "form-group" }); + const label = createElement("label", { class: "form-label", for: id }, labelText); + const input = createElement("input", { + class: "form-input", + id, + name: id, + type: inputType, + placeholder, + autocomplete: inputType === "password" ? "current-password" : "off", + }); + if (id === "host") { + input.setAttribute("required", ""); + } + if (id === "username" || id === "password") { + input.setAttribute("required", ""); + } + + if (inputType === "password") { + const wrapper = createElement("div", { class: "password-wrapper" }); + const toggle = createElement("button", { + class: "password-toggle", + type: "button", + "aria-label": "Toggle password visibility", + }, "\uD83D\uDC41"); + toggle.addEventListener( + "click", + () => { + const isPassword = input.getAttribute("type") === "password"; + input.setAttribute("type", isPassword ? "text" : "password"); + }, + { signal }, + ); + appendChildren(wrapper, input, toggle); + appendChildren(group, label, wrapper); + } else { + appendChildren(group, label, input); + } + + return group; + } + + function buildTotpOverlay(): HTMLDivElement { + const overlay = createElement("div", { class: "totp-overlay totp-overlay--hidden" }); + const card = createElement("div", { class: "totp-card" }); + const title = createElement("h2", { class: "totp-title" }, "Two-Factor Authentication"); + const description = createElement("p", { + class: "totp-subtitle", + }, "Enter the 6-digit code from your authenticator app."); + + totpInput = createElement("input", { + class: "form-input", + type: "text", + maxlength: "6", + placeholder: "000000", + inputmode: "numeric", + pattern: "[0-9]{6}", + autocomplete: "one-time-code", + }); + + totpSubmitBtn = createElement("button", { + class: "btn-primary", + type: "button", + }, "Verify"); + + const cancelBtn = createElement("button", { + class: "totp-back", + type: "button", + }, "Cancel"); + + totpSubmitBtn.addEventListener("click", handleTotpSubmit, { signal }); + cancelBtn.addEventListener("click", handleTotpCancel, { signal }); + + // Allow Enter key in TOTP input + totpInput.addEventListener( + "keydown", + (e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleTotpSubmit(); + } + }, + { signal }, + ); + + appendChildren(card, title, description, totpInput, totpSubmitBtn, cancelBtn); + overlay.appendChild(card); + return overlay; + } + + // --------------------------------------------------------------------------- + // State transitions + // --------------------------------------------------------------------------- + + function transitionTo(state: FormState, error?: string): void { + formState = state; + errorMessage = error ?? ""; + + // Update UI based on state + updateSubmitButton(); + updateErrorBanner(); + updateStatusBar(); + updateTotpOverlay(); + updateFormInputsDisabled(); + } + + function updateSubmitButton(): void { + const isLoading = formState === "loading" || formState === "connecting"; + submitBtn.disabled = isLoading; + submitBtn.classList.toggle("loading", isLoading); + + if (formState === "connecting") { + setText(submitBtnText, "Connecting\u2026"); + } else if (formState === "loading") { + setText(submitBtnText, formMode === "login" ? "Logging in\u2026" : "Registering\u2026"); + } else { + setText(submitBtnText, formMode === "login" ? "Login" : "Register"); + } + } + + function updateErrorBanner(): void { + if (formState === "error" && errorMessage) { + setText(errorBanner, errorMessage); + errorBanner.classList.add("visible"); + // The shakeX animation plays automatically via CSS on .error-banner + // Re-trigger animation by removing and re-adding the element + errorBanner.style.animation = "none"; + // Force reflow to restart animation + void errorBanner.offsetWidth; + errorBanner.style.animation = ""; + } else { + errorBanner.classList.remove("visible"); + } + } + + function updateStatusBar(): void { + switch (formState) { + case "idle": + statusBar.classList.remove("visible", "indeterminate"); + break; + case "loading": + statusBar.classList.add("visible", "indeterminate"); + break; + case "totp": + statusBar.classList.remove("visible", "indeterminate"); + break; + case "connecting": + statusBar.classList.add("visible", "indeterminate"); + break; + case "error": + statusBar.classList.remove("visible", "indeterminate"); + break; + } + } + + function updateTotpOverlay(): void { + if (formState === "totp") { + totpOverlay.classList.remove("totp-overlay--hidden"); + totpInput.value = ""; + totpInput.focus(); + } else { + totpOverlay.classList.add("totp-overlay--hidden"); + } + } + + function updateFormInputsDisabled(): void { + const disable = formState === "loading" || formState === "connecting"; + hostInput.disabled = disable; + usernameInput.disabled = disable; + passwordInput.disabled = disable; + inviteInput.disabled = disable; + } + + // --------------------------------------------------------------------------- + // Event handlers + // --------------------------------------------------------------------------- + + function handleToggleMode(): void { + formMode = formMode === "login" ? "register" : "login"; + + setText(formTitle, formMode === "login" ? "Login" : "Register"); + setText(submitBtnText, formMode === "login" ? "Login" : "Register"); + setText( + toggleModeBtn, + formMode === "login" ? "Need an account? Register" : "Already have an account? Login", + ); + + inviteGroup.classList.toggle("form-group--hidden", formMode === "login"); + + // Clear any existing error + if (formState === "error") { + transitionTo("idle"); + } + } + + function validateForm(): string | null { + const host = hostInput.value.trim(); + const username = usernameInput.value.trim(); + const password = passwordInput.value; + + if (!host) { + return "Server address is required."; + } + if (!username) { + return "Username is required."; + } + if (!password) { + return "Password is required."; + } + if (password.length < MIN_PASSWORD_LENGTH) { + return `Password must be at least ${MIN_PASSWORD_LENGTH} characters.`; + } + if (formMode === "register") { + const inviteCode = inviteInput.value.trim(); + if (!inviteCode) { + return "Invite code is required for registration."; + } + } + return null; + } + + async function handleFormSubmit(e: Event): Promise<void> { + e.preventDefault(); + + if (formState === "loading" || formState === "connecting") { + return; + } + + const validationError = validateForm(); + if (validationError !== null) { + transitionTo("error", validationError); + return; + } + + const host = hostInput.value.trim(); + const username = usernameInput.value.trim(); + const password = passwordInput.value; + + transitionTo("loading"); + + try { + if (formMode === "login") { + await onLogin(host, username, password); + } else { + const inviteCode = inviteInput.value.trim(); + await onRegister(host, username, password, inviteCode); + } + // If the callback didn't throw, the caller handles navigation. + // The caller may also call showTotp() or showError() on this page. + } catch (err: unknown) { + let message: string; + if (err instanceof Error) { + message = err.message; + } else if (typeof err === "string") { + message = err; + } else if (err !== null && typeof err === "object" && "message" in err) { + message = String((err as { message: unknown }).message); + } else { + message = String(err); + } + transitionTo("error", message); + } + } + + async function handleTotpSubmit(): Promise<void> { + const code = totpInput.value.trim(); + if (code.length !== 6 || !/^\d{6}$/.test(code)) { + // Simple inline feedback — add error class to the input + totpInput.classList.add("error"); + setTimeout(() => totpInput.classList.remove("error"), 500); + return; + } + + totpSubmitBtn.disabled = true; + setText(totpSubmitBtn, "Verifying\u2026"); + + try { + await onTotpSubmit(code); + } catch (err) { + const message = err instanceof Error ? err.message : "Verification failed."; + transitionTo("error", message); + } finally { + totpSubmitBtn.disabled = false; + setText(totpSubmitBtn, "Verify"); + } + } + + function handleTotpCancel(): void { + transitionTo("idle"); + } + + // --------------------------------------------------------------------------- + // Build elements + // --------------------------------------------------------------------------- + + const panelEl = buildFormPanel(); + + // Status bar (hidden by default, shown with .visible class) + statusBar = createElement("div", { class: "status-bar" }); + statusBarFill = createElement("div", { class: "status-bar-fill" }); + statusBar.appendChild(statusBarFill); + + // TOTP overlay (hidden by default) + totpOverlay = buildTotpOverlay(); + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + return { + element: panelEl, + statusBarElement: statusBar, + totpOverlayElement: totpOverlay, + + showTotp(): void { + transitionTo("totp"); + }, + + showConnecting(): void { + transitionTo("connecting"); + }, + + showError(message: string): void { + transitionTo("error", message); + }, + + resetToIdle(): void { + transitionTo("idle"); + }, + + getRememberPassword(): boolean { + return rememberPasswordCheckbox?.checked ?? false; + }, + + getPassword(): string { + return passwordInput?.value ?? ""; + }, + + setHost(host: string): void { + hostInput.value = host; + }, + + setCredentials(username: string, password?: string): void { + usernameInput.value = username; + if (password) { + passwordInput.value = password; + rememberPasswordCheckbox.checked = true; + } + }, + + getHost(): string { + return hostInput?.value ?? ""; + }, + + focusHost(): void { + hostInput.focus(); + }, + + destroy(): void { + // Cleanup is handled by the shared AbortSignal from the parent + }, + }; +} diff --git a/Client/tauri-client/src/pages/connect-page/ServerPanel.ts b/Client/tauri-client/src/pages/connect-page/ServerPanel.ts new file mode 100644 index 00000000..92b294c0 --- /dev/null +++ b/Client/tauri-client/src/pages/connect-page/ServerPanel.ts @@ -0,0 +1,299 @@ +// ServerPanel — server profile list sub-component for ConnectPage. +// Pure extraction from ConnectPage.ts. No behavior changes. + +import { + createElement, + setText, + appendChildren, + clearChildren, +} from "@lib/dom"; +import type { HealthStatus, ServerProfile } from "@lib/profiles"; +import { loadCredential } from "@lib/credentials"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Minimal profile shape for the default profile list (backward compat). */ +export interface SimpleProfile { + readonly name: string; + readonly host: string; +} + +/** Color palette for server icons. */ +const ICON_COLORS = [ + "#5865F2", "#57F287", "#FEE75C", "#EB459E", "#ED4245", + "#3BA55D", "#FAA61A", "#5865F2", +]; + +function getIconColor(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = (hash * 31 + name.charCodeAt(i)) | 0; + } + return ICON_COLORS[Math.abs(hash) % ICON_COLORS.length] ?? "#5865f2"; +} + +function getIconInitials(name: string): string { + return name.slice(0, 2).toUpperCase(); +} + +// --------------------------------------------------------------------------- +// Options & Return type +// --------------------------------------------------------------------------- + +export interface ServerPanelOptions { + readonly signal: AbortSignal; + /** Called immediately when the user clicks a server profile. */ + readonly onServerClick: (host: string, username?: string) => void; + /** Called after async credential lookup succeeds (may set password). */ + readonly onCredentialLoaded: (host: string, username: string, password?: string) => void; + readonly onAddProfile?: (name: string, host: string) => void; + readonly onDeleteProfile?: (profileId: string) => void; +} + +export interface ServerPanelApi { + readonly element: HTMLDivElement; + renderProfiles(profiles: readonly SimpleProfile[]): void; + updateHealthStatus(host: string, status: HealthStatus): void; + destroy(): void; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createServerPanel( + opts: ServerPanelOptions, + initialProfiles: readonly SimpleProfile[], +): ServerPanelApi { + const { signal, onServerClick, onCredentialLoaded, onAddProfile, onDeleteProfile } = opts; + + // Map of host -> DOM elements for health status updates + const healthElements = new Map<string, { dot: HTMLDivElement; latency: HTMLSpanElement }>(); + + // Cached DOM references + let serverListEl: HTMLDivElement; + + // --------------------------------------------------------------------------- + // DOM construction + // --------------------------------------------------------------------------- + + function buildPanel(): HTMLDivElement { + const panel = createElement("div", { class: "server-panel" }); + + const header = createElement("div", { class: "server-panel-header" }); + const heading = createElement("h2", {}, "Servers"); + header.appendChild(heading); + + serverListEl = createElement("div", { class: "server-list" }); + + renderServerProfiles(initialProfiles); + + // Footer with "Add Server" button + const footer = createElement("div", { class: "server-panel-footer" }); + const addBtn = createElement("button", { + class: "btn-add-server", + type: "button", + }); + setText(addBtn, "+ Add Server"); + addBtn.addEventListener("click", handleAddServer, { signal }); + footer.appendChild(addBtn); + + appendChildren(panel, header, serverListEl, footer); + return panel; + } + + function renderServerProfiles(profiles: readonly SimpleProfile[]): void { + clearChildren(serverListEl); + healthElements.clear(); + for (const profile of profiles) { + const item = createElement("div", { + class: "server-item", + "data-host": profile.host, + }); + + const icon = createElement("div", { + class: "srv-icon", + style: `background:${getIconColor(profile.name)}`, + }); + setText(icon, getIconInitials(profile.name)); + + // Health status dot on the icon + const statusDot = createElement("div", { class: "srv-status-dot unknown" }); + icon.appendChild(statusDot); + + const info = createElement("div", { class: "srv-info" }); + const name = createElement("div", { class: "srv-name" }, profile.name); + const meta = createElement("div", { class: "srv-meta" }); + const host = createElement("span", { class: "srv-host" }, profile.host); + const latency = createElement("span", { class: "srv-latency" }); + appendChildren(meta, host, latency); + + // Show username if available (full profile has it) + const fullProfile = profile as Partial<ServerProfile>; + if (fullProfile.username) { + const usernameEl = createElement("span", { class: "srv-host" }, fullProfile.username); + appendChildren(meta, usernameEl); + } + + appendChildren(info, name, meta); + + healthElements.set(profile.host, { dot: statusDot, latency }); + + // Delete button (only for full profiles that have an id) + const actions = createElement("div", { class: "srv-actions" }); + if (fullProfile.id && onDeleteProfile) { + const deleteBtn = createElement("button", { + class: "srv-btn danger", + type: "button", + "aria-label": "Delete server", + }); + setText(deleteBtn, "\u2715"); + deleteBtn.addEventListener( + "click", + (e) => { + e.stopPropagation(); + onDeleteProfile(fullProfile.id!); + }, + { signal }, + ); + actions.appendChild(deleteBtn); + } + + appendChildren(item, icon, info, actions); + + item.addEventListener( + "click", + () => { + // Immediately fill host + username from profile + onServerClick(profile.host, fullProfile.username); + // Auto-fill credentials from credential store (async) + const requestedHost = profile.host; + void (async () => { + const cred = await loadCredential(requestedHost); + if (cred) { + onCredentialLoaded(requestedHost, cred.username, cred.password); + } + })(); + }, + { signal }, + ); + + serverListEl.appendChild(item); + } + } + + function updateHealthStatus(host: string, status: HealthStatus): void { + const els = healthElements.get(host); + if (!els) return; + + // Update status dot + els.dot.className = `srv-status-dot ${status.status}`; + + // Update latency badge + if (status.latencyMs !== null) { + const ms = status.latencyMs; + setText(els.latency, `${ms}ms`); + els.latency.className = `srv-latency ${ms < 100 ? "good" : ms < 500 ? "warn" : "bad"}`; + } else { + setText(els.latency, ""); + els.latency.className = "srv-latency"; + } + } + + // --------------------------------------------------------------------------- + // Add Server modal + // --------------------------------------------------------------------------- + + function handleAddServer(): void { + if (!onAddProfile) return; + + const overlay = createElement("div", { class: "modal-overlay visible" }); + const modal = createElement("div", { class: "modal" }); + + const header = createElement("div", { class: "modal-header" }); + const title = createElement("h3", {}, "Add Server"); + const closeBtn = createElement("button", { class: "modal-close", type: "button" }); + setText(closeBtn, "\u2715"); + appendChildren(header, title, closeBtn); + + const body = createElement("div", { class: "modal-body" }); + const nameGroup = createElement("div", { class: "form-group" }); + const nameLabel = createElement("label", { class: "form-label" }, "Server Name"); + const nameInput = createElement("input", { + class: "form-input", + type: "text", + placeholder: "My Server", + }); + appendChildren(nameGroup, nameLabel, nameInput); + + const hostGroup = createElement("div", { class: "form-group" }); + const hostLabel = createElement("label", { class: "form-label" }, "Host Address"); + const hostAddrInput = createElement("input", { + class: "form-input", + type: "text", + placeholder: "example.com:8443", + }); + appendChildren(hostGroup, hostLabel, hostAddrInput); + + appendChildren(body, nameGroup, hostGroup); + + const footer = createElement("div", { class: "modal-footer" }); + const cancelBtn = createElement("button", { class: "btn-ghost", type: "button" }); + setText(cancelBtn, "Cancel"); + const saveBtn = createElement("button", { class: "btn-primary", type: "button" }); + setText(saveBtn, "Add Server"); + appendChildren(footer, cancelBtn, saveBtn); + + appendChildren(modal, header, body, footer); + overlay.appendChild(modal); + + function closeModal(): void { + overlay.remove(); + } + + function handleSave(): void { + const name = (nameInput as HTMLInputElement).value.trim(); + const addr = (hostAddrInput as HTMLInputElement).value.trim(); + if (!name || !addr) return; + onAddProfile!(name, addr); + closeModal(); + } + + closeBtn.addEventListener("click", closeModal, { signal }); + cancelBtn.addEventListener("click", closeModal, { signal }); + saveBtn.addEventListener("click", handleSave, { signal }); + overlay.addEventListener("click", (e) => { + if (e.target === overlay) closeModal(); + }, { signal }); + + // Allow backdrop stop propagation on modal body + modal.addEventListener("click", (e) => e.stopPropagation(), { signal }); + + // Enter key submits + hostAddrInput.addEventListener("keydown", (e) => { + if ((e as KeyboardEvent).key === "Enter") handleSave(); + }, { signal }); + + // Mount onto the panel's closest connect-page root + const root = panelEl.closest(".connect-page") ?? document.body; + root.appendChild(overlay); + (nameInput as HTMLInputElement).focus(); + } + + // --------------------------------------------------------------------------- + // Build & return + // --------------------------------------------------------------------------- + + const panelEl = buildPanel(); + + return { + element: panelEl, + renderProfiles: renderServerProfiles, + updateHealthStatus, + destroy(): void { + // Cleanup is handled by the shared AbortSignal from the parent + }, + }; +} diff --git a/Client/tauri-client/src/pages/main-page/VideoModeController.ts b/Client/tauri-client/src/pages/main-page/VideoModeController.ts index b974487c..45006c64 100644 --- a/Client/tauri-client/src/pages/main-page/VideoModeController.ts +++ b/Client/tauri-client/src/pages/main-page/VideoModeController.ts @@ -112,10 +112,8 @@ export function createVideoModeController( } } } else { - if (localTileAdded) { - videoGrid.removeStream(currentUserId); - localTileAdded = false; - } + videoGrid.removeStream(currentUserId); + localTileAdded = false; } // Remove remote video tiles for users who turned off their camera diff --git a/Client/tauri-client/src/styles/app.css b/Client/tauri-client/src/styles/app.css index b4934e41..4d1e76ac 100644 --- a/Client/tauri-client/src/styles/app.css +++ b/Client/tauri-client/src/styles/app.css @@ -421,6 +421,28 @@ } .msg-image .placeholder-img.loading { opacity: .6; } +/* GIF play/pause overlay */ +.gif-play-btn { + position: absolute; bottom: 8px; left: 8px; + width: 32px; height: 32px; border-radius: 50%; + background: rgba(0,0,0,.7); color: #fff; + border: none; cursor: pointer; + font-size: 14px; line-height: 32px; text-align: center; + opacity: 0; transition: opacity .15s ease; + z-index: 2; padding: 0; + display: flex; align-items: center; justify-content: center; +} +.msg-image:hover .gif-play-btn, +.gif-paused .gif-play-btn { opacity: 1; } +.gif-play-btn:hover { background: rgba(0,0,0,.9); } +.gif-play-btn.playing { font-size: 11px; letter-spacing: 1px; } +.gif-paused::after { + content: "GIF"; position: absolute; top: 8px; left: 8px; + background: rgba(0,0,0,.7); color: #fff; font-size: 10px; + font-weight: 700; padding: 2px 6px; border-radius: 4px; + letter-spacing: .5px; pointer-events: none; +} + /* Image lightbox overlay */ .image-lightbox { position: fixed; inset: 0; z-index: 600; diff --git a/Client/tauri-client/tests/unit/context-menu.test.ts b/Client/tauri-client/tests/unit/context-menu.test.ts new file mode 100644 index 00000000..09a0c192 --- /dev/null +++ b/Client/tauri-client/tests/unit/context-menu.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { showContextMenu } from '../../src/lib/context-menu'; + +describe('showContextMenu', () => { + let ac: AbortController; + + beforeEach(() => { + ac = new AbortController(); + // Clean up any leftover menus + document.querySelectorAll('.context-menu').forEach((el) => el.remove()); + }); + + afterEach(() => { + ac.abort(); + document.querySelectorAll('.context-menu').forEach((el) => el.remove()); + }); + + it('renders menu at correct position', () => { + showContextMenu({ + x: 100, + y: 200, + items: [{ label: 'Test', onClick: vi.fn() }], + signal: ac.signal, + }); + + const menu = document.querySelector('.context-menu') as HTMLElement; + expect(menu).not.toBeNull(); + expect(menu.style.left).toBe('100px'); + expect(menu.style.top).toBe('200px'); + }); + + it('renders all items', () => { + showContextMenu({ + x: 0, + y: 0, + items: [ + { label: 'Edit', onClick: vi.fn() }, + { label: 'Delete', onClick: vi.fn(), danger: true }, + ], + signal: ac.signal, + }); + + const items = document.querySelectorAll('.context-menu-item'); + expect(items.length).toBe(2); + expect(items[0]!.textContent).toBe('Edit'); + expect(items[1]!.textContent).toBe('Delete'); + }); + + it('fires onClick when item clicked', () => { + const onClick = vi.fn(); + showContextMenu({ + x: 0, + y: 0, + items: [{ label: 'Action', onClick }], + signal: ac.signal, + }); + + const item = document.querySelector('.context-menu-item') as HTMLElement; + item.click(); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('removes menu after item click', () => { + showContextMenu({ + x: 0, + y: 0, + items: [{ label: 'Action', onClick: vi.fn() }], + signal: ac.signal, + }); + + const item = document.querySelector('.context-menu-item') as HTMLElement; + item.click(); + + expect(document.querySelector('.context-menu')).toBeNull(); + }); + + it('applies danger class to danger items', () => { + showContextMenu({ + x: 0, + y: 0, + items: [{ label: 'Delete', onClick: vi.fn(), danger: true }], + signal: ac.signal, + }); + + const item = document.querySelector('.context-menu-item') as HTMLElement; + expect(item.classList.contains('danger')).toBe(true); + }); + + it('applies testId to items', () => { + showContextMenu({ + x: 0, + y: 0, + items: [{ label: 'Edit', onClick: vi.fn(), testId: 'ctx-edit' }], + signal: ac.signal, + }); + + const item = document.querySelector('[data-testid="ctx-edit"]'); + expect(item).not.toBeNull(); + }); + + it('removes menu on AbortSignal abort', () => { + showContextMenu({ + x: 0, + y: 0, + items: [{ label: 'Test', onClick: vi.fn() }], + signal: ac.signal, + }); + + expect(document.querySelector('.context-menu')).not.toBeNull(); + + ac.abort(); + + expect(document.querySelector('.context-menu')).toBeNull(); + }); + + it('removes existing menu with same className before showing new one', () => { + showContextMenu({ + x: 0, + y: 0, + items: [{ label: 'First', onClick: vi.fn() }], + signal: ac.signal, + className: 'my-menu', + }); + + showContextMenu({ + x: 50, + y: 50, + items: [{ label: 'Second', onClick: vi.fn() }], + signal: ac.signal, + className: 'my-menu', + }); + + const menus = document.querySelectorAll('.my-menu'); + expect(menus.length).toBe(1); + expect(menus[0]!.querySelector('.context-menu-item')!.textContent).toBe('Second'); + }); +}); diff --git a/Client/tauri-client/tests/unit/media-visibility.test.ts b/Client/tauri-client/tests/unit/media-visibility.test.ts new file mode 100644 index 00000000..4c3dc8f0 --- /dev/null +++ b/Client/tauri-client/tests/unit/media-visibility.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + observeMedia, + unobserveMedia, + pauseAllMedia, + resumeVisibleMedia, + destroyObserver, +} from '../../src/lib/media-visibility'; + +// Mock IntersectionObserver +let observerCallback: IntersectionObserverCallback; +const observeMock = vi.fn(); +const unobserveMock = vi.fn(); +const disconnectMock = vi.fn(); + +class MockIntersectionObserver implements IntersectionObserver { + readonly root: Element | null = null; + readonly rootMargin: string = '0px'; + readonly thresholds: readonly number[] = [0]; + constructor(callback: IntersectionObserverCallback) { + observerCallback = callback; + } + observe = observeMock; + unobserve = unobserveMock; + disconnect = disconnectMock; + takeRecords(): IntersectionObserverEntry[] { return []; } +} + +function createFakeImg(src: string): HTMLImageElement { + const img = document.createElement('img'); + img.src = src; + Object.defineProperty(img, 'naturalWidth', { value: 100 }); + Object.defineProperty(img, 'naturalHeight', { value: 100 }); + return img; +} + +function createWrapper(): HTMLDivElement { + return document.createElement('div'); +} + +function fireIntersection(entries: Array<{ target: Element; isIntersecting: boolean }>): void { + const fakeEntries = entries.map((e) => ({ + target: e.target, + isIntersecting: e.isIntersecting, + boundingClientRect: {} as DOMRectReadOnly, + intersectionRatio: e.isIntersecting ? 1 : 0, + intersectionRect: {} as DOMRectReadOnly, + rootBounds: null, + time: Date.now(), + })); + observerCallback(fakeEntries, {} as IntersectionObserver); +} + +beforeEach(() => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); + vi.useFakeTimers(); + observeMock.mockClear(); + unobserveMock.mockClear(); + disconnectMock.mockClear(); +}); + +afterEach(() => { + destroyObserver(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('media-visibility', () => { + it('observeMedia registers image with IntersectionObserver', () => { + const img = createFakeImg('https://example.com/cat.gif'); + const wrap = createWrapper(); + observeMedia(img, 'https://example.com/cat.gif', wrap); + expect(observeMock).toHaveBeenCalledWith(img); + }); + + it('adds play/pause button to wrapper', () => { + const img = createFakeImg('https://example.com/cat.gif'); + const wrap = createWrapper(); + observeMedia(img, 'https://example.com/cat.gif', wrap); + + const btn = wrap.querySelector('.gif-play-btn'); + expect(btn).not.toBeNull(); + }); + + it('unobserveMedia stops observing and restores original src', () => { + const img = createFakeImg('https://example.com/cat.gif'); + const wrap = createWrapper(); + observeMedia(img, 'https://example.com/cat.gif', wrap); + + img.src = 'data:image/png;base64,frozen'; + + unobserveMedia(img); + expect(unobserveMock).toHaveBeenCalledWith(img); + expect(img.src).toBe('https://example.com/cat.gif'); + }); + + it('does not double-observe same image', () => { + const img = createFakeImg('https://example.com/cat.gif'); + const wrap = createWrapper(); + observeMedia(img, 'https://example.com/cat.gif', wrap); + observeMedia(img, 'https://example.com/cat.gif', wrap); + expect(observeMock).toHaveBeenCalledTimes(1); + }); + + it('freezes GIF when it leaves viewport', () => { + const img = createFakeImg('https://example.com/cat.gif'); + const wrap = createWrapper(); + + const mockCanvas = document.createElement('canvas'); + const mockCtx = { drawImage: vi.fn() }; + const origCreateElement = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + if (tag === 'canvas') return mockCanvas; + return origCreateElement(tag); + }); + vi.spyOn(mockCanvas, 'getContext').mockReturnValue(mockCtx as any); + vi.spyOn(mockCanvas, 'toDataURL').mockReturnValue('data:image/png;base64,frozen'); + + observeMedia(img, 'https://example.com/cat.gif', wrap); + fireIntersection([{ target: img, isIntersecting: false }]); + + expect(img.src).toBe('data:image/png;base64,frozen'); + vi.restoreAllMocks(); + }); + + it('auto-pauses after 10 seconds', () => { + const img = createFakeImg('https://example.com/cat.gif'); + const wrap = createWrapper(); + + const mockCanvas = document.createElement('canvas'); + const mockCtx = { drawImage: vi.fn() }; + const origCreateElement = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + if (tag === 'canvas') return mockCanvas; + return origCreateElement(tag); + }); + vi.spyOn(mockCanvas, 'getContext').mockReturnValue(mockCtx as any); + vi.spyOn(mockCanvas, 'toDataURL').mockReturnValue('data:image/png;base64,frozen'); + + observeMedia(img, 'https://example.com/cat.gif', wrap); + + // Should still be playing + expect(img.src).toBe('https://example.com/cat.gif'); + + // Advance 10 seconds + vi.advanceTimersByTime(10_000); + + // Should be frozen now + expect(img.src).toBe('data:image/png;base64,frozen'); + + // Button should show play icon + const btn = wrap.querySelector('.gif-play-btn'); + expect(btn?.textContent).toBe('\u25B6'); + + vi.restoreAllMocks(); + }); + + it('play button click unfreezes and starts new 10s timer', () => { + const img = createFakeImg('https://example.com/cat.gif'); + const wrap = createWrapper(); + + const mockCanvas = document.createElement('canvas'); + const mockCtx = { drawImage: vi.fn() }; + const origCreateElement = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + if (tag === 'canvas') return mockCanvas; + return origCreateElement(tag); + }); + vi.spyOn(mockCanvas, 'getContext').mockReturnValue(mockCtx as any); + vi.spyOn(mockCanvas, 'toDataURL').mockReturnValue('data:image/png;base64,frozen'); + + observeMedia(img, 'https://example.com/cat.gif', wrap); + + // Auto-pause + vi.advanceTimersByTime(10_000); + expect(img.src).toBe('data:image/png;base64,frozen'); + + // Click play + const btn = wrap.querySelector('.gif-play-btn') as HTMLButtonElement; + btn.click(); + + expect(img.src).toBe('https://example.com/cat.gif'); + + // After another 10s, should freeze again + vi.advanceTimersByTime(10_000); + expect(img.src).toBe('data:image/png;base64,frozen'); + + vi.restoreAllMocks(); + }); + + it('pause button click freezes immediately', () => { + const img = createFakeImg('https://example.com/cat.gif'); + const wrap = createWrapper(); + + const mockCanvas = document.createElement('canvas'); + const mockCtx = { drawImage: vi.fn() }; + const origCreateElement = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + if (tag === 'canvas') return mockCanvas; + return origCreateElement(tag); + }); + vi.spyOn(mockCanvas, 'getContext').mockReturnValue(mockCtx as any); + vi.spyOn(mockCanvas, 'toDataURL').mockReturnValue('data:image/png;base64,frozen'); + + observeMedia(img, 'https://example.com/cat.gif', wrap); + + // Should be playing + expect(img.src).toBe('https://example.com/cat.gif'); + + // Click pause (button is in pause mode while playing) + const btn = wrap.querySelector('.gif-play-btn') as HTMLButtonElement; + btn.click(); + + expect(img.src).toBe('data:image/png;base64,frozen'); + + vi.restoreAllMocks(); + }); + + it('pauseAllMedia freezes all tracked GIFs', () => { + const img1 = createFakeImg('https://example.com/a.gif'); + const img2 = createFakeImg('https://example.com/b.gif'); + const wrap1 = createWrapper(); + const wrap2 = createWrapper(); + + observeMedia(img1, 'https://example.com/a.gif', wrap1); + observeMedia(img2, 'https://example.com/b.gif', wrap2); + + const mockCanvas = document.createElement('canvas'); + const mockCtx = { drawImage: vi.fn() }; + const origCreateElement = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + if (tag === 'canvas') return mockCanvas; + return origCreateElement(tag); + }); + vi.spyOn(mockCanvas, 'getContext').mockReturnValue(mockCtx as any); + vi.spyOn(mockCanvas, 'toDataURL').mockReturnValue('data:image/png;base64,paused'); + + pauseAllMedia(); + + expect(img1.src).toBe('data:image/png;base64,paused'); + expect(img2.src).toBe('data:image/png;base64,paused'); + + vi.restoreAllMocks(); + }); + + it('resumeVisibleMedia only unfreezes intersecting GIFs', () => { + const img1 = createFakeImg('https://example.com/a.gif'); + const img2 = createFakeImg('https://example.com/b.gif'); + const wrap1 = createWrapper(); + const wrap2 = createWrapper(); + + observeMedia(img1, 'https://example.com/a.gif', wrap1); + observeMedia(img2, 'https://example.com/b.gif', wrap2); + + fireIntersection([ + { target: img1, isIntersecting: true }, + { target: img2, isIntersecting: false }, + ]); + + img1.src = 'data:image/png;base64,frozen'; + img2.src = 'data:image/png;base64,frozen'; + + resumeVisibleMedia(); + + expect(img1.src).toBe('https://example.com/a.gif'); + expect(img2.src).toBe('data:image/png;base64,frozen'); + }); + + it('wrapper gets gif-paused class when frozen', () => { + const img = createFakeImg('https://example.com/cat.gif'); + const wrap = createWrapper(); + + const mockCanvas = document.createElement('canvas'); + const mockCtx = { drawImage: vi.fn() }; + const origCreateElement = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + if (tag === 'canvas') return mockCanvas; + return origCreateElement(tag); + }); + vi.spyOn(mockCanvas, 'getContext').mockReturnValue(mockCtx as any); + vi.spyOn(mockCanvas, 'toDataURL').mockReturnValue('data:image/png;base64,frozen'); + + observeMedia(img, 'https://example.com/cat.gif', wrap); + expect(wrap.classList.contains('gif-paused')).toBe(false); + + vi.advanceTimersByTime(10_000); + expect(wrap.classList.contains('gif-paused')).toBe(true); + + vi.restoreAllMocks(); + }); + + it('destroyObserver cleans up', () => { + const img = createFakeImg('https://example.com/cat.gif'); + const wrap = createWrapper(); + observeMedia(img, 'https://example.com/cat.gif', wrap); + + destroyObserver(); + expect(disconnectMock).toHaveBeenCalled(); + }); +}); diff --git a/Client/tauri-client/tests/unit/reconcile.test.ts b/Client/tauri-client/tests/unit/reconcile.test.ts new file mode 100644 index 00000000..46b031f7 --- /dev/null +++ b/Client/tauri-client/tests/unit/reconcile.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, vi } from 'vitest'; +import { reconcileList } from '../../src/lib/reconcile'; + +interface Item { + id: string; + label: string; +} + +function makeContainer(): HTMLDivElement { + return document.createElement('div'); +} + +function makeItem(id: string, label: string): Item { + return { id, label }; +} + +function createEl(item: Item): HTMLDivElement { + const el = document.createElement('div'); + el.textContent = item.label; + el.setAttribute('data-reconcile-key', item.id); + return el; +} + +function updateEl(el: Element, item: Item): void { + el.textContent = item.label; +} + +function getKeys(container: Element): string[] { + return Array.from(container.children).map( + (c) => c.getAttribute('data-reconcile-key') ?? '', + ); +} + +describe('reconcileList', () => { + it('inserts new items into empty container', () => { + const container = makeContainer(); + const items = [makeItem('a', 'A'), makeItem('b', 'B')]; + + reconcileList({ + container, + items, + key: (i) => i.id, + create: createEl, + update: updateEl, + }); + + expect(container.children.length).toBe(2); + expect(getKeys(container)).toEqual(['a', 'b']); + expect(container.children[0]!.textContent).toBe('A'); + expect(container.children[1]!.textContent).toBe('B'); + }); + + it('removes deleted items', () => { + const container = makeContainer(); + const items = [makeItem('a', 'A'), makeItem('b', 'B'), makeItem('c', 'C')]; + + reconcileList({ + container, + items, + key: (i) => i.id, + create: createEl, + update: updateEl, + }); + expect(container.children.length).toBe(3); + + // Remove 'b' + reconcileList({ + container, + items: [makeItem('a', 'A'), makeItem('c', 'C')], + key: (i) => i.id, + create: createEl, + update: updateEl, + }); + + expect(container.children.length).toBe(2); + expect(getKeys(container)).toEqual(['a', 'c']); + }); + + it('reorders moved items', () => { + const container = makeContainer(); + const items = [makeItem('a', 'A'), makeItem('b', 'B'), makeItem('c', 'C')]; + + reconcileList({ + container, + items, + key: (i) => i.id, + create: createEl, + update: updateEl, + }); + + // Reverse order + reconcileList({ + container, + items: [makeItem('c', 'C'), makeItem('b', 'B'), makeItem('a', 'A')], + key: (i) => i.id, + create: createEl, + update: updateEl, + }); + + expect(getKeys(container)).toEqual(['c', 'b', 'a']); + }); + + it('updates changed items in-place (preserves DOM reference)', () => { + const container = makeContainer(); + const items = [makeItem('a', 'A'), makeItem('b', 'B')]; + + reconcileList({ + container, + items, + key: (i) => i.id, + create: createEl, + update: updateEl, + }); + + const origA = container.children[0]!; + const origB = container.children[1]!; + + // Update label for 'a' + reconcileList({ + container, + items: [makeItem('a', 'A-updated'), makeItem('b', 'B')], + key: (i) => i.id, + create: createEl, + update: updateEl, + }); + + // SAME DOM elements — not rebuilt + expect(container.children[0]).toBe(origA); + expect(container.children[1]).toBe(origB); + expect(origA.textContent).toBe('A-updated'); + }); + + it('handles empty → items', () => { + const container = makeContainer(); + + reconcileList({ + container, + items: [], + key: (i: Item) => i.id, + create: createEl, + update: updateEl, + }); + expect(container.children.length).toBe(0); + + reconcileList({ + container, + items: [makeItem('x', 'X')], + key: (i) => i.id, + create: createEl, + update: updateEl, + }); + expect(container.children.length).toBe(1); + expect(getKeys(container)).toEqual(['x']); + }); + + it('handles items → empty', () => { + const container = makeContainer(); + + reconcileList({ + container, + items: [makeItem('a', 'A'), makeItem('b', 'B')], + key: (i) => i.id, + create: createEl, + update: updateEl, + }); + expect(container.children.length).toBe(2); + + reconcileList({ + container, + items: [], + key: (i: Item) => i.id, + create: createEl, + update: updateEl, + }); + expect(container.children.length).toBe(0); + }); + + it('no-op when identical items', () => { + const container = makeContainer(); + const items = [makeItem('a', 'A'), makeItem('b', 'B')]; + const createSpy = vi.fn(createEl); + + reconcileList({ + container, + items, + key: (i) => i.id, + create: createSpy, + update: updateEl, + }); + + const origA = container.children[0]!; + const origB = container.children[1]!; + createSpy.mockClear(); + + // Same items again + reconcileList({ + container, + items: [makeItem('a', 'A'), makeItem('b', 'B')], + key: (i) => i.id, + create: createSpy, + update: updateEl, + }); + + // No new elements created + expect(createSpy).not.toHaveBeenCalled(); + // Same DOM references + expect(container.children[0]).toBe(origA); + expect(container.children[1]).toBe(origB); + }); + + it('handles simultaneous add, remove, and reorder', () => { + const container = makeContainer(); + + reconcileList({ + container, + items: [makeItem('a', 'A'), makeItem('b', 'B'), makeItem('c', 'C')], + key: (i) => i.id, + create: createEl, + update: updateEl, + }); + + const origC = container.children[2]!; + + // Remove 'a', add 'd', reorder: c, d, b + reconcileList({ + container, + items: [makeItem('c', 'C'), makeItem('d', 'D'), makeItem('b', 'B')], + key: (i) => i.id, + create: createEl, + update: updateEl, + }); + + expect(getKeys(container)).toEqual(['c', 'd', 'b']); + expect(container.children.length).toBe(3); + // 'c' element preserved + expect(container.children[0]).toBe(origC); + }); +}); diff --git a/Client/tauri-client/tests/unit/settings-overlay.test.ts b/Client/tauri-client/tests/unit/settings-overlay.test.ts index 3f3ea4c4..3c44601d 100644 --- a/Client/tauri-client/tests/unit/settings-overlay.test.ts +++ b/Client/tauri-client/tests/unit/settings-overlay.test.ts @@ -21,6 +21,7 @@ vi.mock("@stores/ui.store", () => ({ uiStore: { getState: () => ({ settingsOpen: false }), subscribe: () => () => {}, + subscribeSelector: vi.fn((_sel: unknown, _listener: unknown) => () => {}), }, setTheme: (...args: unknown[]) => mockSetTheme(...args), })); diff --git a/Client/tauri-client/tests/unit/store.test.ts b/Client/tauri-client/tests/unit/store.test.ts index fb29d5a3..ad17ee7b 100644 --- a/Client/tauri-client/tests/unit/store.test.ts +++ b/Client/tauri-client/tests/unit/store.test.ts @@ -132,3 +132,127 @@ describe('createStore', () => { expect(store.getState().count).toBe(100); }); }); + +// --------------------------------------------------------------------------- +// subscribeSelector +// --------------------------------------------------------------------------- + +describe('subscribeSelector', () => { + it('fires when selected slice changes', () => { + const store = freshStore(); + const listener = vi.fn(); + store.subscribeSelector((s) => s.count, listener); + + store.setState((prev) => ({ ...prev, count: 5 })); + store.flush(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(5); + }); + + it('does NOT fire when selected slice is unchanged', () => { + const store = freshStore(); + const listener = vi.fn(); + store.subscribeSelector((s) => s.count, listener); + + // Change name but not count + store.setState((prev) => ({ ...prev, name: 'updated' })); + store.flush(); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('fires only for the changed slice among multiple selectors', () => { + const store = freshStore(); + const countListener = vi.fn(); + const nameListener = vi.fn(); + store.subscribeSelector((s) => s.count, countListener); + store.subscribeSelector((s) => s.name, nameListener); + + store.setState((prev) => ({ ...prev, count: 10 })); + store.flush(); + + expect(countListener).toHaveBeenCalledTimes(1); + expect(nameListener).not.toHaveBeenCalled(); + }); + + it('returns unsubscribe function', () => { + const store = freshStore(); + const listener = vi.fn(); + const unsub = store.subscribeSelector((s) => s.count, listener); + + store.setState((prev) => ({ ...prev, count: 1 })); + store.flush(); + expect(listener).toHaveBeenCalledTimes(1); + + unsub(); + + store.setState((prev) => ({ ...prev, count: 2 })); + store.flush(); + expect(listener).toHaveBeenCalledTimes(1); // no new call + }); + + it('works with custom equality comparator', () => { + const store = freshStore(); + const listener = vi.fn(); + // Custom comparator: only fire when count changes by more than 5 + store.subscribeSelector( + (s) => s.count, + listener, + (a, b) => Math.abs(a - b) <= 5, + ); + + store.setState((prev) => ({ ...prev, count: 3 })); // diff = 3, within threshold + store.flush(); + expect(listener).not.toHaveBeenCalled(); + + store.setState((prev) => ({ ...prev, count: 10 })); // diff = 10, exceeds threshold + store.flush(); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(10); + }); + + it('works with microtask batching', () => { + const store = freshStore(); + const listener = vi.fn(); + store.subscribeSelector((s) => s.count, listener); + + // Multiple rapid updates — only final state matters + store.setState((prev) => ({ ...prev, count: 1 })); + store.setState((prev) => ({ ...prev, count: 2 })); + store.setState((prev) => ({ ...prev, count: 3 })); + store.flush(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(3); + }); + + it('multiple selectors on the same store work independently', () => { + const store = freshStore(); + const results: string[] = []; + store.subscribeSelector((s) => s.count, (c) => results.push(`count:${c}`)); + store.subscribeSelector((s) => s.name, (n) => results.push(`name:${n}`)); + + store.setState((prev) => ({ ...prev, count: 1, name: 'updated' })); + store.flush(); + + expect(results).toEqual(['count:1', 'name:updated']); + }); + + it('warns about unstable selectors (creates new ref every time)', () => { + const store = freshStore(); + const listener = vi.fn(); + // BAD selector: creates new object every time + store.subscribeSelector( + (s) => ({ count: s.count }), + listener, + ); + + // Even changing just name will fire because selector returns new object + store.setState((prev) => ({ ...prev, name: 'changed' })); + store.flush(); + + // This DOES fire because { count: 0 } !== { count: 0 } (different refs) + expect(listener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/Client/tauri-client/tests/unit/typing-indicator.test.ts b/Client/tauri-client/tests/unit/typing-indicator.test.ts index 76ad87a7..a211d7a8 100644 --- a/Client/tauri-client/tests/unit/typing-indicator.test.ts +++ b/Client/tauri-client/tests/unit/typing-indicator.test.ts @@ -11,6 +11,12 @@ vi.mock("@stores/members.store", () => ({ storeCallback = null; }; }), + subscribeSelector: vi.fn((_sel: unknown, listener: () => void) => { + storeCallback = listener; + return () => { + storeCallback = null; + }; + }), }, getTypingUsers: vi.fn(() => typingUsers), })); diff --git a/Client/tauri-client/tests/unit/video-grid.test.ts b/Client/tauri-client/tests/unit/video-grid.test.ts index f8e0e169..3198c934 100644 --- a/Client/tauri-client/tests/unit/video-grid.test.ts +++ b/Client/tauri-client/tests/unit/video-grid.test.ts @@ -6,7 +6,7 @@ import { /** Minimal MediaStream stub for testing. */ function fakeStream(): MediaStream { - return {} as unknown as MediaStream; + return { getTracks: () => [] } as unknown as MediaStream; } describe("VideoGrid", () => { diff --git a/Server/api/livekit_proxy.go b/Server/api/livekit_proxy.go index 950fb3f0..62c2b027 100644 --- a/Server/api/livekit_proxy.go +++ b/Server/api/livekit_proxy.go @@ -1,40 +1,139 @@ package api import ( + "context" + "io" + "log/slog" "net/http" "net/http/httputil" "net/url" + "strings" + + "nhooyr.io/websocket" ) -// NewLiveKitProxy creates a reverse proxy handler that forwards requests -// to the LiveKit server. This allows the client to reach LiveKit through -// OwnCord's existing HTTPS server, avoiding mixed-content blocks in -// WebView2 (secure page → insecure WebSocket). +// NewLiveKitProxy creates a reverse proxy handler that forwards both HTTP +// and WebSocket requests to the LiveKit server. This allows the client to +// reach LiveKit through OwnCord's existing HTTPS server, avoiding +// mixed-content blocks in WebView2 (secure page → insecure WebSocket). // // The client connects to wss://server:8443/livekit/ which is proxied to // ws://localhost:7880/ on the LiveKit server. func NewLiveKitProxy(livekitURL string) http.Handler { target, err := url.Parse(livekitURL) if err != nil { - // Fall back to default if URL is invalid target, _ = url.Parse("http://localhost:7880") } - // Convert ws:// to http:// for the proxy target - switch target.Scheme { + // Normalise scheme for HTTP proxy target. + httpTarget := *target + switch httpTarget.Scheme { case "ws": - target.Scheme = "http" + httpTarget.Scheme = "http" case "wss": - target.Scheme = "https" + httpTarget.Scheme = "https" } - proxy := &httputil.ReverseProxy{ + // Normalise scheme for WebSocket proxy target. + wsTarget := *target + switch wsTarget.Scheme { + case "http": + wsTarget.Scheme = "ws" + case "https": + wsTarget.Scheme = "wss" + } + + httpProxy := &httputil.ReverseProxy{ Director: func(req *http.Request) { - req.URL.Scheme = target.Scheme - req.URL.Host = target.Host - req.Host = target.Host + req.URL.Scheme = httpTarget.Scheme + req.URL.Host = httpTarget.Host + req.Host = httpTarget.Host }, } - return proxy + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Detect WebSocket upgrade requests. + if isWebSocketUpgrade(r) { + proxyWebSocket(w, r, &wsTarget) + return + } + httpProxy.ServeHTTP(w, r) + }) +} + +func isWebSocketUpgrade(r *http.Request) bool { + for _, v := range r.Header.Values("Connection") { + if strings.EqualFold(strings.TrimSpace(v), "upgrade") { + return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") + } + } + return false +} + +// proxyWebSocket opens a backend WS connection and shovels data in both +// directions until either side closes. +func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL) { + // Build backend URL preserving the request path and query. + backendURL := *target + backendURL.Path = r.URL.Path + backendURL.RawQuery = r.URL.RawQuery + + // Connect to LiveKit backend. + backConn, _, err := websocket.Dial(r.Context(), backendURL.String(), &websocket.DialOptions{ + Subprotocols: r.Header.Values("Sec-WebSocket-Protocol"), + }) + if err != nil { + slog.Warn("livekit proxy: backend dial failed", "url", backendURL.String(), "err", err) + http.Error(w, "backend unavailable", http.StatusBadGateway) + return + } + defer backConn.Close(websocket.StatusNormalClosure, "") + + // Accept the frontend WebSocket. + frontConn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + Subprotocols: []string{backConn.Subprotocol()}, + OriginPatterns: []string{"*"}, + }) + if err != nil { + slog.Warn("livekit proxy: frontend accept failed", "err", err) + return + } + defer frontConn.Close(websocket.StatusNormalClosure, "") + + ctx := r.Context() + errc := make(chan error, 2) + + // Frontend → Backend + go func() { + errc <- copyWS(ctx, backConn, frontConn) + }() + + // Backend → Frontend + go func() { + errc <- copyWS(ctx, frontConn, backConn) + }() + + // Wait for either direction to finish. + <-errc +} + +// copyWS reads messages from src and writes them to dst until an error or +// context cancellation. +func copyWS(ctx context.Context, dst, src *websocket.Conn) error { + for { + msgType, reader, err := src.Reader(ctx) + if err != nil { + return err + } + writer, err := dst.Writer(ctx, msgType) + if err != nil { + return err + } + if _, err = io.Copy(writer, reader); err != nil { + return err + } + if err = writer.Close(); err != nil { + return err + } + } } diff --git a/Server/ws/messages.go b/Server/ws/messages.go index 48dd0206..1baf1ed0 100644 --- a/Server/ws/messages.go +++ b/Server/ws/messages.go @@ -232,13 +232,16 @@ func buildVoiceConfig(channelID int64, quality string, bitrate int, maxUsers int } // buildVoiceToken constructs a voice_token message with a LiveKit token and URL. -func buildVoiceToken(channelID int64, token string, livekitURL string) []byte { +// url is the proxy path ("/livekit") for remote clients; direct_url is the raw +// LiveKit URL (e.g. "ws://localhost:7880") for localhost clients. +func buildVoiceToken(channelID int64, token string, proxyPath string, directURL string) []byte { return buildJSON(map[string]any{ "type": "voice_token", "payload": map[string]any{ "channel_id": channelID, "token": token, - "url": livekitURL, + "url": proxyPath, + "direct_url": directURL, }, }) } diff --git a/Server/ws/messages_test.go b/Server/ws/messages_test.go index d3f019f6..987a1882 100644 --- a/Server/ws/messages_test.go +++ b/Server/ws/messages_test.go @@ -551,7 +551,7 @@ func TestBuildTypingMsg_ValidJSON(t *testing.T) { // ─── buildVoiceToken ────────────────────────────────────────────────────────── func TestBuildVoiceToken_Type(t *testing.T) { - msg := buildVoiceToken(99, "jwt-token", "ws://localhost:7880") + msg := buildVoiceToken(99, "jwt-token", "/livekit", "ws://localhost:7880") var env struct { Type string `json:"type"` } @@ -564,12 +564,13 @@ func TestBuildVoiceToken_Type(t *testing.T) { } func TestBuildVoiceToken_Payload(t *testing.T) { - msg := buildVoiceToken(99, "jwt-token", "ws://localhost:7880") + msg := buildVoiceToken(99, "jwt-token", "/livekit", "ws://localhost:7880") var env struct { Payload struct { ChannelID int64 `json:"channel_id"` Token string `json:"token"` URL string `json:"url"` + DirectURL string `json:"direct_url"` } `json:"payload"` } if err := json.Unmarshal(msg, &env); err != nil { @@ -581,13 +582,16 @@ func TestBuildVoiceToken_Payload(t *testing.T) { if env.Payload.Token != "jwt-token" { t.Errorf("payload.token = %q, want jwt-token", env.Payload.Token) } - if env.Payload.URL != "ws://localhost:7880" { - t.Errorf("payload.url = %q, want ws://localhost:7880", env.Payload.URL) + if env.Payload.URL != "/livekit" { + t.Errorf("payload.url = %q, want /livekit", env.Payload.URL) + } + if env.Payload.DirectURL != "ws://localhost:7880" { + t.Errorf("payload.direct_url = %q, want ws://localhost:7880", env.Payload.DirectURL) } } func TestBuildVoiceToken_ValidJSON(t *testing.T) { - if !json.Valid(buildVoiceToken(1, "t", "ws://a")) { + if !json.Valid(buildVoiceToken(1, "t", "/livekit", "ws://a")) { t.Error("buildVoiceToken output is not valid JSON") } } diff --git a/Server/ws/voice_handlers.go b/Server/ws/voice_handlers.go index 7067fd01..cabb45a6 100644 --- a/Server/ws/voice_handlers.go +++ b/Server/ws/voice_handlers.go @@ -108,7 +108,10 @@ func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) { slog.Error("ws handleVoiceJoin GenerateToken", "err", tokenErr, "user_id", c.userID) // Non-fatal: voice join still succeeds at the DB/state level. } else { - c.sendMsg(buildVoiceToken(channelID, token, h.livekit.URL())) + // Send both proxy path and direct URL. The client uses direct_url + // when on localhost (avoids self-signed TLS issues with WebView + // fetch) and falls back to the /livekit proxy for remote clients. + c.sendMsg(buildVoiceToken(channelID, token, "/livekit", h.livekit.URL())) } }