mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -276,6 +276,7 @@ export interface VoiceTokenPayload {
|
||||
readonly channel_id: number;
|
||||
readonly token: string;
|
||||
readonly url: string;
|
||||
readonly direct_url?: string;
|
||||
}
|
||||
|
||||
export interface MemberJoinPayload {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user