refactor: UI architecture improvements + GIF auto-pause

Architecture:
- Add subscribeSelector to store.ts for selective state subscriptions
- Create reconcileList utility for DOM list patching without rebuild
- Create shared createContextMenu utility (dedup 3 files)
- Convert all 20 subscribe() calls to subscribeSelector across 11 files
- Split renderers.ts (1131L) into 7 focused files by concern
- Split ConnectPage.ts (838L) into ServerPanel + LoginForm + shell
- Fix ineffective (s) => s selector in ChannelSidebar

GIF visibility:
- Add media-visibility.ts with IntersectionObserver + canvas snapshots
- GIFs auto-pause after 10s, play/pause button overlay on hover
- Freeze GIFs on scroll-away, window blur, and minimize
- Wire into media.ts, attachments.ts, embeds.ts renderers

Tests: 46 new tests (1073 total), all passing
Net: -1166 lines across client codebase
This commit is contained in:
jevb
2026-03-20 15:21:56 +01:00
parent 0b2106af88
commit 2dfb73083f
43 changed files with 3751 additions and 1821 deletions
@@ -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<HTMLElement>(`.voice-user-item[data-voice-uid="${uid}"]`);
if (row !== null) {
row.classList.toggle("speaking", u.speaking);
}
}
}
});
unsubscribers.push(unsubVoice);
}
@@ -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);
}
@@ -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 {
@@ -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());
@@ -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) {
@@ -61,9 +61,10 @@ export function createTypingIndicator(
updateFromState();
unsubscribe = membersStore.subscribe(() => {
updateFromState();
});
unsubscribe = membersStore.subscribeSelector(
(s) => s.typingUsers,
() => { updateFromState(); },
);
container.appendChild(root);
}
@@ -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);
}
@@ -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);
@@ -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();
@@ -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);
}
@@ -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<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. */
export 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. */
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<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;
}
// -- 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<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);
}
}
@@ -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;
}
@@ -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<string, OgMeta>();
/** URLs currently being fetched (prevents duplicate requests). */
const ogInFlight = new Set<string>();
// -- 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(
`<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"),
};
}
// -- 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 = "";
}
}
}
@@ -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)";
}
}
@@ -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;
}
@@ -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;
}
File diff suppressed because it is too large Load Diff
@@ -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();
});
}
+1 -1
View File
@@ -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);
}),
);
-4
View File
@@ -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);
}
+28 -6
View File
@@ -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) {
@@ -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();
}
+77
View File
@@ -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();
}
}
}
+44 -1
View File
@@ -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 };
}
+1
View File
@@ -276,6 +276,7 @@ export interface VoiceTokenPayload {
readonly channel_id: number;
readonly token: string;
readonly url: string;
readonly direct_url?: string;
}
export interface MemberJoinPayload {
+24 -21
View File
@@ -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", () => {
+57 -718
View File
@@ -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);
},
};
}
+17 -11
View File
@@ -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();
@@ -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
},
};
}
@@ -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
},
};
}
@@ -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
+22
View File
@@ -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;
@@ -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');
});
});
@@ -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();
});
});
@@ -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);
});
});
@@ -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),
}));
@@ -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);
});
});
@@ -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),
}));
@@ -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", () => {
+113 -14
View File
@@ -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
}
}
}
+5 -2
View File
@@ -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,
},
})
}
+9 -5
View File
@@ -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")
}
}
+4 -1
View File
@@ -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()))
}
}