chore: client source updates from prior sessions

Component, lib, store, and page updates from client component
extractions and security hardening work across prior sessions.
This commit is contained in:
jevb
2026-04-01 11:40:18 +02:00
parent b718447e64
commit 5d8cb32c90
85 changed files with 3310 additions and 1900 deletions
@@ -60,53 +60,73 @@ function withConfirmation(
let confirming = false;
const originalLabel = item.textContent ?? "";
item.addEventListener("click", (e) => {
e.stopPropagation();
if (confirming) {
confirming = false;
setText(item, originalLabel);
onConfirm();
} else {
confirming = true;
setText(item, confirmLabel);
}
}, { signal });
item.addEventListener(
"click",
(e) => {
e.stopPropagation();
if (confirming) {
confirming = false;
setText(item, originalLabel);
onConfirm();
} else {
confirming = true;
setText(item, confirmLabel);
}
},
{ signal },
);
}
// ---------------------------------------------------------------------------
// Member Context Menu
// ---------------------------------------------------------------------------
export function createMemberContextMenu(
options: MemberContextMenuOptions,
): ContextMenuResult {
export function createMemberContextMenu(options: MemberContextMenuOptions): ContextMenuResult {
const ac = new AbortController();
const menu = createElement("div", { class: "context-menu" });
// Role submenu trigger
const roleItem = createElement("div", {
class: "context-menu__item",
}, "Change Role");
const roleItem = createElement(
"div",
{
class: "context-menu__item",
},
"Change Role",
);
const roleSub = createElement("div", { class: "context-menu__submenu" });
for (const role of options.availableRoles) {
const cls = role === options.currentRole
? "context-menu__item context-menu__item--active"
: "context-menu__item";
const roleOption = createMenuItem(role, cls, () => {
if (role !== options.currentRole) {
void options.onChangeRole(role);
}
}, ac.signal);
const cls =
role === options.currentRole
? "context-menu__item context-menu__item--active"
: "context-menu__item";
const roleOption = createMenuItem(
role,
cls,
() => {
if (role !== options.currentRole) {
void options.onChangeRole(role);
}
},
ac.signal,
);
roleSub.appendChild(roleOption);
}
roleItem.addEventListener("mouseenter", () => {
roleSub.style.display = "";
}, { signal: ac.signal });
roleItem.addEventListener("mouseleave", () => {
roleSub.style.display = "none";
}, { signal: ac.signal });
roleItem.addEventListener(
"mouseenter",
() => {
roleSub.style.display = "";
},
{ signal: ac.signal },
);
roleItem.addEventListener(
"mouseleave",
() => {
roleSub.style.display = "none";
},
{ signal: ac.signal },
);
roleSub.style.display = "none";
appendChildren(roleItem, roleSub);
@@ -115,21 +135,39 @@ export function createMemberContextMenu(
menu.appendChild(createSeparator());
// Kick with confirmation
const kickItem = createElement("div", {
class: "context-menu__item context-menu__item--danger",
}, "Kick");
withConfirmation(kickItem, "Are you sure?", () => {
void options.onKick();
}, ac.signal);
const kickItem = createElement(
"div",
{
class: "context-menu__item context-menu__item--danger",
},
"Kick",
);
withConfirmation(
kickItem,
"Are you sure?",
() => {
void options.onKick();
},
ac.signal,
);
menu.appendChild(kickItem);
// Ban with confirmation
const banItem = createElement("div", {
class: "context-menu__item context-menu__item--danger",
}, "Ban");
withConfirmation(banItem, "Are you sure?", () => {
void options.onBan();
}, ac.signal);
const banItem = createElement(
"div",
{
class: "context-menu__item context-menu__item--danger",
},
"Ban",
);
withConfirmation(
banItem,
"Are you sure?",
() => {
void options.onBan();
},
ac.signal,
);
menu.appendChild(banItem);
function destroy(): void {
@@ -144,9 +182,7 @@ export function createMemberContextMenu(
// Channel Context Menu
// ---------------------------------------------------------------------------
export function createChannelContextMenu(
options: ChannelContextMenuOptions,
): ContextMenuResult {
export function createChannelContextMenu(options: ChannelContextMenuOptions): ContextMenuResult {
const ac = new AbortController();
const menu = createElement("div", { class: "context-menu" });
@@ -171,12 +207,21 @@ export function createChannelContextMenu(
menu.appendChild(createSeparator());
// Delete Channel with confirmation
const deleteItem = createElement("div", {
class: "context-menu__item context-menu__item--danger",
}, "Delete Channel");
withConfirmation(deleteItem, "Are you sure?", () => {
void options.onDelete();
}, ac.signal);
const deleteItem = createElement(
"div",
{
class: "context-menu__item context-menu__item--danger",
},
"Delete Channel",
);
withConfirmation(
deleteItem,
"Are you sure?",
() => {
void options.onDelete();
},
ac.signal,
);
menu.appendChild(deleteItem);
function destroy(): void {
@@ -18,9 +18,7 @@ export interface CertMismatchModalOptions {
readonly onReject: () => void;
}
export function createCertMismatchModal(
options: CertMismatchModalOptions,
): MountableComponent {
export function createCertMismatchModal(options: CertMismatchModalOptions): MountableComponent {
const { host, storedFingerprint, newFingerprint, onAccept, onReject } = options;
let overlay: HTMLDivElement | null = null;
const ac = new AbortController();
@@ -52,8 +50,8 @@ export function createCertMismatchModal(
setText(
desc,
"The server's TLS certificate fingerprint has changed. " +
"This could mean the server regenerated its certificate, " +
"or it could indicate a security issue.",
"This could mean the server regenerated its certificate, " +
"or it could indicate a security issue.",
);
const details = createElement("div", { class: "cert-details" });
@@ -110,11 +108,7 @@ export function createCertMismatchModal(
return { mount, destroy };
}
function buildRow(
label: string,
value: string,
isFingerprint: boolean,
): HTMLDivElement {
function buildRow(label: string, value: string, isFingerprint: boolean): HTMLDivElement {
const row = createElement("div", { class: "cert-row" });
const labelEl = createElement("span", { class: "cert-label" });
setText(labelEl, label);
@@ -27,8 +27,14 @@ const READY_DELAY_MS = 800;
function serverIconColor(name: string): string {
const palette = [
"#5865f2", "#57f287", "#fee75c", "#eb459e",
"#ed4245", "#f0b232", "#2ecc71", "#e74c3c",
"#5865f2",
"#57f287",
"#fee75c",
"#eb459e",
"#ed4245",
"#f0b232",
"#2ecc71",
"#e74c3c",
] as const;
let hash = 0;
for (let i = 0; i < name.length; i++) {
@@ -37,14 +43,15 @@ function serverIconColor(name: string): string {
return palette[Math.abs(hash) % palette.length] ?? palette[0];
}
export function createConnectedOverlay(
options: ConnectedOverlayOptions,
): ConnectedOverlayControl {
export function createConnectedOverlay(options: ConnectedOverlayOptions): ConnectedOverlayControl {
const { serverName, username, motd, onReady } = options;
const ac = new AbortController();
// Root overlay (hidden by default, .visible to show)
const overlay = createElement("div", { class: "connected-overlay", "data-testid": "connected-overlay" });
const overlay = createElement("div", {
class: "connected-overlay",
"data-testid": "connected-overlay",
});
// Server icon with check badge
const iconWrap = createElement("div", { class: "connected-icon-wrap" });
@@ -70,13 +77,21 @@ export function createConnectedOverlay(
appendChildren(iconWrap, srvIcon, checkBadge);
// Text elements
const connectedText = createElement("div", {
class: "connected-text",
}, "Connected!");
const connectedText = createElement(
"div",
{
class: "connected-text",
},
"Connected!",
);
const userText = createElement("div", {
class: "connected-user",
}, `Logged in as ${username}`);
const userText = createElement(
"div",
{
class: "connected-user",
},
`Logged in as ${username}`,
);
const motdEl = createElement("div", { class: "connected-motd" });
if (motd) {
@@ -14,11 +14,7 @@ export interface CreateChannelModalOptions {
/** The category this channel will be created under. */
readonly category: string;
/** Called when the user submits the form. */
readonly onCreate: (data: {
name: string;
type: ChannelType;
category: string;
}) => Promise<void>;
readonly onCreate: (data: { name: string; type: ChannelType; category: string }) => Promise<void>;
/** Called when the modal is closed without creating. */
readonly onClose: () => void;
}
@@ -29,18 +25,14 @@ export function isVoiceCategory(category: string): boolean {
}
/** Returns the allowed channel types for a given category. */
export function allowedTypesForCategory(
category: string,
): readonly ChannelType[] {
export function allowedTypesForCategory(category: string): readonly ChannelType[] {
if (isVoiceCategory(category)) {
return ["voice"] as const;
}
return ["text", "announcement"] as const;
}
export function createCreateChannelModal(
options: CreateChannelModalOptions,
): MountableComponent {
export function createCreateChannelModal(options: CreateChannelModalOptions): MountableComponent {
const { category, onCreate, onClose } = options;
const ac = new AbortController();
let overlay: HTMLDivElement | null = null;
@@ -72,11 +64,7 @@ export function createCreateChannelModal(
// Category (read-only display)
const categoryGroup = createElement("div", { class: "form-group" });
const categoryLabel = createElement(
"label",
{ class: "form-label" },
"Category",
);
const categoryLabel = createElement("label", { class: "form-label" }, "Category");
const categoryDisplay = createElement("div", {
class: "form-input",
style: "opacity: 0.7; cursor: default;",
@@ -104,11 +92,7 @@ export function createCreateChannelModal(
});
for (const t of allowedTypes) {
const opt = createElement(
"option",
{ value: t },
t.charAt(0).toUpperCase() + t.slice(1),
);
const opt = createElement("option", { value: t }, t.charAt(0).toUpperCase() + t.slice(1));
typeSelect.appendChild(opt);
}
appendChildren(typeGroup, typeLabel, typeSelect);
@@ -166,10 +150,7 @@ export function createCreateChannelModal(
});
} catch (err) {
errorEl.style.display = "block";
setText(
errorEl,
err instanceof Error ? err.message : "Failed to create channel",
);
setText(errorEl, err instanceof Error ? err.message : "Failed to create channel");
createBtn.removeAttribute("disabled");
setText(createBtn, "Create Channel");
}
@@ -14,9 +14,7 @@ export interface DeleteChannelModalOptions {
readonly onClose: () => void;
}
export function createDeleteChannelModal(
options: DeleteChannelModalOptions,
): MountableComponent {
export function createDeleteChannelModal(options: DeleteChannelModalOptions): MountableComponent {
const { channelName, onConfirm, onClose } = options;
const ac = new AbortController();
let overlay: HTMLDivElement | null = null;
@@ -88,10 +86,7 @@ export function createDeleteChannelModal(
await onConfirm();
} catch (err) {
errorEl.style.display = "block";
setText(
errorEl,
err instanceof Error ? err.message : "Failed to delete channel",
);
setText(errorEl, err instanceof Error ? err.message : "Failed to delete channel");
deleteBtn.removeAttribute("disabled");
setText(deleteBtn, "Delete Channel");
}
+19 -16
View File
@@ -8,11 +8,7 @@
* dm-name, dm-close, dm-unread.
*/
import {
createElement,
setText,
appendChildren,
} from "@lib/dom";
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
@@ -111,16 +107,20 @@ function renderDmItem(
item.appendChild(unreadDot);
}
item.addEventListener("click", () => {
const parent = item.parentElement;
if (parent !== null) {
for (const sibling of parent.querySelectorAll(".dm-item.active")) {
sibling.classList.remove("active");
item.addEventListener(
"click",
() => {
const parent = item.parentElement;
if (parent !== null) {
for (const sibling of parent.querySelectorAll(".dm-item.active")) {
sibling.classList.remove("active");
}
}
}
item.classList.add("active");
onSelect(convo.userId);
}, { signal });
item.classList.add("active");
onSelect(convo.userId);
},
{ signal },
);
return item;
}
@@ -142,8 +142,11 @@ export function createDmSidebar(options: DmSidebarOptions): MountableComponent {
});
const arrow = createElement("span", { class: "dm-back-arrow" }, "\u2190");
const backInfo = createElement("div", { class: "dm-back-info" });
const backTitle = createElement("div", { class: "dm-back-title" },
`Back to ${options.serverName ?? "Server"}`);
const backTitle = createElement(
"div",
{ class: "dm-back-title" },
`Back to ${options.serverName ?? "Server"}`,
);
const backSub = createElement("div", { class: "dm-back-subtitle" }, "Return to channels");
appendChildren(backInfo, backTitle, backSub);
appendChildren(backHeader, arrow, backInfo);
@@ -20,9 +20,7 @@ export interface EditChannelModalOptions {
readonly onClose: () => void;
}
export function createEditChannelModal(
options: EditChannelModalOptions,
): MountableComponent {
export function createEditChannelModal(options: EditChannelModalOptions): MountableComponent {
const { channelName, channelType, onSave, onClose } = options;
const ac = new AbortController();
let overlay: HTMLDivElement | null = null;
@@ -120,10 +118,7 @@ export function createEditChannelModal(
await onSave({ name });
} catch (err) {
errorEl.style.display = "block";
setText(
errorEl,
err instanceof Error ? err.message : "Failed to update channel",
);
setText(errorEl, err instanceof Error ? err.message : "Failed to update channel");
saveBtn.removeAttribute("disabled");
setText(saveBtn, "Save Changes");
}
+443 -104
View File
@@ -35,128 +35,457 @@ const CATEGORIES: readonly EmojiCategory[] = [
{
name: "Smileys",
emoji: [
"😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "😊",
"😇", "🥰", "😍", "🤩", "😘", "😗", "😋", "😛", "😜", "🤪",
"😝", "🤑", "🤗", "🤭", "🤫", "🤔", "🤐", "🤨", "😐", "😑",
"😶", "😏", "😒", "🙄", "😬", "🤥", "😌", "😔", "😪", "🤤",
"😴", "😷", "🤒", "🤕", "🤢", "🤮", "🥵", "🥶", "🥴", "😵",
"🤯", "🤠", "🥳", "😎", "🤓", "🧐", "😕", "😟", "🙁", "😮",
"😲", "😳", "🥺", "😢", "😭", "😤", "😠", "😡", "🤬", "💀",
"😀",
"😃",
"😄",
"😁",
"😆",
"😅",
"🤣",
"😂",
"🙂",
"😊",
"😇",
"🥰",
"😍",
"🤩",
"😘",
"😗",
"😋",
"😛",
"😜",
"🤪",
"😝",
"🤑",
"🤗",
"🤭",
"🤫",
"🤔",
"🤐",
"🤨",
"😐",
"😑",
"😶",
"😏",
"😒",
"🙄",
"😬",
"🤥",
"😌",
"😔",
"😪",
"🤤",
"😴",
"😷",
"🤒",
"🤕",
"🤢",
"🤮",
"🥵",
"🥶",
"🥴",
"😵",
"🤯",
"🤠",
"🥳",
"😎",
"🤓",
"🧐",
"😕",
"😟",
"🙁",
"😮",
"😲",
"😳",
"🥺",
"😢",
"😭",
"😤",
"😠",
"😡",
"🤬",
"💀",
],
},
{
name: "People",
emoji: [
"👋", "🤚", "🖐", "✋", "🖖", "👌", "🤌", "🤏", "✌️", "🤞",
"🤟", "🤘", "🤙", "👈", "👉", "👆", "👇", "☝️", "👍", "👎",
"✊", "👊", "🤛", "🤜", "👏", "🙌", "👐", "🤲", "🤝", "🙏",
"👋",
"🤚",
"🖐",
"✋",
"🖖",
"👌",
"🤌",
"🤏",
"✌️",
"🤞",
"🤟",
"🤘",
"🤙",
"👈",
"👉",
"👆",
"👇",
"☝️",
"👍",
"👎",
"✊",
"👊",
"🤛",
"🤜",
"👏",
"🙌",
"👐",
"🤲",
"🤝",
"🙏",
],
},
{
name: "Nature",
emoji: [
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯",
"🦁", "🐮", "🐷", "🐸", "🐵", "🐔", "🐧", "🐦", "🐤", "🦄",
"🌸", "🌹", "🌺", "🌻", "🌼", "🌷", "🌱", "🌲", "🌳", "🍀",
"🐶",
"🐱",
"🐭",
"🐹",
"🐰",
"🦊",
"🐻",
"🐼",
"🐨",
"🐯",
"🦁",
"🐮",
"🐷",
"🐸",
"🐵",
"🐔",
"🐧",
"🐦",
"🐤",
"🦄",
"🌸",
"🌹",
"🌺",
"🌻",
"🌼",
"🌷",
"🌱",
"🌲",
"🌳",
"🍀",
],
},
{
name: "Food",
emoji: [
"🍎", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🍒", "🍑", "🍍",
"🥝", "🍔", "🍟", "🍕", "🌭", "🍿", "🧀", "🥚", "🍳", "🥓",
"☕", "🍵", "🍺", "🍻", "🥂", "🍷", "🍸", "🍹", "🍾", "🧁",
"🍎",
"🍊",
"🍋",
"🍌",
"🍉",
"🍇",
"🍓",
"🍒",
"🍑",
"🍍",
"🥝",
"🍔",
"🍟",
"🍕",
"🌭",
"🍿",
"🧀",
"🥚",
"🍳",
"🥓",
"☕",
"🍵",
"🍺",
"🍻",
"🥂",
"🍷",
"🍸",
"🍹",
"🍾",
"🧁",
],
},
{
name: "Objects",
emoji: [
"⚽", "🏀", "🏈", "⚾", "🎾", "🎮", "🎲", "🎯", "🎵", "🎶",
"💡", "🔥", "⭐", "🌟", "💫", "✨", "💥", "❤️", "🧡", "💛",
"💚", "💙", "💜", "🖤", "🤍", "💯", "💢", "💬", "👁‍🗨", "🗨",
"⚽",
"🏀",
"🏈",
"⚾",
"🎾",
"🎮",
"🎲",
"🎯",
"🎵",
"🎶",
"💡",
"🔥",
"⭐",
"🌟",
"💫",
"✨",
"💥",
"❤️",
"🧡",
"💛",
"💚",
"💙",
"💜",
"🖤",
"🤍",
"💯",
"💢",
"💬",
"👁‍🗨",
"🗨",
],
},
{
name: "Symbols",
emoji: [
"✅", "❌", "❓", "❗", "‼️", "⁉️", "💤", "💮", "♻️", "🔰",
"⚠️", "🚫", "🔴", "🟠", "🟡", "🟢", "🔵", "🟣", "⚫", "⚪",
"✅",
"",
"❓",
"❗",
"‼️",
"⁉️",
"💤",
"💮",
"♻️",
"🔰",
"⚠️",
"🚫",
"🔴",
"🟠",
"🟡",
"🟢",
"🔵",
"🟣",
"⚫",
"⚪",
],
},
];
/** Emoji name lookup for search. Maps emoji character → searchable keywords. */
const EMOJI_NAMES: Readonly<Record<string, string>> = {
"😀": "grinning face happy smile", "😃": "smiley face happy smile", "😄": "smile happy grin",
"😁": "beaming grin teeth smile", "😆": "laughing happy squint smile", "😅": "sweat smile nervous",
"🤣": "rofl laughing rolling floor", "😂": "joy tears laughing cry happy", "🙂": "slightly smiling",
"😊": "blush happy smile shy", "😇": "innocent angel halo", "🥰": "love hearts face smiling",
"😍": "heart eyes love", "🤩": "star struck excited", "😘": "kiss blowing wink",
"😗": "kissing face", "😋": "yummy delicious tongue food", "😛": "tongue out",
"😜": "wink tongue playful", "🤪": "zany crazy wild", "😝": "squinting tongue",
"🤑": "money face rich dollar", "🤗": "hugging hug hands", "🤭": "hand over mouth oops giggle",
"🤫": "shushing quiet secret shh", "🤔": "thinking hmm wonder", "🤐": "zipper mouth shut secret",
"🤨": "raised eyebrow skeptical", "😐": "neutral face blank", "😑": "expressionless blank",
"😶": "no mouth silent mute", "😏": "smirk smug", "😒": "unamused bored annoyed",
"🙄": "eye roll whatever", "😬": "grimace awkward teeth", "🤥": "lying pinocchio nose",
"😌": "relieved calm peaceful", "😔": "pensive sad thoughtful", "😪": "sleepy tired",
"🤤": "drooling hungry", "😴": "sleeping zzz tired", "😷": "mask sick medical face",
"🤒": "thermometer sick fever", "🤕": "bandage hurt injured", "🤢": "nauseous sick green",
"🤮": "vomiting throw up sick", "🥵": "hot face overheated", "🥶": "cold face freezing",
"🥴": "woozy drunk dizzy", "😵": "dizzy spiral knocked out", "🤯": "mind blown exploding head",
"🤠": "cowboy hat yeehaw", "🥳": "party celebration birthday", "😎": "sunglasses cool",
"🤓": "nerd glasses geek", "🧐": "monocle detective inspect", "😕": "confused puzzled",
"😟": "worried concerned", "🙁": "frowning sad", "😮": "open mouth surprised",
"😲": "astonished shocked wow", "😳": "flushed embarrassed", "🥺": "pleading puppy eyes please",
"😢": "crying sad tear", "😭": "sobbing crying loud", "😤": "steam nose angry huffing",
"😠": "angry mad", "😡": "rage furious red", "🤬": "cursing swearing symbols angry",
"😀": "grinning face happy smile",
"😃": "smiley face happy smile",
"😄": "smile happy grin",
"😁": "beaming grin teeth smile",
"😆": "laughing happy squint smile",
"😅": "sweat smile nervous",
"🤣": "rofl laughing rolling floor",
"😂": "joy tears laughing cry happy",
"🙂": "slightly smiling",
"😊": "blush happy smile shy",
"😇": "innocent angel halo",
"🥰": "love hearts face smiling",
"😍": "heart eyes love",
"🤩": "star struck excited",
"😘": "kiss blowing wink",
"😗": "kissing face",
"😋": "yummy delicious tongue food",
"😛": "tongue out",
"😜": "wink tongue playful",
"🤪": "zany crazy wild",
"😝": "squinting tongue",
"🤑": "money face rich dollar",
"🤗": "hugging hug hands",
"🤭": "hand over mouth oops giggle",
"🤫": "shushing quiet secret shh",
"🤔": "thinking hmm wonder",
"🤐": "zipper mouth shut secret",
"🤨": "raised eyebrow skeptical",
"😐": "neutral face blank",
"😑": "expressionless blank",
"😶": "no mouth silent mute",
"😏": "smirk smug",
"😒": "unamused bored annoyed",
"🙄": "eye roll whatever",
"😬": "grimace awkward teeth",
"🤥": "lying pinocchio nose",
"😌": "relieved calm peaceful",
"😔": "pensive sad thoughtful",
"😪": "sleepy tired",
"🤤": "drooling hungry",
"😴": "sleeping zzz tired",
"😷": "mask sick medical face",
"🤒": "thermometer sick fever",
"🤕": "bandage hurt injured",
"🤢": "nauseous sick green",
"🤮": "vomiting throw up sick",
"🥵": "hot face overheated",
"🥶": "cold face freezing",
"🥴": "woozy drunk dizzy",
"😵": "dizzy spiral knocked out",
"🤯": "mind blown exploding head",
"🤠": "cowboy hat yeehaw",
"🥳": "party celebration birthday",
"😎": "sunglasses cool",
"🤓": "nerd glasses geek",
"🧐": "monocle detective inspect",
"😕": "confused puzzled",
"😟": "worried concerned",
"🙁": "frowning sad",
"😮": "open mouth surprised",
"😲": "astonished shocked wow",
"😳": "flushed embarrassed",
"🥺": "pleading puppy eyes please",
"😢": "crying sad tear",
"😭": "sobbing crying loud",
"😤": "steam nose angry huffing",
"😠": "angry mad",
"😡": "rage furious red",
"🤬": "cursing swearing symbols angry",
"💀": "skull dead death skeleton",
"👋": "wave hello hi bye hand", "🤚": "raised back hand", "🖐": "hand fingers splayed five",
"": "raised hand stop high five", "🖖": "vulcan spock", "👌": "ok okay perfect",
"🤌": "pinched fingers italian", "🤏": "pinching small little", "✌️": "peace victory two",
"🤞": "crossed fingers luck hope", "🤟": "love you gesture rock",
"🤘": "rock on horns metal", "🤙": "call me hang loose shaka", "👈": "pointing left",
"👉": "pointing right", "👆": "pointing up", "👇": "pointing down", "☝️": "index pointing up",
"👍": "thumbs up like good yes", "👎": "thumbs down dislike bad no",
"": "raised fist power", "👊": "fist bump punch", "🤛": "left fist bump",
"🤜": "right fist bump", "👏": "clap applause bravo", "🙌": "raising hands hooray celebrate",
"👐": "open hands jazz", "🤲": "palms up together prayer", "🤝": "handshake deal agreement",
"👋": "wave hello hi bye hand",
"🤚": "raised back hand",
"🖐": "hand fingers splayed five",
"": "raised hand stop high five",
"🖖": "vulcan spock",
"👌": "ok okay perfect",
"🤌": "pinched fingers italian",
"🤏": "pinching small little",
"✌️": "peace victory two",
"🤞": "crossed fingers luck hope",
"🤟": "love you gesture rock",
"🤘": "rock on horns metal",
"🤙": "call me hang loose shaka",
"👈": "pointing left",
"👉": "pointing right",
"👆": "pointing up",
"👇": "pointing down",
"☝️": "index pointing up",
"👍": "thumbs up like good yes",
"👎": "thumbs down dislike bad no",
"✊": "raised fist power",
"👊": "fist bump punch",
"🤛": "left fist bump",
"🤜": "right fist bump",
"👏": "clap applause bravo",
"🙌": "raising hands hooray celebrate",
"👐": "open hands jazz",
"🤲": "palms up together prayer",
"🤝": "handshake deal agreement",
"🙏": "pray thanks please folded hands",
"🐶": "dog puppy pet", "🐱": "cat kitten pet", "🐭": "mouse rat", "🐹": "hamster",
"🐰": "rabbit bunny", "🦊": "fox", "🐻": "bear", "🐼": "panda bear",
"🐨": "koala", "🐯": "tiger", "🦁": "lion king", "🐮": "cow moo",
"🐷": "pig oink", "🐸": "frog toad", "🐵": "monkey face", "🐔": "chicken hen",
"🐧": "penguin", "🐦": "bird", "🐤": "chick baby bird", "🦄": "unicorn magic",
"🌸": "cherry blossom flower pink", "🌹": "rose flower red", "🌺": "hibiscus flower",
"🌻": "sunflower", "🌼": "blossom flower", "🌷": "tulip flower",
"🌱": "seedling sprout plant", "🌲": "evergreen tree pine", "🌳": "tree deciduous", "🍀": "four leaf clover luck",
"🍎": "red apple fruit", "🍊": "orange tangerine fruit", "🍋": "lemon fruit", "🍌": "banana fruit",
"🍉": "watermelon fruit", "🍇": "grapes fruit", "🍓": "strawberry fruit", "🍒": "cherries fruit",
"🍑": "peach fruit butt", "🍍": "pineapple fruit", "🥝": "kiwi fruit",
"🍔": "hamburger burger food", "🍟": "fries french food", "🍕": "pizza food slice",
"🌭": "hot dog food", "🍿": "popcorn snack movie", "🧀": "cheese wedge",
"🥚": "egg", "🍳": "cooking fried egg", "🥓": "bacon",
"": "coffee hot drink", "🍵": "tea hot drink", "🍺": "beer mug drink",
"🍻": "clinking beers cheers drink", "🥂": "champagne toast celebrate drink",
"🍷": "wine glass drink red", "🍸": "cocktail martini drink", "🍹": "tropical drink",
"🍾": "bottle popping champagne celebrate", "🧁": "cupcake dessert sweet",
"": "soccer football ball sport", "🏀": "basketball ball sport", "🏈": "football american sport",
"": "baseball ball sport", "🎾": "tennis ball sport", "🎮": "video game controller gaming",
"🎲": "dice game random", "🎯": "bullseye target dart", "🎵": "music note",
"🎶": "music notes", "💡": "light bulb idea", "🔥": "fire hot flame lit",
"": "star yellow", "🌟": "glowing star sparkle", "💫": "dizzy star shooting",
"": "sparkles magic shine", "💥": "boom collision crash", "❤️": "red heart love",
"🧡": "orange heart love", "💛": "yellow heart love", "💚": "green heart love",
"💙": "blue heart love", "💜": "purple heart love", "🖤": "black heart dark love",
"🤍": "white heart love", "💯": "hundred percent perfect score", "💢": "anger symbol mad",
"💬": "speech bubble chat talk", "👁‍🗨": "eye speech bubble witness", "🗨": "speech balloon left",
"": "check mark yes done complete", "❌": "cross mark no wrong cancel",
"": "question mark red", "❗": "exclamation mark red alert", "‼️": "double exclamation",
"⁉️": "exclamation question", "💤": "sleeping zzz tired", "💮": "white flower",
"♻️": "recycle green environment", "🔰": "beginner new japanese", "⚠️": "warning caution alert",
"🚫": "prohibited forbidden no", "🔴": "red circle", "🟠": "orange circle",
"🟡": "yellow circle", "🟢": "green circle", "🔵": "blue circle",
"🟣": "purple circle", "⚫": "black circle", "⚪": "white circle",
"🐶": "dog puppy pet",
"🐱": "cat kitten pet",
"🐭": "mouse rat",
"🐹": "hamster",
"🐰": "rabbit bunny",
"🦊": "fox",
"🐻": "bear",
"🐼": "panda bear",
"🐨": "koala",
"🐯": "tiger",
"🦁": "lion king",
"🐮": "cow moo",
"🐷": "pig oink",
"🐸": "frog toad",
"🐵": "monkey face",
"🐔": "chicken hen",
"🐧": "penguin",
"🐦": "bird",
"🐤": "chick baby bird",
"🦄": "unicorn magic",
"🌸": "cherry blossom flower pink",
"🌹": "rose flower red",
"🌺": "hibiscus flower",
"🌻": "sunflower",
"🌼": "blossom flower",
"🌷": "tulip flower",
"🌱": "seedling sprout plant",
"🌲": "evergreen tree pine",
"🌳": "tree deciduous",
"🍀": "four leaf clover luck",
"🍎": "red apple fruit",
"🍊": "orange tangerine fruit",
"🍋": "lemon fruit",
"🍌": "banana fruit",
"🍉": "watermelon fruit",
"🍇": "grapes fruit",
"🍓": "strawberry fruit",
"🍒": "cherries fruit",
"🍑": "peach fruit butt",
"🍍": "pineapple fruit",
"🥝": "kiwi fruit",
"🍔": "hamburger burger food",
"🍟": "fries french food",
"🍕": "pizza food slice",
"🌭": "hot dog food",
"🍿": "popcorn snack movie",
"🧀": "cheese wedge",
"🥚": "egg",
"🍳": "cooking fried egg",
"🥓": "bacon",
"☕": "coffee hot drink",
"🍵": "tea hot drink",
"🍺": "beer mug drink",
"🍻": "clinking beers cheers drink",
"🥂": "champagne toast celebrate drink",
"🍷": "wine glass drink red",
"🍸": "cocktail martini drink",
"🍹": "tropical drink",
"🍾": "bottle popping champagne celebrate",
"🧁": "cupcake dessert sweet",
"⚽": "soccer football ball sport",
"🏀": "basketball ball sport",
"🏈": "football american sport",
"⚾": "baseball ball sport",
"🎾": "tennis ball sport",
"🎮": "video game controller gaming",
"🎲": "dice game random",
"🎯": "bullseye target dart",
"🎵": "music note",
"🎶": "music notes",
"💡": "light bulb idea",
"🔥": "fire hot flame lit",
"⭐": "star yellow",
"🌟": "glowing star sparkle",
"💫": "dizzy star shooting",
"✨": "sparkles magic shine",
"💥": "boom collision crash",
"❤️": "red heart love",
"🧡": "orange heart love",
"💛": "yellow heart love",
"💚": "green heart love",
"💙": "blue heart love",
"💜": "purple heart love",
"🖤": "black heart dark love",
"🤍": "white heart love",
"💯": "hundred percent perfect score",
"💢": "anger symbol mad",
"💬": "speech bubble chat talk",
"👁‍🗨": "eye speech bubble witness",
"🗨": "speech balloon left",
"✅": "check mark yes done complete",
"❌": "cross mark no wrong cancel",
"❓": "question mark red",
"❗": "exclamation mark red alert",
"‼️": "double exclamation",
"⁉️": "exclamation question",
"💤": "sleeping zzz tired",
"💮": "white flower",
"♻️": "recycle green environment",
"🔰": "beginner new japanese",
"⚠️": "warning caution alert",
"🚫": "prohibited forbidden no",
"🔴": "red circle",
"🟠": "orange circle",
"🟡": "yellow circle",
"🟢": "green circle",
"🔵": "blue circle",
"🟣": "purple circle",
"⚫": "black circle",
"⚪": "white circle",
};
const MAX_RECENT = 20;
@@ -224,9 +553,7 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
// Build categories with recent + custom
function getAllCategories(): readonly EmojiCategory[] {
const recent = getRecentEmoji();
const cats: EmojiCategory[] = [
{ name: "Recent", emoji: recent },
];
const cats: EmojiCategory[] = [{ name: "Recent", emoji: recent }];
// Custom server emoji
if (options.customEmoji && options.customEmoji.length > 0) {
@@ -292,9 +619,13 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
// If nothing rendered at all, show empty state
if (scrollArea.children.length === 0) {
const empty = createElement("div", {
style: "padding: 24px; text-align: center; color: var(--text-faint); font-size: 13px;",
}, "No emoji found");
const empty = createElement(
"div",
{
style: "padding: 24px; text-align: center; color: var(--text-faint); font-size: 13px;",
},
"No emoji found",
);
scrollArea.appendChild(empty);
}
}
@@ -303,17 +634,25 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
renderAllCategories(getAllCategories());
// Search handler
searchInput.addEventListener("input", () => {
searchQuery = searchInput.value.trim();
renderAllCategories(getAllCategories());
}, { signal });
searchInput.addEventListener(
"input",
() => {
searchQuery = searchInput.value.trim();
renderAllCategories(getAllCategories());
},
{ signal },
);
// Close on Escape
root.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
options.onClose();
}
}, { signal });
root.addEventListener(
"keydown",
(e) => {
if (e.key === "Escape") {
options.onClose();
}
},
{ signal },
);
// Focus search on mount
requestAnimationFrame(() => searchInput.focus());
@@ -7,9 +7,16 @@ import type { MountableComponent } from "@lib/safe-render";
/** Default allowed MIME types for file uploads. */
const DEFAULT_ALLOWED_TYPES = [
"image/jpeg", "image/png", "image/gif", "image/webp", "image/avif",
"video/mp4", "video/webm",
"audio/mpeg", "audio/ogg", "audio/wav",
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/avif",
"video/mp4",
"video/webm",
"audio/mpeg",
"audio/ogg",
"audio/wav",
"application/pdf",
"text/plain",
];
@@ -85,7 +92,9 @@ export function createFileUpload(options: FileUploadOptions): FileUploadComponen
return;
}
if (file.size > maxBytes) {
showError(`File too large (${formatSize(file.size)}). Max ${options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB} MB.`);
showError(
`File too large (${formatSize(file.size)}). Max ${options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB} MB.`,
);
return;
}
showPreview(file);
@@ -105,8 +114,13 @@ export function createFileUpload(options: FileUploadOptions): FileUploadComponen
function buildDom(): void {
root = createElement("div", { class: "file-upload" });
dropzone = createElement("div", { class: "file-upload__dropzone file-upload__dropzone--hidden" });
appendChildren(dropzone, createElement("span", { class: "file-upload__droptext" }, "Drop files here"));
dropzone = createElement("div", {
class: "file-upload__dropzone file-upload__dropzone--hidden",
});
appendChildren(
dropzone,
createElement("span", { class: "file-upload__droptext" }, "Drop files here"),
);
const allowed = options.allowedMimeTypes ?? DEFAULT_ALLOWED_TYPES;
fileInput = createElement("input", {
@@ -135,38 +149,64 @@ export function createFileUpload(options: FileUploadOptions): FileUploadComponen
}
function attachListeners(): void {
fileInput.addEventListener("change", () => {
const file = fileInput.files?.[0];
if (file) { void handleFile(file); fileInput.value = ""; }
}, { signal });
fileInput.addEventListener(
"change",
() => {
const file = fileInput.files?.[0];
if (file) {
void handleFile(file);
fileInput.value = "";
}
},
{ signal },
);
cancelBtn.addEventListener("click", () => {
if (uploadAbort !== null) uploadAbort.abort();
resetPreview();
}, { signal });
cancelBtn.addEventListener(
"click",
() => {
if (uploadAbort !== null) uploadAbort.abort();
resetPreview();
},
{ signal },
);
let dragCounter = 0;
root!.addEventListener("dragenter", (e) => {
e.preventDefault();
dragCounter++;
dropzone.classList.remove("file-upload__dropzone--hidden");
}, { signal });
root!.addEventListener(
"dragenter",
(e) => {
e.preventDefault();
dragCounter++;
dropzone.classList.remove("file-upload__dropzone--hidden");
},
{ signal },
);
root!.addEventListener("dragleave", (e) => {
e.preventDefault();
dragCounter--;
if (dragCounter <= 0) { dragCounter = 0; dropzone.classList.add("file-upload__dropzone--hidden"); }
}, { signal });
root!.addEventListener(
"dragleave",
(e) => {
e.preventDefault();
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
dropzone.classList.add("file-upload__dropzone--hidden");
}
},
{ signal },
);
root!.addEventListener("dragover", (e) => e.preventDefault(), { signal });
root!.addEventListener("drop", (e) => {
e.preventDefault();
dragCounter = 0;
dropzone.classList.add("file-upload__dropzone--hidden");
const file = e.dataTransfer?.files[0];
if (file) void handleFile(file);
}, { signal });
root!.addEventListener(
"drop",
(e) => {
e.preventDefault();
dragCounter = 0;
dropzone.classList.add("file-upload__dropzone--hidden");
const file = e.dataTransfer?.files[0];
if (file) void handleFile(file);
},
{ signal },
);
}
function mount(container: Element): void {
@@ -182,7 +222,9 @@ export function createFileUpload(options: FileUploadOptions): FileUploadComponen
root = null;
}
function openPicker(): void { fileInput.click(); }
function openPicker(): void {
fileInput.click();
}
return { mount, destroy, openPicker };
}
+31 -20
View File
@@ -88,10 +88,14 @@ export function createGifPicker(options: GifPickerOptions): {
});
item.appendChild(img);
item.addEventListener("click", () => {
options.onSelect(gif.fullUrl);
options.onClose();
}, { signal });
item.addEventListener(
"click",
() => {
options.onSelect(gif.fullUrl);
options.onClose();
},
{ signal },
);
grid.appendChild(item);
}
@@ -109,9 +113,8 @@ export function createGifPicker(options: GifPickerOptions): {
showLoading();
try {
const gifs = query.length > 0
? await searchGifs(query, GIF_LIMIT)
: await getTrendingGifs(GIF_LIMIT);
const gifs =
query.length > 0 ? await searchGifs(query, GIF_LIMIT) : await getTrendingGifs(GIF_LIMIT);
// Only render if this is still the latest request
if (requestId === currentRequestId) {
@@ -130,20 +133,28 @@ export function createGifPicker(options: GifPickerOptions): {
// ── Event handlers ──
searchInput.addEventListener("input", () => {
if (debounceTimer !== null) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
void loadGifs(searchInput.value.trim());
}, DEBOUNCE_MS);
}, { signal });
searchInput.addEventListener(
"input",
() => {
if (debounceTimer !== null) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
void loadGifs(searchInput.value.trim());
}, DEBOUNCE_MS);
},
{ signal },
);
root.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
options.onClose();
}
}, { signal });
root.addEventListener(
"keydown",
(e) => {
if (e.key === "Escape") {
options.onClose();
}
},
{ signal },
);
// Focus search on mount
requestAnimationFrame(() => searchInput.focus());
@@ -39,9 +39,8 @@ function maskCode(code: string): string {
}
function formatInviteInfo(invite: InviteItem): string {
const uses = invite.maxUses !== null
? `${invite.uses}/${invite.maxUses} uses`
: `${invite.uses} uses`;
const uses =
invite.maxUses !== null ? `${invite.uses}/${invite.maxUses} uses` : `${invite.uses} uses`;
return `Created by ${invite.createdBy} \u00B7 ${uses}`;
}
@@ -49,9 +48,7 @@ function formatInviteInfo(invite: InviteItem): string {
// Factory
// ---------------------------------------------------------------------------
export function createInviteManager(
options: InviteManagerOptions,
): MountableComponent {
export function createInviteManager(options: InviteManagerOptions): MountableComponent {
const ac = new AbortController();
let root: HTMLDivElement | null = null;
let listEl: HTMLDivElement | null = null;
@@ -80,21 +77,32 @@ export function createInviteManager(
const copyBtn = createElement("button", { class: "invite-item__copy" });
copyBtn.appendChild(createIcon("external-link", 14));
copyBtn.appendChild(document.createTextNode(" Copy"));
copyBtn.addEventListener("click", () => {
options.onCopyLink(invite.code);
}, { signal: ac.signal });
copyBtn.addEventListener(
"click",
() => {
options.onCopyLink(invite.code);
},
{ signal: ac.signal },
);
const revokeBtn = createElement("button", { class: "invite-item__revoke" });
revokeBtn.appendChild(createIcon("trash-2", 14));
revokeBtn.appendChild(document.createTextNode(" Revoke"));
revokeBtn.addEventListener("click", () => {
void options.onRevokeInvite(invite.code).then(() => {
invites = invites.filter((i) => i.code !== invite.code);
renderList();
}).catch(() => {
options.onError?.("Failed to revoke invite");
});
}, { signal: ac.signal });
revokeBtn.addEventListener(
"click",
() => {
void options
.onRevokeInvite(invite.code)
.then(() => {
invites = invites.filter((i) => i.code !== invite.code);
renderList();
})
.catch(() => {
options.onError?.("Failed to revoke invite");
});
},
{ signal: ac.signal },
);
appendChildren(actions, copyBtn, revokeBtn);
appendChildren(headerRow, code, actions);
@@ -135,29 +143,44 @@ export function createInviteManager(
const createBtn = createElement("button", { class: "invite-manager__create btn-modal-save" });
createBtn.appendChild(createIcon("external-link", 14));
createBtn.appendChild(document.createTextNode(" Create Invite"));
createBtn.addEventListener("click", () => {
void options.onCreateInvite().then((newInvite) => {
invites = [...invites, newInvite];
renderList();
}).catch(() => {
options.onError?.("Failed to create invite");
});
}, { signal: ac.signal });
createBtn.addEventListener(
"click",
() => {
void options
.onCreateInvite()
.then((newInvite) => {
invites = [...invites, newInvite];
renderList();
})
.catch(() => {
options.onError?.("Failed to create invite");
});
},
{ signal: ac.signal },
);
footer.appendChild(createBtn);
// Escape key
document.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Escape") {
options.onClose();
}
}, { signal: ac.signal });
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape") {
options.onClose();
}
},
{ signal: ac.signal },
);
// Click overlay to close
root.addEventListener("click", (e) => {
if (e.target === root) {
options.onClose();
}
}, { signal: ac.signal });
root.addEventListener(
"click",
(e) => {
if (e.target === root) {
options.onClose();
}
},
{ signal: ac.signal },
);
appendChildren(modal, header, body, footer);
root.appendChild(modal);
@@ -35,21 +35,31 @@ const ROLE_GROUPS: readonly {
/** Status priority for sorting: lower = higher priority (shown first). */
function statusPriority(status: UserStatus): number {
switch (status) {
case "online": return 0;
case "idle": return 1;
case "dnd": return 2;
case "offline": return 3;
default: return 99;
case "online":
return 0;
case "idle":
return 1;
case "dnd":
return 2;
case "offline":
return 3;
default:
return 99;
}
}
function statusColor(status: UserStatus): string {
switch (status) {
case "online": return "var(--green)";
case "idle": return "var(--yellow)";
case "dnd": return "var(--red)";
case "offline": return "var(--text-micro)";
default: return "#747f8d";
case "online":
return "var(--green)";
case "idle":
return "var(--yellow)";
case "dnd":
return "var(--red)";
case "offline":
return "var(--text-micro)";
default:
return "#747f8d";
}
}
@@ -95,53 +105,54 @@ function createMemberItem(
});
avatar.appendChild(statusDot);
const name = createElement(
"span",
{ class: "mi-name", style: `color: ${colorVar}` },
);
const name = createElement("span", { class: "mi-name", style: `color: ${colorVar}` });
setText(name, member.username);
appendChildren(item, avatar, name);
// Context menu for admin actions
item.addEventListener("contextmenu", (e) => {
e.preventDefault();
item.addEventListener(
"contextmenu",
(e) => {
e.preventDefault();
// Don't show context menu for yourself
const currentUserId = authStore.getState().user?.id ?? 0;
if (member.id === currentUserId) return;
// Don't show context menu for yourself
const currentUserId = authStore.getState().user?.id ?? 0;
if (member.id === currentUserId) return;
// Only admins and owners can use admin actions
const role = opts.currentUserRole.toLowerCase();
if (role !== "owner" && role !== "admin") return;
// Only admins and owners can use admin actions
const role = opts.currentUserRole.toLowerCase();
if (role !== "owner" && role !== "admin") return;
closeActiveMenu();
document.removeEventListener("mousedown", handleOutsideClick);
closeActiveMenu();
document.removeEventListener("mousedown", handleOutsideClick);
const availableRoles = ["admin", "moderator", "member"];
const availableRoles = ["admin", "moderator", "member"];
activeMenu = createMemberContextMenu({
userId: member.id,
username: member.username,
currentRole: member.role.toLowerCase(),
availableRoles,
onKick: () => opts.onKick(member.id, member.username),
onBan: () => opts.onBan(member.id, member.username),
onChangeRole: (newRole: string) => opts.onChangeRole(member.id, member.username, newRole),
});
activeMenu = createMemberContextMenu({
userId: member.id,
username: member.username,
currentRole: member.role.toLowerCase(),
availableRoles,
onKick: () => opts.onKick(member.id, member.username),
onBan: () => opts.onBan(member.id, member.username),
onChangeRole: (newRole: string) => opts.onChangeRole(member.id, member.username, newRole),
});
// Position at mouse
activeMenu.element.style.position = "fixed";
activeMenu.element.style.left = `${e.clientX}px`;
activeMenu.element.style.top = `${e.clientY}px`;
activeMenu.element.style.zIndex = "1000";
document.body.appendChild(activeMenu.element);
// Position at mouse
activeMenu.element.style.position = "fixed";
activeMenu.element.style.left = `${e.clientX}px`;
activeMenu.element.style.top = `${e.clientY}px`;
activeMenu.element.style.zIndex = "1000";
document.body.appendChild(activeMenu.element);
// Close on outside click (deferred so this click doesn't close it)
setTimeout(() => {
document.addEventListener("mousedown", handleOutsideClick);
}, 0);
}, { signal });
// Close on outside click (deferred so this click doesn't close it)
setTimeout(() => {
document.addEventListener("mousedown", handleOutsideClick);
}, 0);
},
{ signal },
);
return item;
}
@@ -12,7 +12,11 @@ import { createGifPicker } from "@components/GifPicker";
export interface MessageInputOptions {
readonly channelId: number;
readonly channelName: string;
readonly onSend: (content: string, replyTo: number | null, attachments: readonly string[]) => void;
readonly onSend: (
content: string,
replyTo: number | null,
attachments: readonly string[],
) => void;
readonly onUploadFile?: (file: File) => Promise<{ id: string; url: string; filename: string }>;
readonly onTyping: () => void;
readonly onEditMessage: (messageId: number, content: string) => void;
@@ -40,14 +44,14 @@ const ALLOWED_TYPES = [
"application/json",
];
export function createMessageInput(
options: MessageInputOptions,
): MessageInputComponent {
export function createMessageInput(options: MessageInputOptions): MessageInputComponent {
const ac = new AbortController();
const signal = ac.signal;
let root: HTMLDivElement | null = null;
let state = { replyTo: null as { messageId: number; username: string } | null,
editing: null as { messageId: number } | null };
let state = {
replyTo: null as { messageId: number; username: string } | null,
editing: null as { messageId: number } | null,
};
let lastTypingTime = 0;
let lastSendTime = 0;
@@ -58,7 +62,8 @@ export function createMessageInput(
let attachmentPreviewBar: HTMLDivElement | null = null;
/** Pending attachment IDs to send with the next message. */
const pendingAttachments: { id: string; filename: string; readonly previewEl: HTMLDivElement }[] = [];
const pendingAttachments: { id: string; filename: string; readonly previewEl: HTMLDivElement }[] =
[];
/** Count of file uploads currently in flight. */
let pendingUploadCount = 0;
/** References to picker close functions, set by mount() for destroy() to call. */
@@ -72,9 +77,15 @@ export function createMessageInput(
replyBar.classList.add("visible");
}
function hideReplyBar(): void { replyBar?.classList.remove("visible"); }
function showEditBar(): void { editBar?.classList.add("visible"); }
function hideEditBar(): void { editBar?.classList.remove("visible"); }
function hideReplyBar(): void {
replyBar?.classList.remove("visible");
}
function showEditBar(): void {
editBar?.classList.add("visible");
}
function hideEditBar(): void {
editBar?.classList.remove("visible");
}
function autoResize(): void {
if (textarea === null) return;
@@ -102,11 +113,18 @@ export function createMessageInput(
function showUploadError(message: string): void {
if (attachmentPreviewBar === null) return;
const errEl = createElement("div", {
class: "attachment-upload-error",
}, message);
const errEl = createElement(
"div",
{
class: "attachment-upload-error",
},
message,
);
attachmentPreviewBar.appendChild(errEl);
const t = setTimeout(() => { activeTimers.delete(t); errEl.remove(); }, 4000);
const t = setTimeout(() => {
activeTimers.delete(t);
errEl.remove();
}, 4000);
activeTimers.add(t);
}
@@ -203,15 +221,17 @@ export function createMessageInput(
alt: file.name,
});
item.appendChild(img);
readFileAsDataUrl(file).then((dataUrl) => {
if (signal.aborted) return;
img.src = dataUrl;
}).catch(() => {
if (signal.aborted) return;
// Fallback: show filename
const nameEl = createElement("span", { class: "attachment-preview-name" }, file.name);
img.replaceWith(nameEl);
});
readFileAsDataUrl(file)
.then((dataUrl) => {
if (signal.aborted) return;
img.src = dataUrl;
})
.catch(() => {
if (signal.aborted) return;
// Fallback: show filename
const nameEl = createElement("span", { class: "attachment-preview-name" }, file.name);
img.replaceWith(nameEl);
});
} else {
const icon = createElement("div", { class: "attachment-preview-file" });
icon.appendChild(createIcon("file-text", 16));
@@ -229,10 +249,14 @@ export function createMessageInput(
"data-testid": "attachment-remove",
});
removeBtn.appendChild(createIcon("x", 14));
removeBtn.addEventListener("click", (e) => {
e.stopPropagation();
removePreviewItem(tempId);
}, { signal });
removeBtn.addEventListener(
"click",
(e) => {
e.stopPropagation();
removePreviewItem(tempId);
},
{ signal },
);
item.appendChild(removeBtn);
attachmentPreviewBar.appendChild(item);
@@ -289,7 +313,10 @@ export function createMessageInput(
function cancelEdit(): void {
state = { ...state, editing: null };
hideEditBar();
if (textarea !== null) { textarea.value = ""; autoResize(); }
if (textarea !== null) {
textarea.value = "";
autoResize();
}
}
function mount(container: Element): void {
@@ -318,8 +345,11 @@ export function createMessageInput(
attachmentPreviewBar = createElement("div", { class: "attachment-preview-bar" });
const inputBox = createElement("div", { class: "message-input-box" });
const attachBtn = createElement("button",
{ class: "input-btn attach-btn", "aria-label": "Attach file" }, "+");
const attachBtn = createElement(
"button",
{ class: "input-btn attach-btn", "aria-label": "Attach file" },
"+",
);
// File picker via attach button
if (options.onUploadFile !== undefined) {
@@ -328,13 +358,17 @@ export function createMessageInput(
style: "display: none;",
accept: "image/*,video/*,audio/*,.pdf,.txt,.zip,.rar,.7z",
});
fileInput.addEventListener("change", () => {
const file = fileInput.files?.[0];
if (file != null) {
void handlePasteFile(file);
}
fileInput.value = "";
}, { signal });
fileInput.addEventListener(
"change",
() => {
const file = fileInput.files?.[0];
if (file != null) {
void handlePasteFile(file);
}
fileInput.value = "";
},
{ signal },
);
attachBtn.addEventListener("click", () => fileInput.click(), { signal });
root?.appendChild(fileInput);
} else {
@@ -342,42 +376,73 @@ export function createMessageInput(
attachBtn.title = "File uploads not available";
}
textarea = createElement("textarea", {
class: "msg-textarea", placeholder: `Message #${options.channelName}`, rows: "1",
class: "msg-textarea",
placeholder: `Message #${options.channelName}`,
rows: "1",
"data-testid": "msg-textarea",
});
const emojiBtn = createElement("button",
{ class: "input-btn emoji-btn", "aria-label": "Emoji" });
const emojiBtn = createElement("button", {
class: "input-btn emoji-btn",
"aria-label": "Emoji",
});
emojiBtn.appendChild(createIcon("smile", 20));
const gifBtn = createElement("button",
{ class: "input-btn gif-btn", "aria-label": "GIF" }, "GIF");
const sendBtn = createElement("button",
{ class: "input-btn send-btn", "aria-label": "Send message", "data-testid": "send-btn" });
const gifBtn = createElement(
"button",
{ class: "input-btn gif-btn", "aria-label": "GIF" },
"GIF",
);
const sendBtn = createElement("button", {
class: "input-btn send-btn",
"aria-label": "Send message",
"data-testid": "send-btn",
});
sendBtn.appendChild(createIcon("send", 20));
textarea.addEventListener("input", () => { autoResize(); maybeEmitTyping(); }, { signal });
textarea.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); }
if (e.key === "Escape") {
if (state.editing !== null) { cancelEdit(); }
else if (state.replyTo !== null) { clearReply(); }
}
if (e.key === "ArrowUp" && textarea !== null && textarea.value.length === 0) {
root?.dispatchEvent(new CustomEvent("edit-last-message", { bubbles: true }));
}
}, { signal });
textarea.addEventListener(
"input",
() => {
autoResize();
maybeEmitTyping();
},
{ signal },
);
textarea.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
if (e.key === "Escape") {
if (state.editing !== null) {
cancelEdit();
} else if (state.replyTo !== null) {
clearReply();
}
}
if (e.key === "ArrowUp" && textarea !== null && textarea.value.length === 0) {
root?.dispatchEvent(new CustomEvent("edit-last-message", { bubbles: true }));
}
},
{ signal },
);
// Clipboard paste: detect images/files
textarea.addEventListener("paste", (e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (items === undefined) return;
for (const item of items) {
if (item.kind !== "file") continue;
const file = item.getAsFile();
if (file === null) continue;
e.preventDefault();
void handlePasteFile(file);
}
}, { signal });
textarea.addEventListener(
"paste",
(e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (items === undefined) return;
for (const item of items) {
if (item.kind !== "file") continue;
const file = item.getAsFile();
if (file === null) continue;
e.preventDefault();
void handlePasteFile(file);
}
},
{ signal },
);
sendBtn.addEventListener("click", handleSend, { signal });
@@ -398,7 +463,11 @@ export function createMessageInput(
if (emojiPicker === null) return;
const target = e.target as Node;
// Close if click is outside both the picker and the emoji button
if (!emojiPicker.element.contains(target) && target !== emojiBtn && !emojiBtn.contains(target)) {
if (
!emojiPicker.element.contains(target) &&
target !== emojiBtn &&
!emojiBtn.contains(target)
) {
closeEmojiPicker();
}
}
@@ -494,7 +563,10 @@ export function createMessageInput(
gifBtn.addEventListener("click", toggleGifPicker, { signal });
// Store picker cleanup for destroy()
cleanupPickers = () => { closeEmojiPicker(); closeGifPicker(); };
cleanupPickers = () => {
closeEmojiPicker();
closeGifPicker();
};
appendChildren(inputBox, attachBtn, textarea, emojiBtn, gifBtn, sendBtn);
appendChildren(root, replyBar, editBar, attachmentPreviewBar, inputBox);
@@ -11,12 +11,7 @@ import type { Message } from "@stores/messages.store";
import { membersStore } from "@stores/members.store";
const log = createLogger("message-list");
import {
shouldGroup,
isSameDay,
renderDayDivider,
renderMessage,
} from "./message-list/renderers";
import { shouldGroup, isSameDay, renderDayDivider, renderMessage } from "./message-list/renderers";
import { FenwickTree } from "./message-list/fenwick";
// -- Options ------------------------------------------------------------------
@@ -120,9 +115,7 @@ function renderEmptyState(channelName: string, channelType?: string): HTMLDivEle
icon.textContent = isDm ? "@" : "#";
const title = createElement("h2", { class: "channel-welcome-title" });
title.textContent = isDm
? channelName
: `Welcome to #${channelName}!`;
title.textContent = isDm ? channelName : `Welcome to #${channelName}!`;
const text = createElement("p", { class: "channel-welcome-text" });
text.textContent = isDm
@@ -277,7 +270,8 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
let renderWindowResetTimer = 0;
function renderWindow(): void {
if (root === null || contentContainer === null || topSpacer === null || bottomSpacer === null) return;
if (root === null || contentContainer === null || topSpacer === null || bottomSpacer === null)
return;
const scrollTop = root.scrollTop;
const clientHeight = root.clientHeight;
@@ -465,9 +459,9 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
// Load older messages when near top
if (
root.scrollTop < SCROLL_TOP_THRESHOLD
&& !loadingOlder
&& hasMoreMessages(options.channelId)
root.scrollTop < SCROLL_TOP_THRESHOLD &&
!loadingOlder &&
hasMoreMessages(options.channelId)
) {
loadingOlder = true;
options.onScrollTop();
@@ -499,10 +493,14 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
scrollToBottomBtn = createElement("button", { class: "scroll-to-bottom-btn" });
scrollToBottomBtn.textContent = "↓";
scrollToBottomBtn.addEventListener("click", () => {
scrollToBottom();
updateScrollToBottomBtn();
}, { signal: ac.signal });
scrollToBottomBtn.addEventListener(
"click",
() => {
scrollToBottom();
updateScrollToBottomBtn();
},
{ signal: ac.signal },
);
root.appendChild(topSpacer);
root.appendChild(contentContainer);
@@ -555,21 +553,29 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
const initialScrollRaf = requestAnimationFrame(() => scrollToBottom());
ac.signal.addEventListener("abort", () => cancelAnimationFrame(initialScrollRaf));
unsubscribers.push(messagesStore.subscribeSelector(
(s) => s.messagesByChannel,
() => { renderAll(); },
));
unsubscribers.push(
messagesStore.subscribeSelector(
(s) => s.messagesByChannel,
() => {
renderAll();
},
),
);
// Only re-render when member roles change, not on presence/typing updates.
// Extract a role-only map so shallowEqual ignores status changes.
unsubscribers.push(membersStore.subscribeSelector(
(s) => {
const roles = new Map<number, string>();
for (const [id, m] of s.members) roles.set(id, m.role);
return roles;
},
() => { renderAll(); },
));
unsubscribers.push(
membersStore.subscribeSelector(
(s) => {
const roles = new Map<number, string>();
for (const [id, m] of s.members) roles.set(id, m.role);
return roles;
},
() => {
renderAll();
},
),
);
}
function destroy(): void {
@@ -591,11 +597,16 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
renderWindowResetTimer = 0;
}
unsubLoadingReset();
for (const unsub of unsubscribers) { unsub(); }
for (const unsub of unsubscribers) {
unsub();
}
unsubscribers.length = 0;
heightCache.clear();
tree = null;
if (root !== null) { root.remove(); root = null; }
if (root !== null) {
root.remove();
root = null;
}
contentContainer = null;
topSpacer = null;
bottomSpacer = null;
@@ -618,7 +629,9 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
const el = contentContainer.children[localIdx] as HTMLElement | undefined;
if (el !== undefined) {
el.classList.add("highlight-flash");
setTimeout(() => { el.classList.remove("highlight-flash"); }, 1500);
setTimeout(() => {
el.classList.remove("highlight-flash");
}, 1500);
}
}
@@ -3,10 +3,7 @@
* with avatars, hover actions, and entry animation.
*/
import {
createElement,
appendChildren,
} from "@lib/dom";
import { createElement, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
@@ -92,9 +89,7 @@ function renderEmptyState(): HTMLDivElement {
return empty;
}
export function createPinnedMessages(
options: PinnedMessagesOptions,
): MountableComponent {
export function createPinnedMessages(options: PinnedMessagesOptions): MountableComponent {
const ac = new AbortController();
let root: HTMLDivElement | null = null;
@@ -39,17 +39,24 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
});
// Close on backdrop click (not on modal content)
root.addEventListener("click", (e) => {
if (e.target === root) options.onClose();
}, { signal: ac.signal });
root.addEventListener(
"click",
(e) => {
if (e.target === root) options.onClose();
},
{ signal: ac.signal },
);
const modal = createElement("div", { class: "quick-switch-modal" });
// Header
const header = createElement("div", { class: "quick-switch-header" });
const title = createElement("h2", {}, "Switch Server");
const subtitle = createElement("p", { class: "quick-switch-subtitle" },
"You\u2019ll disconnect from the current server.");
const subtitle = createElement(
"p",
{ class: "quick-switch-subtitle" },
"You\u2019ll disconnect from the current server.",
);
appendChildren(header, title, subtitle);
// Server list
@@ -68,8 +75,11 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
const info = createElement("div", { class: "quick-switch-info" });
const nameEl = createElement("div", { class: "quick-switch-name" }, profile.name);
const hostEl = createElement("div", { class: "quick-switch-host" },
`${profile.host}${isCurrent ? " \u00B7 Connected" : ""}`);
const hostEl = createElement(
"div",
{ class: "quick-switch-host" },
`${profile.host}${isCurrent ? " \u00B7 Connected" : ""}`,
);
appendChildren(info, nameEl, hostEl);
if (isCurrent) {
@@ -77,9 +87,13 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
appendChildren(item, icon, info, dot);
} else {
appendChildren(item, icon, info);
item.addEventListener("click", () => {
options.onSwitch(profile.host, profile.name);
}, { signal: ac.signal });
item.addEventListener(
"click",
() => {
options.onSwitch(profile.host, profile.name);
},
{ signal: ac.signal },
);
}
list.appendChild(item);
@@ -93,7 +107,11 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
const addIcon = createElement("div", { class: "quick-switch-icon add" }, "+");
const addInfo = createElement("div", { class: "quick-switch-info" });
const addName = createElement("div", { class: "quick-switch-name" }, "Add new server");
const addHost = createElement("div", { class: "quick-switch-host" }, "Connect to another OwnCord server");
const addHost = createElement(
"div",
{ class: "quick-switch-host" },
"Connect to another OwnCord server",
);
appendChildren(addInfo, addName, addHost);
appendChildren(addItem, addIcon, addInfo);
addItem.addEventListener("click", () => options.onAddServer(), { signal: ac.signal });
@@ -107,9 +125,13 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
container.appendChild(root);
// Escape key closes overlay
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") options.onClose();
}, { signal: ac.signal });
document.addEventListener(
"keydown",
(e) => {
if (e.key === "Escape") options.onClose();
},
{ signal: ac.signal },
);
}
function destroy(): void {
@@ -68,10 +68,14 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
appendChildren(item, ...parts);
item.addEventListener("click", () => {
options.onSelectChannel(ch.id);
options.onClose();
}, { signal });
item.addEventListener(
"click",
() => {
options.onSelectChannel(ch.id);
options.onClose();
},
{ signal },
);
resultsDiv.appendChild(item);
}
@@ -144,7 +148,8 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
// Overlay backdrop
root = createElement("div", {
class: "quick-switcher-overlay",
style: "position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000; display: flex; justify-content: center; padding-top: 20vh;",
style:
"position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000; display: flex; justify-content: center; padding-top: 20vh;",
});
// Modal container
@@ -175,10 +180,7 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
document.addEventListener("keydown", handleGlobalKeydown, { signal });
// Subscribe to store changes
unsubscribe = channelsStore.subscribeSelector(
(s) => s.channels,
refreshFromStore,
);
unsubscribe = channelsStore.subscribeSelector((s) => s.channels, refreshFromStore);
// Auto-focus
requestAnimationFrame(() => input.focus());
@@ -13,7 +13,11 @@ import type { SearchResultItem } from "@lib/types";
// ---------------------------------------------------------------------------
export interface SearchOverlayOptions {
readonly onSearch: (query: string, channelId?: number, signal?: AbortSignal) => Promise<readonly SearchResultItem[]>;
readonly onSearch: (
query: string,
channelId?: number,
signal?: AbortSignal,
) => Promise<readonly SearchResultItem[]>;
readonly onSelectResult: (result: SearchResultItem) => void;
readonly onClose: () => void;
readonly currentChannelId?: number;
@@ -49,8 +53,11 @@ export function createSearchOverlay(options: SearchOverlayOptions): MountableCom
function formatTimestamp(ts: string): string {
try {
const d = new Date(ts);
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
+ " " + d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
return (
d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) +
" " +
d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
);
} catch {
return ts;
}
@@ -65,9 +72,7 @@ export function createSearchOverlay(options: SearchOverlayOptions): MountableCom
const isActive = i === activeIndex;
const item = createElement("div", {
class: isActive
? "search-result-item search-result-item--active"
: "search-result-item",
class: isActive ? "search-result-item search-result-item--active" : "search-result-item",
role: "option",
"aria-selected": isActive ? "true" : "false",
"data-testid": `search-result-${i}`,
@@ -87,10 +92,14 @@ export function createSearchOverlay(options: SearchOverlayOptions): MountableCom
appendChildren(item, header, content);
item.addEventListener("click", () => {
options.onSelectResult(r);
options.onClose();
}, { signal });
item.addEventListener(
"click",
() => {
options.onSelectResult(r);
options.onClose();
},
{ signal },
);
resultsDiv.appendChild(item);
}
@@ -122,7 +131,8 @@ export function createSearchOverlay(options: SearchOverlayOptions): MountableCom
setStatus("Searching...");
options.onSearch(query, options.currentChannelId, searchAbort.signal)
options
.onSearch(query, options.currentChannelId, searchAbort.signal)
.then((items) => {
results = items;
activeIndex = 0;
@@ -43,7 +43,16 @@ export interface SettingsOverlayOptions {
isAuthenticated?: boolean;
}
export type TabName = "Account" | "Appearance" | "Notifications" | "Text & Images" | "Accessibility" | "Voice & Audio" | "Keybinds" | "Advanced" | "Logs";
export type TabName =
| "Account"
| "Appearance"
| "Notifications"
| "Text & Images"
| "Accessibility"
| "Voice & Audio"
| "Keybinds"
| "Advanced"
| "Logs";
const TAB_ICONS: Record<TabName, IconName> = {
Account: "user",
@@ -92,8 +101,14 @@ export function applyStoredAppearance(): void {
"compact-mode",
loadPref<boolean>("compactMode", false),
);
document.documentElement.classList.toggle("reduced-motion", loadPref<boolean>("reducedMotion", false));
document.documentElement.classList.toggle("high-contrast", loadPref<boolean>("highContrast", false));
document.documentElement.classList.toggle(
"reduced-motion",
loadPref<boolean>("reducedMotion", false),
);
document.documentElement.classList.toggle(
"high-contrast",
loadPref<boolean>("highContrast", false),
);
document.documentElement.classList.toggle("large-font", loadPref<boolean>("largeFont", false));
syncOsMotionListener(loadPref<boolean>("syncOsMotion", false));
@@ -178,14 +193,26 @@ export function createSettingsOverlay(
// User profile section at top of sidebar
const user = authStore.getState().user;
const profileSection = createElement("div", { class: "settings-sidebar-profile" });
const avatarEl = createElement("div", { class: "settings-sidebar-avatar" },
(user?.username ?? "U").charAt(0).toUpperCase());
const avatarEl = createElement(
"div",
{ class: "settings-sidebar-avatar" },
(user?.username ?? "U").charAt(0).toUpperCase(),
);
const profileInfo = createElement("div", {});
const profileName = createElement("div", { class: "settings-sidebar-name" },
user?.username ?? "Unknown");
const editProfileLink = createElement("div", { class: "settings-sidebar-edit" }, "Edit Profile");
const profileName = createElement(
"div",
{ class: "settings-sidebar-name" },
user?.username ?? "Unknown",
);
const editProfileLink = createElement(
"div",
{ class: "settings-sidebar-edit" },
"Edit Profile",
);
if (authenticated) {
editProfileLink.addEventListener("click", () => setActiveTab("Account"), { signal: ac.signal });
editProfileLink.addEventListener("click", () => setActiveTab("Account"), {
signal: ac.signal,
});
} else {
editProfileLink.style.display = "none";
}
@@ -214,7 +241,16 @@ export function createSettingsOverlay(
const appSettingsCat = createElement("div", { class: "settings-cat" }, "App Settings");
sidebar.appendChild(appSettingsCat);
const appTabs: readonly TabName[] = ["Appearance", "Notifications", "Text & Images", "Accessibility", "Voice & Audio", "Keybinds", "Advanced", "Logs"];
const appTabs: readonly TabName[] = [
"Appearance",
"Notifications",
"Text & Images",
"Accessibility",
"Voice & Audio",
"Keybinds",
"Advanced",
"Logs",
];
for (const name of appTabs) {
const btn = createElement("button", {
class: `settings-nav-item${name === activeTab ? " active" : ""}`,
@@ -248,27 +284,39 @@ export function createSettingsOverlay(
const closeWrap = createElement("div", { class: "settings-close-wrap" });
const closeBtn = createElement("button", { class: "settings-close-btn" });
closeBtn.appendChild(createIcon("x", 18));
closeBtn.addEventListener("click", () => {
options.onClose();
}, { signal: ac.signal });
closeBtn.addEventListener(
"click",
() => {
options.onClose();
},
{ signal: ac.signal },
);
const escLabel = createElement("div", { class: "settings-esc-label" }, "ESC");
appendChildren(closeWrap, closeBtn, escLabel);
// Escape key
document.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Escape" && root?.classList.contains("open")) {
options.onClose();
}
}, { signal: ac.signal });
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && root?.classList.contains("open")) {
options.onClose();
}
},
{ signal: ac.signal },
);
// Inner panel (Discord-style centered card)
const panel = createElement("div", { class: "settings-panel" });
appendChildren(panel, sidebar, contentArea, closeWrap);
// Click backdrop (outside panel) to close
root.addEventListener("click", (e: MouseEvent) => {
if (e.target === root) options.onClose();
}, { signal: ac.signal });
root.addEventListener(
"click",
(e: MouseEvent) => {
if (e.target === root) options.onClose();
},
{ signal: ac.signal },
);
root.appendChild(panel);
renderActiveTab();
@@ -93,6 +93,7 @@ export function createToastContainer(): ToastContainer {
}
function clear(): void {
// oxlint-disable-next-line no-useless-spread -- snapshot needed: removeToast splices the array during iteration
for (const entry of [...toasts]) {
removeToast(entry);
}
@@ -25,9 +25,7 @@ function formatTypingText(users: readonly Member[]): string {
return "Several people are typing...";
}
export function createTypingIndicator(
options: TypingIndicatorOptions,
): MountableComponent {
export function createTypingIndicator(options: TypingIndicatorOptions): MountableComponent {
const disposable = new Disposable();
let root: HTMLDivElement | null = null;
@@ -65,7 +63,9 @@ export function createTypingIndicator(
disposable.onStoreChange(
membersStore,
(s) => s.typingUsers,
() => { updateFromState(); },
() => {
updateFromState();
},
);
container.appendChild(root);
@@ -32,17 +32,26 @@ export function createUpdateNotifier(options: UpdateNotifierOptions): MountableC
banner = createElement("div", { class: "update-banner" });
const text = createElement("span", { class: "update-banner-text" },
`Update v${version} available`);
const text = createElement(
"span",
{ class: "update-banner-text" },
`Update v${version} available`,
);
const updateBtn = createElement("button", { class: "update-banner-btn update-banner-install" },
"Update Now");
const updateBtn = createElement(
"button",
{ class: "update-banner-btn update-banner-install" },
"Update Now",
);
updateBtn.addEventListener("click", () => {
void installUpdate();
});
const laterBtn = createElement("button", { class: "update-banner-btn update-banner-later" },
"Later");
const laterBtn = createElement(
"button",
{ class: "update-banner-btn update-banner-later" },
"Later",
);
laterBtn.addEventListener("click", () => {
dismissed = true;
removeBanner();
@@ -57,8 +66,11 @@ export function createUpdateNotifier(options: UpdateNotifierOptions): MountableC
// Replace banner content with progress indicator
while (banner.firstChild) banner.removeChild(banner.firstChild);
const progress = createElement("span", { class: "update-banner-text" },
"Downloading update...");
const progress = createElement(
"span",
{ class: "update-banner-text" },
"Downloading update...",
);
banner.appendChild(progress);
try {
@@ -67,10 +79,16 @@ export function createUpdateNotifier(options: UpdateNotifierOptions): MountableC
} catch (err) {
log.error("Update install failed", { error: String(err) });
while (banner.firstChild) banner.removeChild(banner.firstChild);
const errorText = createElement("span", { class: "update-banner-text" },
"Update failed. Please try again later.");
const dismissBtn = createElement("button", { class: "update-banner-btn update-banner-later" },
"Dismiss");
const errorText = createElement(
"span",
{ class: "update-banner-text" },
"Update failed. Please try again later.",
);
const dismissBtn = createElement(
"button",
{ class: "update-banner-btn update-banner-later" },
"Dismiss",
);
dismissBtn.addEventListener("click", () => {
dismissed = true;
removeBanner();
@@ -89,7 +107,9 @@ export function createUpdateNotifier(options: UpdateNotifierOptions): MountableC
function mount(target: Element): void {
container = target;
// Delay the check slightly so the main UI renders first
setTimeout(() => { void performCheck(); }, 3000);
setTimeout(() => {
void performCheck();
}, 3000);
}
function destroy(): void {
@@ -26,9 +26,13 @@ export interface VideoGridComponent extends MountableComponent {
}
/** Create a fresh volume icon element. */
function volumeIcon(): SVGSVGElement { return createIcon("volume-2", 16); }
function volumeIcon(): SVGSVGElement {
return createIcon("volume-2", 16);
}
/** Create a fresh volume-x (muted) icon element. */
function volumeXIcon(): SVGSVGElement { return createIcon("volume-x", 16); }
function volumeXIcon(): SVGSVGElement {
return createIcon("volume-x", 16);
}
/** Replace a button's icon child with a new one. */
function setButtonIcon(btn: HTMLButtonElement, icon: SVGSVGElement): void {
while (btn.firstChild) btn.removeChild(btn.firstChild);
@@ -191,7 +195,12 @@ export function createVideoGrid(): VideoGridComponent {
applyGridSizes();
}
function addStream(userId: number, username: string, stream: MediaStream, config?: TileConfig): void {
function addStream(
userId: number,
username: string,
stream: MediaStream,
config?: TileConfig,
): void {
if (root === null) return;
// If a cell already exists for this user, update it in place
@@ -358,7 +367,9 @@ export function createVideoGrid(): VideoGridComponent {
container.appendChild(root);
// Observe container size changes to recalculate tile layout
resizeObserver = new ResizeObserver(() => { scheduleResize(); });
resizeObserver = new ResizeObserver(() => {
scheduleResize();
});
resizeObserver.observe(root);
}
@@ -384,5 +395,13 @@ export function createVideoGrid(): VideoGridComponent {
}
}
return { mount, destroy, addStream, removeStream, hasStreams, setFocusedTile, getFocusedTileId: getFocusedTileIdFn };
return {
mount,
destroy,
addStream,
removeStream,
hasStreams,
setFocusedTile,
getFocusedTileId: getFocusedTileIdFn,
};
}
@@ -77,10 +77,14 @@ export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelRe
const menu = createElement("div", { class: "context-menu" });
// Header
const header = createElement("div", {
class: "context-menu-item",
style: "font-weight:600;cursor:default;pointer-events:none",
}, username);
const header = createElement(
"div",
{
class: "context-menu-item",
style: "font-weight:600;cursor:default;pointer-events:none",
},
username,
);
menu.appendChild(header);
const sep = createElement("div", { class: "context-menu-sep" });
@@ -88,10 +92,14 @@ export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelRe
// Volume label
const currentVol = getUserVolume(userId);
const volLabel = createElement("div", {
class: "context-menu-item",
style: "font-size:12px;color:var(--text-muted);cursor:default;pointer-events:none",
}, `User Volume: ${currentVol}%`);
const volLabel = createElement(
"div",
{
class: "context-menu-item",
style: "font-size:12px;color:var(--text-muted);cursor:default;pointer-events:none",
},
`User Volume: ${currentVol}%`,
);
menu.appendChild(volLabel);
// Volume slider (0-200%, like Discord)
@@ -106,10 +114,14 @@ export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelRe
value: String(currentVol),
style: "flex:1",
});
const valLabel = createElement("span", {
class: "slider-val",
style: "min-width:40px;text-align:right;font-size:12px;color:var(--text-muted)",
}, `${currentVol}%`);
const valLabel = createElement(
"span",
{
class: "slider-val",
style: "min-width:40px;text-align:right;font-size:12px;color:var(--text-muted)",
},
`${currentVol}%`,
);
slider.addEventListener("input", () => {
const val = Number(slider.value);
@@ -142,18 +154,20 @@ export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelRe
const dismissSignal = menuDismissAc.signal;
setTimeout(() => {
if (dismissSignal.aborted) return;
document.addEventListener("mousedown", (e: MouseEvent) => {
if (!menu.contains(e.target as Node)) {
closeContextMenu();
}
}, { signal: dismissSignal });
document.addEventListener(
"mousedown",
(e: MouseEvent) => {
if (!menu.contains(e.target as Node)) {
closeContextMenu();
}
},
{ signal: dismissSignal },
);
}, 0);
}
function createUserRow(user: VoiceUser, username: string): HTMLDivElement {
const classes = user.speaking
? "voice-user-item speaking"
: "voice-user-item";
const classes = user.speaking ? "voice-user-item speaking" : "voice-user-item";
const row = createElement("div", { class: classes });
const initial = username.length > 0 ? username.charAt(0).toUpperCase() : "?";
@@ -180,11 +194,15 @@ export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelRe
// Right-click for per-user volume (skip for own user)
const currentUser = authStore.getState().user;
if (currentUser === null || currentUser.id !== user.userId) {
row.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
showVolumeMenu(user.userId, username, e.clientX, e.clientY);
}, { signal: ac.signal });
row.addEventListener(
"contextmenu",
(e) => {
e.preventDefault();
e.stopPropagation();
showVolumeMenu(user.userId, username, e.clientX, e.clientY);
},
{ signal: ac.signal },
);
}
return row;
@@ -227,8 +245,18 @@ export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelRe
// Initial render and subscribe
update();
unsubs.push(voiceStore.subscribeSelector((s) => s.voiceUsers, () => update()));
unsubs.push(membersStore.subscribeSelector((s) => s.members, () => update()));
unsubs.push(
voiceStore.subscribeSelector(
(s) => s.voiceUsers,
() => update(),
),
);
unsubs.push(
membersStore.subscribeSelector(
(s) => s.members,
() => update(),
),
);
function destroy(): void {
closeContextMenu();
@@ -32,97 +32,105 @@ export function ensureGlobalDragListeners(): void {
}
globalDragAc = new AbortController();
document.addEventListener("mousemove", (e) => {
if (activeDrag === null) {
return;
}
// Clear old indicators
activeDrag.containerEl.querySelectorAll(".channel-drop-indicator").forEach((x) => {
x.classList.remove("channel-drop-indicator");
});
document.addEventListener(
"mousemove",
(e) => {
if (activeDrag === null) {
return;
}
// Clear old indicators
activeDrag.containerEl.querySelectorAll(".channel-drop-indicator").forEach((x) => {
x.classList.remove("channel-drop-indicator");
});
// Find which channel item we're hovering over
const items = activeDrag.containerEl.querySelectorAll("[data-drag-channel-id]");
for (const item of items) {
const rect = item.getBoundingClientRect();
if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
const targetId = Number((item as HTMLElement).dataset.dragChannelId);
if (targetId !== activeDrag.channelId) {
item.classList.add("channel-drop-indicator");
// Find which channel item we're hovering over
const items = activeDrag.containerEl.querySelectorAll("[data-drag-channel-id]");
for (const item of items) {
const rect = item.getBoundingClientRect();
if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
const targetId = Number((item as HTMLElement).dataset.dragChannelId);
if (targetId !== activeDrag.channelId) {
item.classList.add("channel-drop-indicator");
}
break;
}
break;
}
}
}, { signal: globalDragAc.signal });
},
{ signal: globalDragAc.signal },
);
document.addEventListener("mouseup", (e) => {
if (activeDrag === null) {
return;
}
const drag = activeDrag;
activeDrag = null;
// Clean up visual state
drag.sourceEl.classList.remove("dragging");
document.body.classList.remove("channel-reordering");
drag.containerEl.querySelectorAll(".channel-drop-indicator").forEach((x) => {
x.classList.remove("channel-drop-indicator");
});
// Find drop target
const items = drag.containerEl.querySelectorAll("[data-drag-channel-id]");
let dropTargetId: number | null = null;
let dropBefore = false;
for (const item of items) {
const rect = item.getBoundingClientRect();
if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
dropTargetId = Number((item as HTMLElement).dataset.dragChannelId);
dropBefore = e.clientY < rect.top + rect.height / 2;
break;
document.addEventListener(
"mouseup",
(e) => {
if (activeDrag === null) {
return;
}
}
const drag = activeDrag;
activeDrag = null;
if (dropTargetId === null || dropTargetId === drag.channelId) {
return;
}
// Clean up visual state
drag.sourceEl.classList.remove("dragging");
document.body.classList.remove("channel-reordering");
drag.containerEl.querySelectorAll(".channel-drop-indicator").forEach((x) => {
x.classList.remove("channel-drop-indicator");
});
// Compute new order
const orderedIds = drag.channels.map((ch) => ch.id);
const dragIdx = orderedIds.indexOf(drag.channelId);
if (dragIdx === -1) {
return;
}
const withoutDrag = orderedIds.filter((id) => id !== drag.channelId);
const targetIdx = withoutDrag.indexOf(dropTargetId);
if (targetIdx === -1) {
return;
}
const insertIdx = dropBefore ? targetIdx : targetIdx + 1;
const reorderedIds = [
...withoutDrag.slice(0, insertIdx),
drag.channelId,
...withoutDrag.slice(insertIdx),
];
// Build reorder data and update store immediately
const reorders: ChannelReorderData[] = [];
for (let i = 0; i < reorderedIds.length; i++) {
const id = reorderedIds[i];
if (id === undefined) {
continue;
// Find drop target
const items = drag.containerEl.querySelectorAll("[data-drag-channel-id]");
let dropTargetId: number | null = null;
let dropBefore = false;
for (const item of items) {
const rect = item.getBoundingClientRect();
if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
dropTargetId = Number((item as HTMLElement).dataset.dragChannelId);
dropBefore = e.clientY < rect.top + rect.height / 2;
break;
}
}
const ch = drag.channels.find((c) => c.id === id);
if (ch !== undefined && ch.position !== i) {
reorders.push({ channelId: id, newPosition: i });
updateChannelPosition(id, i);
}
}
if (reorders.length > 0) {
drag.onReorder(reorders);
}
}, { signal: globalDragAc.signal });
if (dropTargetId === null || dropTargetId === drag.channelId) {
return;
}
// Compute new order
const orderedIds = drag.channels.map((ch) => ch.id);
const dragIdx = orderedIds.indexOf(drag.channelId);
if (dragIdx === -1) {
return;
}
const withoutDrag = orderedIds.filter((id) => id !== drag.channelId);
const targetIdx = withoutDrag.indexOf(dropTargetId);
if (targetIdx === -1) {
return;
}
const insertIdx = dropBefore ? targetIdx : targetIdx + 1;
const reorderedIds = [
...withoutDrag.slice(0, insertIdx),
drag.channelId,
...withoutDrag.slice(insertIdx),
];
// Build reorder data and update store immediately
const reorders: ChannelReorderData[] = [];
for (let i = 0; i < reorderedIds.length; i++) {
const id = reorderedIds[i];
if (id === undefined) {
continue;
}
const ch = drag.channels.find((c) => c.id === id);
if (ch !== undefined && ch.position !== i) {
reorders.push({ channelId: id, newPosition: i });
updateChannelPosition(id, i);
}
}
if (reorders.length > 0) {
drag.onReorder(reorders);
}
},
{ signal: globalDragAc.signal },
);
}
/** Make a channel element draggable via mousedown (admin/owner only). */
@@ -22,20 +22,28 @@ export function showUserVolumeMenu(
const menu = createElement("div", { class: "context-menu user-vol-menu" });
const header = createElement("div", {
class: "context-menu-item",
style: "font-weight:600;cursor:default;pointer-events:none",
}, username);
const header = createElement(
"div",
{
class: "context-menu-item",
style: "font-weight:600;cursor:default;pointer-events:none",
},
username,
);
menu.appendChild(header);
const sep = createElement("div", { class: "context-menu-sep" });
menu.appendChild(sep);
const currentVol = getUserVolume(userId);
const volLabel = createElement("div", {
class: "context-menu-item",
style: "font-size:12px;color:var(--text-muted);cursor:default;pointer-events:none",
}, `User Volume: ${currentVol}%`);
const volLabel = createElement(
"div",
{
class: "context-menu-item",
style: "font-size:12px;color:var(--text-muted);cursor:default;pointer-events:none",
},
`User Volume: ${currentVol}%`,
);
menu.appendChild(volLabel);
const sliderRow = createElement("div", {
@@ -49,10 +57,14 @@ export function showUserVolumeMenu(
value: String(currentVol),
style: "flex:1",
});
const valLabel = createElement("span", {
class: "slider-val",
style: "min-width:40px;text-align:right;font-size:12px;color:var(--text-muted)",
}, `${currentVol}%`);
const valLabel = createElement(
"span",
{
class: "slider-val",
style: "min-width:40px;text-align:right;font-size:12px;color:var(--text-muted)",
},
`${currentVol}%`,
);
slider.addEventListener("input", () => {
const val = Number(slider.value);
@@ -82,12 +94,16 @@ export function showUserVolumeMenu(
(menu as HTMLElement & { _dismissAc?: AbortController })._dismissAc = dismissAc;
setTimeout(() => {
if (dismissAc.signal.aborted) return;
document.addEventListener("mousedown", (e: MouseEvent) => {
if (!menu.contains(e.target as Node)) {
menu.remove();
dismissAc.abort();
}
}, { signal: dismissAc.signal });
document.addEventListener(
"mousedown",
(e: MouseEvent) => {
if (!menu.contains(e.target as Node)) {
menu.remove();
dismissAc.abort();
}
},
{ signal: dismissAc.signal },
);
}, 0);
// Also clean up if the parent component is destroyed
@@ -53,14 +53,16 @@ export function buildPreviewItem(
alt: file.name,
});
item.appendChild(img);
readFileAsDataUrl(file).then((dataUrl) => {
if (signal.aborted) return;
img.src = dataUrl;
}).catch(() => {
if (signal.aborted) return;
const nameEl = createElement("span", { class: "attachment-preview-name" }, file.name);
img.replaceWith(nameEl);
});
readFileAsDataUrl(file)
.then((dataUrl) => {
if (signal.aborted) return;
img.src = dataUrl;
})
.catch(() => {
if (signal.aborted) return;
const nameEl = createElement("span", { class: "attachment-preview-name" }, file.name);
img.replaceWith(nameEl);
});
} else {
const icon = createElement("div", { class: "attachment-preview-file" });
icon.appendChild(createIcon("file-text", 16));
@@ -78,10 +80,14 @@ export function buildPreviewItem(
"data-testid": "attachment-remove",
});
removeBtn.appendChild(createIcon("x", 14));
removeBtn.addEventListener("click", (e) => {
e.stopPropagation();
onRemove();
}, { signal });
removeBtn.addEventListener(
"click",
(e) => {
e.stopPropagation();
onRemove();
},
{ signal },
);
item.appendChild(removeBtn);
return item;
@@ -33,7 +33,11 @@ export function createPickerToggle(opts: PickerToggleOptions): PickerToggleHandl
function handleClickOutside(e: MouseEvent): void {
if (instance === null) return;
const target = e.target as Node;
if (!instance.element.contains(target) && target !== opts.triggerEl && !opts.triggerEl.contains(target)) {
if (
!instance.element.contains(target) &&
target !== opts.triggerEl &&
!opts.triggerEl.contains(target)
) {
close();
}
}
@@ -3,10 +3,7 @@
* Also owns the server host state and URL resolution used by other modules.
*/
import {
createElement,
appendChildren,
} from "@lib/dom";
import { createElement, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { observeMedia } from "@lib/media-visibility";
import { loadPref } from "@components/settings/helpers";
@@ -81,9 +78,18 @@ export function clearAttachmentCaches(): void {
// loaded in <object>, <embed>, or <iframe> contexts. Only raster formats
// are considered safe for data: URI rendering via <img>.
const SAFE_MIME_TYPES = new Set([
"image/png", "image/jpeg", "image/gif", "image/webp",
"image/avif", "image/bmp", "video/mp4", "video/webm", "audio/mpeg",
"audio/ogg", "audio/wav", "application/pdf",
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/avif",
"image/bmp",
"video/mp4",
"video/webm",
"audio/mpeg",
"audio/ogg",
"audio/wav",
"application/pdf",
]);
/** Sanitize a Content-Type header value for use in a data: URI. */
@@ -221,7 +227,7 @@ export function fetchImageAsDataUrl(url: string): Promise<string | null> {
try {
const useInsecure = isServerUrl(url);
const fetchOpts: RequestInit = useInsecure
? { danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false } } as RequestInit
? ({ danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false } } as RequestInit)
: {};
const res = await tauriFetch(url, fetchOpts);
if (!res.ok) return null;
@@ -270,7 +276,8 @@ export function renderAttachment(att: Attachment): HTMLDivElement {
// Reserve space using server-provided dimensions to prevent layout shift.
if (att.width != null && att.height != null && att.width > 0 && att.height > 0) {
const maxW = 400, maxH = 350;
const maxW = 400,
maxH = 350;
const scale = Math.min(1, maxW / att.width, maxH / att.height);
const w = Math.round(att.width * scale);
const h = Math.round(att.height * scale);
@@ -310,10 +317,14 @@ export function renderAttachment(att: Attachment): HTMLDivElement {
alt: att.filename,
});
attachLightbox(img);
img.addEventListener("load", () => {
clearReservation();
if (isGif) observeMedia(img, cached, wrap, !loadPref("animateGifs", true));
}, { once: true });
img.addEventListener(
"load",
() => {
clearReservation();
if (isGif) observeMedia(img, cached, wrap, !loadPref("animateGifs", true));
},
{ once: true },
);
wrap.appendChild(img);
} else {
// Show loading placeholder, then replace with image
@@ -327,10 +338,14 @@ export function renderAttachment(att: Attachment): HTMLDivElement {
alt: att.filename,
});
attachLightbox(img);
img.addEventListener("load", () => {
clearReservation();
if (isGif) observeMedia(img, dataUrl, wrap, !loadPref("animateGifs", true));
}, { once: true });
img.addEventListener(
"load",
() => {
clearReservation();
if (isGif) observeMedia(img, dataUrl, wrap, !loadPref("animateGifs", true));
},
{ once: true },
);
placeholder.replaceWith(img);
} else {
placeholder.classList.remove("loading");
@@ -377,7 +392,7 @@ async function downloadFile(url: string, filename: string): Promise<void> {
// Fetch file data — only accept invalid certs for the OwnCord server
const useInsecure = isServerUrl(url);
const fetchOpts: RequestInit = useInsecure
? { danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false } } as RequestInit
? ({ danger: { acceptInvalidCerts: true, acceptInvalidHostnames: false } } as RequestInit)
: {};
const res = await tauriFetch(url, fetchOpts);
if (!res.ok) {
@@ -3,10 +3,7 @@
* inline code, code blocks, @mentions, and URL linkification.
*/
import {
createElement,
setText,
} from "@lib/dom";
import { createElement, setText } from "@lib/dom";
import { isSafeUrl } from "./attachments";
// -- Regex constants ----------------------------------------------------------
@@ -108,7 +105,7 @@ export function renderMessageContent(content: string): DocumentFragment {
const segment = parts[i]!;
if (i % 2 === 0) {
// Prose segment
const trimmed = i === 0 ? segment : (i === parts.length - 1 ? segment.trim() : segment);
const trimmed = i === 0 ? segment : i === parts.length - 1 ? segment.trim() : segment;
if (trimmed.length > 0) {
const text = createElement("div", { class: "msg-text" });
text.appendChild(renderInlineContent(trimmed));
@@ -123,13 +120,16 @@ export function renderMessageContent(content: string): DocumentFragment {
const copyBtn = createElement("button", { class: "msg-codeblock-copy" });
setText(copyBtn, "Copy");
copyBtn.addEventListener("click", () => {
void navigator.clipboard.writeText(codeContent).then(() => {
setText(copyBtn, "Copied!");
setTimeout(() => setText(copyBtn, "Copy"), 2000);
}).catch(() => {
setText(copyBtn, "Failed");
setTimeout(() => setText(copyBtn, "Copy"), 2000);
});
void navigator.clipboard
.writeText(codeContent)
.then(() => {
setText(copyBtn, "Copied!");
setTimeout(() => setText(copyBtn, "Copy"), 2000);
})
.catch(() => {
setText(copyBtn, "Failed");
setTimeout(() => setText(copyBtn, "Copy"), 2000);
});
});
codeWrap.appendChild(codeBlock);
codeWrap.appendChild(copyBtn);
@@ -3,10 +3,7 @@
* (title, description, image) for generic URLs as compact link cards.
*/
import {
createElement,
setText,
} from "@lib/dom";
import { createElement, setText } from "@lib/dom";
import { observeMedia } from "@lib/media-visibility";
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { createLogger } from "@lib/logger";
@@ -52,7 +49,7 @@ export function parseOgTags(html: string): OgMeta {
const escaped = escapeRegex(property);
const regex = new RegExp(
`<meta[^>]*(?:property|name)=["']${escaped}["'][^>]*content=["']([^"']*)["']` +
`|<meta[^>]*content=["']([^"']*)["'][^>]*(?:property|name)=["']${escaped}["']`,
`|<meta[^>]*content=["']([^"']*)["'][^>]*(?:property|name)=["']${escaped}["']`,
"i",
);
const match = html.match(regex);
@@ -183,10 +180,16 @@ function fetchOgMeta(url: string): Promise<OgMeta> {
const timer = setTimeout(() => controller.abort(), 5000);
const fetchOpts: RequestInit = {
signal: controller.signal,
headers: { "User-Agent": "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)" },
headers: {
"User-Agent": "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)",
},
};
if (isTrustedServerUrl(url)) {
(fetchOpts as RequestInit & { danger?: { acceptInvalidCerts: boolean; acceptInvalidHostnames: boolean } }).danger = { acceptInvalidCerts: true, acceptInvalidHostnames: false };
(
fetchOpts as RequestInit & {
danger?: { acceptInvalidCerts: boolean; acceptInvalidHostnames: boolean };
}
).danger = { acceptInvalidCerts: true, acceptInvalidHostnames: false };
}
const res = await tauriFetch(url, fetchOpts);
clearTimeout(timer);
@@ -301,9 +304,8 @@ export function applyOgMeta(
setText(hostEl, meta.siteName);
}
if (meta.description !== null) {
const desc = meta.description.length > 200
? meta.description.slice(0, 197) + "..."
: meta.description;
const desc =
meta.description.length > 200 ? meta.description.slice(0, 197) + "..." : meta.description;
setText(descEl, desc);
descEl.style.display = "";
} else {
@@ -316,7 +318,9 @@ export function applyOgMeta(
try {
const base = new URL(url);
imgSrc = `${base.origin}${imgSrc}`;
} catch { /* keep as-is */ }
} catch {
/* keep as-is */
}
}
if (isSafeUrl(imgSrc) && !isBlockedForPreview(imgSrc)) {
const isGif = imgSrc.toLowerCase().endsWith(".gif");
@@ -334,9 +338,13 @@ export function applyOgMeta(
imageWrap.style.display = "none";
});
if (isGif) {
(img).addEventListener("load", () => {
observeMedia(img, imgSrc, imageWrap);
}, { once: true });
img.addEventListener(
"load",
() => {
observeMedia(img, imgSrc, imageWrap);
},
{ once: true },
);
}
imageWrap.appendChild(img);
imageWrap.style.display = "";
@@ -19,8 +19,8 @@ export class FenwickTree {
const delta = value - prev;
if (delta === 0) return;
this.values[i] = value;
for (let x = i + 1; x <= this.size; x += x & (-x)) {
(this.tree)[x] = (this.tree[x] as number) + delta;
for (let x = i + 1; x <= this.size; x += x & -x) {
this.tree[x] = (this.tree[x] as number) + delta;
}
}
@@ -33,7 +33,7 @@ export class FenwickTree {
prefixSum(i: number): number {
if (i < 0) return 0;
let s = 0;
for (let x = i + 1; x > 0; x -= x & (-x)) {
for (let x = i + 1; x > 0; x -= x & -x) {
s += this.tree[x] as number;
}
return s;
@@ -101,9 +101,13 @@ export function roleColorVar(role: string): string {
return "var(--role-member)";
}
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)";
case "owner":
return "var(--role-owner)";
case "admin":
return "var(--role-admin)";
case "moderator":
return "var(--role-mod)";
default:
return "var(--role-member)";
}
}
@@ -3,11 +3,7 @@
* inline image rendering, lightbox overlay, and URL embed orchestration.
*/
import {
createElement,
setText,
appendChildren,
} from "@lib/dom";
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { createLogger } from "@lib/logger";
import { observeMedia } from "@lib/media-visibility";
@@ -106,7 +102,11 @@ export function renderYouTubeEmbed(videoId: string, originalUrl: string): HTMLDi
// Validate videoId to prevent injection into iframe src / img src.
if (!YOUTUBE_ID_RE.test(videoId)) {
const fallback = createElement("div", { class: "msg-embed" });
const link = createElement("a", { href: originalUrl, target: "_blank", rel: "noopener noreferrer" });
const link = createElement("a", {
href: originalUrl,
target: "_blank",
rel: "noopener noreferrer",
});
setText(link, originalUrl);
fallback.appendChild(link);
return fallback;
@@ -181,15 +181,22 @@ export function renderYouTubeEmbed(videoId: string, originalUrl: string): HTMLDi
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.setAttribute("sandbox", "allow-scripts allow-same-origin allow-presentation allow-popups");
iframe.className = "msg-embed-iframe";
thumbWrap.replaceChildren(iframe);
}, { once: true });
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.setAttribute(
"sandbox",
"allow-scripts allow-same-origin allow-presentation allow-popups",
);
iframe.className = "msg-embed-iframe";
thumbWrap.replaceChildren(iframe);
},
{ once: true },
);
return wrap;
}
@@ -221,7 +228,8 @@ export function renderInlineImage(url: string): HTMLDivElement {
const attrs: Record<string, string> = {
src: url,
alt: "Image",
style: "max-width: 100%; max-height: 350px; display: block; border-radius: 4px; cursor: pointer;",
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)) {
@@ -233,31 +241,47 @@ export function renderInlineImage(url: string): HTMLDivElement {
// height so future virtual-scroll rebuilds start at the correct size.
// Measure synchronously — deferring to rAF loses the race with
// ResizeObserver which can rebuild the DOM before the rAF fires.
img.addEventListener("load", () => {
log.info("Image loaded", { url: url.slice(0, 80), naturalW: (img).naturalWidth, naturalH: (img).naturalHeight });
wrap.style.minHeight = "";
const h = wrap.offsetHeight;
if (h > 0) cacheImageHeight(url, h);
log.debug("Image height cached", { url: url.slice(0, 80), h });
}, { once: true });
img.addEventListener(
"load",
() => {
log.info("Image loaded", {
url: url.slice(0, 80),
naturalW: img.naturalWidth,
naturalH: img.naturalHeight,
});
wrap.style.minHeight = "";
const h = wrap.offsetHeight;
if (h > 0) cacheImageHeight(url, h);
log.debug("Image height cached", { url: url.slice(0, 80), h });
},
{ once: true },
);
// On error: clear min-height so the wrapper collapses instead of
// holding a 200px empty reservation that can oscillate with virtual scroll.
img.addEventListener("error", () => {
log.error("Image failed to load", { url });
wrap.style.minHeight = "";
}, { once: true });
img.addEventListener(
"error",
() => {
log.error("Image failed to load", { url });
wrap.style.minHeight = "";
},
{ once: true },
);
// Observe GIFs for visibility-based freeze/unfreeze + play/pause button.
// When the animateGifs pref is disabled, start frozen so the first frame is
// shown by default; the user can still click the play button to animate.
if (isGifUrl(url)) {
img.addEventListener("load", () => {
log.debug("Calling observeMedia for GIF", { url: url.slice(0, 80) });
const startFrozen = !loadPref("animateGifs", true);
observeMedia(img, url, wrap, startFrozen);
log.debug("observeMedia complete", { startFrozen });
}, { once: true });
img.addEventListener(
"load",
() => {
log.debug("Calling observeMedia for GIF", { url: url.slice(0, 80) });
const startFrozen = !loadPref("animateGifs", true);
observeMedia(img, url, wrap, startFrozen);
log.debug("observeMedia complete", { startFrozen });
},
{ once: true },
);
}
img.addEventListener("click", () => {
@@ -460,7 +484,12 @@ export function renderUrlEmbeds(content: string): DocumentFragment {
// Direct image/GIF URL — render inline
const isDirect = isDirectImageUrl(url);
const isSafe = isSafeUrl(url);
log.debug("URL classification", { url: url.slice(0, 80), isDirect, isSafe, isGif: isGifUrl(url) });
log.debug("URL classification", {
url: url.slice(0, 80),
isDirect,
isSafe,
isGif: isGifUrl(url),
});
if (isDirect && isSafe) {
if (!inlineMedia) continue;
fragment.appendChild(renderInlineImage(url));
@@ -4,11 +4,7 @@
* renderSystemMessage) that orchestrate pieces from the split modules.
*/
import {
createElement,
setText,
appendChildren,
} from "@lib/dom";
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { loadPref } from "@components/settings/helpers";
import type { Message } from "@stores/messages.store";
@@ -58,11 +54,7 @@ export {
} from "./media";
export type { OgMeta } from "./embeds";
export {
parseOgTags,
renderGenericLinkPreview,
applyOgMeta,
} from "./embeds";
export { parseOgTags, renderGenericLinkPreview, applyOgMeta } from "./embeds";
export {
formatFileSize,
@@ -100,19 +92,20 @@ export function renderDayDivider(iso: string): HTMLDivElement {
return divider;
}
function renderReplyRef(
replyToId: number,
allMessages: readonly Message[],
): HTMLDivElement {
function renderReplyRef(replyToId: number, allMessages: readonly Message[]): HTMLDivElement {
const ref = allMessages.find((m) => m.id === replyToId);
const bar = createElement("div", { class: "msg-reply-ref" });
if (ref) {
const preview = ref.deleted ? "[message deleted]" : ref.content.slice(0, 100);
const role = getUserRole(ref.user.id);
const miniAvatar = createElement("div", {
class: "rr-avatar",
style: `background: ${roleColorVar(role)}`,
}, ref.user.username.charAt(0).toUpperCase());
const miniAvatar = createElement(
"div",
{
class: "rr-avatar",
style: `background: ${roleColorVar(role)}`,
},
ref.user.username.charAt(0).toUpperCase(),
);
appendChildren(
bar,
miniAvatar,
@@ -154,17 +147,25 @@ export function renderMessage(
const role = getUserRole(msg.user.id);
const initial = msg.user.username.charAt(0).toUpperCase();
const avatar = createElement("div", {
class: "msg-avatar",
style: `background: ${roleColorVar(role)}`,
}, initial);
const avatar = createElement(
"div",
{
class: "msg-avatar",
style: `background: ${roleColorVar(role)}`,
},
initial,
);
el.appendChild(avatar);
if (isGrouped) {
const hoverTime = createElement("div", {
class: "msg-hover-time",
title: formatFullDate(msg.timestamp),
}, formatTime(msg.timestamp));
const hoverTime = createElement(
"div",
{
class: "msg-hover-time",
title: formatFullDate(msg.timestamp),
},
formatTime(msg.timestamp),
);
el.appendChild(hoverTime);
}
@@ -173,11 +174,19 @@ export function renderMessage(
}
const header = createElement("div", { class: "msg-header" });
const author = createElement("span", {
class: "msg-author",
style: `color: ${roleColorVar(role)}`,
}, msg.user.username);
const time = createElement("span", { class: "msg-time", title: formatFullDate(msg.timestamp) }, formatMessageTimestamp(msg.timestamp));
const author = createElement(
"span",
{
class: "msg-author",
style: `color: ${roleColorVar(role)}`,
},
msg.user.username,
);
const time = createElement(
"span",
{ class: "msg-time", title: formatFullDate(msg.timestamp) },
formatMessageTimestamp(msg.timestamp),
);
appendChildren(header, author, time);
el.appendChild(header);
@@ -235,11 +244,9 @@ export function renderMessage(
});
pinBtn.appendChild(createIcon(msg.pinned ? "pin-off" : "pin", 16));
pinBtn.title = msg.pinned ? "Unpin" : "Pin";
pinBtn.addEventListener(
"click",
() => opts.onPinClick(msg.id, msg.channelId, msg.pinned),
{ signal },
);
pinBtn.addEventListener("click", () => opts.onPinClick(msg.id, msg.channelId, msg.pinned), {
signal,
});
actionsBar.appendChild(pinBtn);
if (msg.user.id === opts.currentUserId) {
@@ -271,9 +278,15 @@ export function renderMessage(
});
copyIdBtn.appendChild(createIcon("hash", 16));
copyIdBtn.title = "Copy ID";
copyIdBtn.addEventListener("click", () => {
void navigator.clipboard.writeText(String(msg.id)).catch(() => { /* clipboard unavailable */ });
}, { signal });
copyIdBtn.addEventListener(
"click",
() => {
void navigator.clipboard.writeText(String(msg.id)).catch(() => {
/* clipboard unavailable */
});
},
{ signal },
);
actionsBar.appendChild(copyIdBtn);
}
@@ -44,7 +44,9 @@ const TOGGLES: ReadonlyArray<ToggleItem> = [
label: "Sync with OS",
desc: "Automatically enable reduced motion based on your OS accessibility settings",
fallback: false,
sideEffect: (nowOn) => { syncOsMotionListener(nowOn); },
sideEffect: (nowOn) => {
syncOsMotionListener(nowOn);
},
},
{
key: "largeFont",
@@ -32,7 +32,9 @@ function buildProfileCard(username: string): ProfileCardResult {
// Avatar overlapping the banner
const avatarWrap = createElement("div", { class: "account-avatar-wrap" });
const avatarLarge = createElement("div", { class: "account-avatar-large" },
const avatarLarge = createElement(
"div",
{ class: "account-avatar-large" },
username.charAt(0).toUpperCase(),
);
const statusDot = createElement("div", { class: "account-status-dot" });
@@ -71,54 +73,73 @@ function buildPasswordSection(
const wrapper = createElement("div", {});
const separator = createElement("div", { class: "settings-separator" });
const pwHeader = createElement("div", { class: "settings-section-title" }, "Password and Authentication");
const pwHeader = createElement(
"div",
{ class: "settings-section-title" },
"Password and Authentication",
);
const oldPw = createElement("input", {
class: "form-input", type: "password",
placeholder: "Old password", style: "margin-bottom:12px",
class: "form-input",
type: "password",
placeholder: "Old password",
style: "margin-bottom:12px",
});
const newPw = createElement("input", {
class: "form-input", type: "password",
placeholder: "New password", style: "margin-bottom:12px",
class: "form-input",
type: "password",
placeholder: "New password",
style: "margin-bottom:12px",
});
const confirmPw = createElement("input", {
class: "form-input", type: "password",
placeholder: "Confirm new password", style: "margin-bottom:12px",
class: "form-input",
type: "password",
placeholder: "Confirm new password",
style: "margin-bottom:12px",
});
const pwError = createElement("div", {
style: "color:var(--red);font-size:13px;margin-bottom:8px",
});
const pwError = createElement("div", { style: "color:var(--red);font-size:13px;margin-bottom:8px" });
const pwBtn = createElement("button", { class: "ac-btn" }, "Change Password");
let pwSuccessTimer: ReturnType<typeof setTimeout> | null = null;
pwBtn.addEventListener("click", () => {
const oldVal = oldPw.value;
const newVal = newPw.value;
const confirmVal = confirmPw.value;
pwBtn.addEventListener(
"click",
() => {
const oldVal = oldPw.value;
const newVal = newPw.value;
const confirmVal = confirmPw.value;
if (newVal.length < 8) {
setText(pwError, "New password must be at least 8 characters.");
return;
}
if (newVal !== confirmVal) {
setText(pwError, "Passwords do not match.");
return;
}
setText(pwError, "");
void options.onChangePassword(oldVal, newVal).then(() => {
oldPw.value = "";
newPw.value = "";
confirmPw.value = "";
if (pwSuccessTimer !== null) clearTimeout(pwSuccessTimer);
pwError.style.color = "var(--green)";
setText(pwError, "Password changed successfully.");
pwSuccessTimer = setTimeout(() => {
setText(pwError, "");
pwError.style.color = "var(--red)";
pwSuccessTimer = null;
}, 3000);
}).catch((err: unknown) => {
setText(pwError, err instanceof Error ? err.message : "Failed to change password.");
});
}, { signal });
if (newVal.length < 8) {
setText(pwError, "New password must be at least 8 characters.");
return;
}
if (newVal !== confirmVal) {
setText(pwError, "Passwords do not match.");
return;
}
setText(pwError, "");
void options
.onChangePassword(oldVal, newVal)
.then(() => {
oldPw.value = "";
newPw.value = "";
confirmPw.value = "";
if (pwSuccessTimer !== null) clearTimeout(pwSuccessTimer);
pwError.style.color = "var(--green)";
setText(pwError, "Password changed successfully.");
pwSuccessTimer = setTimeout(() => {
setText(pwError, "");
pwError.style.color = "var(--red)";
pwSuccessTimer = null;
}, 3000);
})
.catch((err: unknown) => {
setText(pwError, err instanceof Error ? err.message : "Failed to change password.");
});
},
{ signal },
);
appendChildren(wrapper, separator, pwHeader, oldPw, newPw, confirmPw, pwError, pwBtn);
return wrapper;
@@ -135,19 +156,29 @@ function buildTotpEnrollForm(
): HTMLDivElement {
const wrapper = createElement("div", {});
const description = createElement("div", {
style: "color:var(--text-muted);font-size:13px;margin-bottom:12px",
}, "Add an extra layer of security to your account.");
const description = createElement(
"div",
{
style: "color:var(--text-muted);font-size:13px;margin-bottom:12px",
},
"Add an extra layer of security to your account.",
);
const enableBtn = createElement("button", {
class: "ac-btn",
"data-testid": "totp-enable-btn",
}, "Enable 2FA");
const enableBtn = createElement(
"button",
{
class: "ac-btn",
"data-testid": "totp-enable-btn",
},
"Enable 2FA",
);
const formArea = createElement("div", { style: "display:none" });
const pwInput = createElement("input", {
class: "form-input", type: "password",
placeholder: "Enter your password", style: "margin-bottom:12px",
class: "form-input",
type: "password",
placeholder: "Enter your password",
style: "margin-bottom:12px",
"data-testid": "totp-password-input",
});
const errorEl = createElement("div", {
@@ -160,36 +191,47 @@ function buildTotpEnrollForm(
const enrollArea = createElement("div", { style: "display:none" });
enableBtn.addEventListener("click", () => {
enableBtn.style.display = "none";
formArea.style.display = "block";
pwInput.value = "";
setText(errorEl, "");
pwInput.focus();
}, { signal });
enableBtn.addEventListener(
"click",
() => {
enableBtn.style.display = "none";
formArea.style.display = "block";
pwInput.value = "";
setText(errorEl, "");
pwInput.focus();
},
{ signal },
);
submitBtn.addEventListener("click", () => {
const pw = pwInput.value;
if (pw.length === 0) {
setText(errorEl, "Password is required.");
return;
}
setText(errorEl, "");
submitBtn.disabled = true;
setText(submitBtn, "Requesting...");
submitBtn.addEventListener(
"click",
() => {
const pw = pwInput.value;
if (pw.length === 0) {
setText(errorEl, "Password is required.");
return;
}
setText(errorEl, "");
submitBtn.disabled = true;
setText(submitBtn, "Requesting...");
void options.onEnableTotp(pw).then((result) => {
formArea.style.display = "none";
buildTotpConfirmArea(enrollArea, options, pw, result, signal, onEnrolled);
enrollArea.style.display = "block";
submitBtn.disabled = false;
setText(submitBtn, "Submit");
}).catch((err: unknown) => {
setText(errorEl, err instanceof Error ? err.message : "Failed to enable 2FA.");
submitBtn.disabled = false;
setText(submitBtn, "Submit");
});
}, { signal });
void options
.onEnableTotp(pw)
.then((result) => {
formArea.style.display = "none";
buildTotpConfirmArea(enrollArea, options, pw, result, signal, onEnrolled);
enrollArea.style.display = "block";
submitBtn.disabled = false;
setText(submitBtn, "Submit");
})
.catch((err: unknown) => {
setText(errorEl, err instanceof Error ? err.message : "Failed to enable 2FA.");
submitBtn.disabled = false;
setText(submitBtn, "Submit");
});
},
{ signal },
);
appendChildren(wrapper, description, enableBtn, formArea, enrollArea);
return wrapper;
@@ -208,34 +250,54 @@ function buildTotpConfirmArea(
container.removeChild(container.firstChild);
}
const qrLabel = createElement("div", {
style: "color:var(--text-muted);font-size:13px;margin-bottom:8px",
}, "Scan this URI with your authenticator app, or copy it manually:");
const qrLabel = createElement(
"div",
{
style: "color:var(--text-muted);font-size:13px;margin-bottom:8px",
},
"Scan this URI with your authenticator app, or copy it manually:",
);
const qrUri = createElement("code", {
style: "display:block;background:var(--bg-active);padding:8px 12px;border-radius:6px;" +
"font-family:monospace;font-size:12px;word-break:break-all;margin-bottom:12px;" +
"color:var(--text-primary);user-select:all",
"data-testid": "totp-qr-uri",
}, result.qr_uri);
const qrUri = createElement(
"code",
{
style:
"display:block;background:var(--bg-active);padding:8px 12px;border-radius:6px;" +
"font-family:monospace;font-size:12px;word-break:break-all;margin-bottom:12px;" +
"color:var(--text-primary);user-select:all",
"data-testid": "totp-qr-uri",
},
result.qr_uri,
);
const elements: HTMLElement[] = [qrLabel, qrUri];
if (result.backup_codes.length > 0) {
const backupLabel = createElement("div", {
style: "color:var(--text-muted);font-size:13px;margin-bottom:8px",
}, "Save these backup codes in a safe place:");
const backupList = createElement("code", {
style: "display:block;background:var(--bg-active);padding:8px 12px;border-radius:6px;" +
"font-family:monospace;font-size:12px;white-space:pre-wrap;margin-bottom:12px;" +
"color:var(--text-primary);user-select:all",
}, result.backup_codes.join("\n"));
const backupLabel = createElement(
"div",
{
style: "color:var(--text-muted);font-size:13px;margin-bottom:8px",
},
"Save these backup codes in a safe place:",
);
const backupList = createElement(
"code",
{
style:
"display:block;background:var(--bg-active);padding:8px 12px;border-radius:6px;" +
"font-family:monospace;font-size:12px;white-space:pre-wrap;margin-bottom:12px;" +
"color:var(--text-primary);user-select:all",
},
result.backup_codes.join("\n"),
);
elements.push(backupLabel, backupList);
}
const codeInput = createElement("input", {
class: "form-input", type: "text",
placeholder: "6-digit code", maxlength: "6",
class: "form-input",
type: "text",
placeholder: "6-digit code",
maxlength: "6",
style: "margin-bottom:12px",
"data-testid": "totp-code-input",
});
@@ -245,29 +307,40 @@ function buildTotpConfirmArea(
"data-testid": "totp-error",
});
const confirmBtn = createElement("button", {
class: "ac-btn",
"data-testid": "totp-confirm-btn",
}, "Verify & Activate");
const confirmBtn = createElement(
"button",
{
class: "ac-btn",
"data-testid": "totp-confirm-btn",
},
"Verify & Activate",
);
confirmBtn.addEventListener("click", () => {
const code = codeInput.value.trim();
if (!/^\d{6}$/.test(code)) {
setText(confirmError, "Please enter a valid 6-digit code.");
return;
}
setText(confirmError, "");
confirmBtn.disabled = true;
setText(confirmBtn, "Verifying...");
confirmBtn.addEventListener(
"click",
() => {
const code = codeInput.value.trim();
if (!/^\d{6}$/.test(code)) {
setText(confirmError, "Please enter a valid 6-digit code.");
return;
}
setText(confirmError, "");
confirmBtn.disabled = true;
setText(confirmBtn, "Verifying...");
void options.onConfirmTotp(password, code).then(() => {
onEnrolled();
}).catch((err: unknown) => {
setText(confirmError, err instanceof Error ? err.message : "Invalid verification code.");
confirmBtn.disabled = false;
setText(confirmBtn, "Verify & Activate");
});
}, { signal });
void options
.onConfirmTotp(password, code)
.then(() => {
onEnrolled();
})
.catch((err: unknown) => {
setText(confirmError, err instanceof Error ? err.message : "Invalid verification code.");
confirmBtn.disabled = false;
setText(confirmBtn, "Verify & Activate");
});
},
{ signal },
);
elements.push(codeInput, confirmError, confirmBtn);
appendChildren(container, ...elements);
@@ -280,19 +353,29 @@ function buildTotpDisableView(
): HTMLDivElement {
const wrapper = createElement("div", {});
const description = createElement("div", {
style: "color:var(--text-muted);font-size:13px;margin-bottom:12px",
}, "Your account is protected with 2FA.");
const description = createElement(
"div",
{
style: "color:var(--text-muted);font-size:13px;margin-bottom:12px",
},
"Your account is protected with 2FA.",
);
const disableBtn = createElement("button", {
class: "ac-btn account-delete-btn",
"data-testid": "totp-disable-btn",
}, "Disable 2FA");
const disableBtn = createElement(
"button",
{
class: "ac-btn account-delete-btn",
"data-testid": "totp-disable-btn",
},
"Disable 2FA",
);
const confirmArea = createElement("div", { style: "display:none" });
const pwInput = createElement("input", {
class: "form-input", type: "password",
placeholder: "Enter your password", style: "margin-bottom:12px",
class: "form-input",
type: "password",
placeholder: "Enter your password",
style: "margin-bottom:12px",
"data-testid": "totp-password-input",
});
const errorEl = createElement("div", {
@@ -300,69 +383,95 @@ function buildTotpDisableView(
"data-testid": "totp-error",
});
const btnRow = createElement("div", { style: "display:flex;gap:8px" });
const confirmBtn = createElement("button", { class: "ac-btn account-delete-btn" }, "Confirm Disable");
const cancelBtn = createElement("button", {
class: "ac-btn", style: "background:var(--bg-active)",
}, "Cancel");
const confirmBtn = createElement(
"button",
{ class: "ac-btn account-delete-btn" },
"Confirm Disable",
);
const cancelBtn = createElement(
"button",
{
class: "ac-btn",
style: "background:var(--bg-active)",
},
"Cancel",
);
appendChildren(btnRow, confirmBtn, cancelBtn);
appendChildren(confirmArea, pwInput, errorEl, btnRow);
disableBtn.addEventListener("click", () => {
disableBtn.style.display = "none";
confirmArea.style.display = "block";
pwInput.value = "";
setText(errorEl, "");
pwInput.focus();
}, { signal });
disableBtn.addEventListener(
"click",
() => {
disableBtn.style.display = "none";
confirmArea.style.display = "block";
pwInput.value = "";
setText(errorEl, "");
pwInput.focus();
},
{ signal },
);
cancelBtn.addEventListener("click", () => {
confirmArea.style.display = "none";
disableBtn.style.display = "";
pwInput.value = "";
setText(errorEl, "");
}, { signal });
cancelBtn.addEventListener(
"click",
() => {
confirmArea.style.display = "none";
disableBtn.style.display = "";
pwInput.value = "";
setText(errorEl, "");
},
{ signal },
);
confirmBtn.addEventListener("click", () => {
const pw = pwInput.value;
if (pw.length === 0) {
setText(errorEl, "Password is required.");
return;
}
setText(errorEl, "");
confirmBtn.disabled = true;
setText(confirmBtn, "Disabling...");
confirmBtn.addEventListener(
"click",
() => {
const pw = pwInput.value;
if (pw.length === 0) {
setText(errorEl, "Password is required.");
return;
}
setText(errorEl, "");
confirmBtn.disabled = true;
setText(confirmBtn, "Disabling...");
void options.onDisableTotp(pw).then(() => {
onDisabled();
}).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : "Failed to disable 2FA.";
const is403Required = msg.toLowerCase().includes("required");
setText(errorEl, is403Required
? "2FA is required by this server and cannot be disabled"
: msg);
confirmBtn.disabled = false;
setText(confirmBtn, "Confirm Disable");
});
}, { signal });
void options
.onDisableTotp(pw)
.then(() => {
onDisabled();
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : "Failed to disable 2FA.";
const is403Required = msg.toLowerCase().includes("required");
setText(
errorEl,
is403Required ? "2FA is required by this server and cannot be disabled" : msg,
);
confirmBtn.disabled = false;
setText(confirmBtn, "Confirm Disable");
});
},
{ signal },
);
appendChildren(wrapper, description, disableBtn, confirmArea);
return wrapper;
}
function buildTotpSection(
options: SettingsOverlayOptions,
signal: AbortSignal,
): HTMLDivElement {
function buildTotpSection(options: SettingsOverlayOptions, signal: AbortSignal): HTMLDivElement {
const wrapper = createElement("div", { "data-testid": "totp-section" });
const separator = createElement("div", { class: "settings-separator" });
const headerRow = createElement("div", {
style: "display:flex;align-items:center;gap:8px;margin-bottom:4px",
});
const header = createElement("div", {
class: "settings-section-title",
style: "margin-bottom:0",
}, "Two-Factor Authentication");
const header = createElement(
"div",
{
class: "settings-section-title",
style: "margin-bottom:0",
},
"Two-Factor Authentication",
);
const statusBadge = createElement("span", {
"data-testid": "totp-status-badge",
@@ -415,16 +524,23 @@ interface StatusOption {
}
const STATUS_OPTIONS: readonly StatusOption[] = [
{ value: "online", label: "Online", description: "", color: "#3ba55d" },
{ value: "idle", label: "Idle", description: "You will appear as idle", color: "#faa61a" },
{ value: "dnd", label: "Do Not Disturb", description: "You will not receive desktop notifications", color: "#ed4245" },
{ value: "offline", label: "Offline", description: "You will appear offline but still have full access", color: "#747f8d" },
{ value: "online", label: "Online", description: "", color: "#3ba55d" },
{ value: "idle", label: "Idle", description: "You will appear as idle", color: "#faa61a" },
{
value: "dnd",
label: "Do Not Disturb",
description: "You will not receive desktop notifications",
color: "#ed4245",
},
{
value: "offline",
label: "Offline",
description: "You will appear offline but still have full access",
color: "#747f8d",
},
];
function buildStatusSelector(
options: SettingsOverlayOptions,
signal: AbortSignal,
): HTMLDivElement {
function buildStatusSelector(options: SettingsOverlayOptions, signal: AbortSignal): HTMLDivElement {
const wrapper = createElement("div", {});
const separator = createElement("div", { class: "settings-separator" });
const sectionTitle = createElement("div", { class: "settings-section-title" }, "Status");
@@ -467,12 +583,16 @@ function buildStatusSelector(
};
row.addEventListener("click", selectStatus, { signal });
row.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
selectStatus();
}
}, { signal });
row.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
selectStatus();
}
},
{ signal },
);
rowElements.set(opt.value, row);
optionsList.appendChild(row);
@@ -493,19 +613,31 @@ function buildDeleteAccountSection(
const wrapper = createElement("div", {});
const separator = createElement("div", { class: "settings-separator" });
const header = createElement("div", {
class: "settings-section-title",
style: "color:var(--red)",
}, "Danger Zone");
const header = createElement(
"div",
{
class: "settings-section-title",
style: "color:var(--red)",
},
"Danger Zone",
);
const description = createElement("div", {
style: "color:var(--text-muted);font-size:13px;margin-bottom:12px",
}, "Permanently delete your account and all associated data.");
const description = createElement(
"div",
{
style: "color:var(--text-muted);font-size:13px;margin-bottom:12px",
},
"Permanently delete your account and all associated data.",
);
const deleteBtn = createElement("button", {
class: "ac-btn account-delete-btn",
"data-testid": "delete-account-trigger",
}, "Delete Account");
const deleteBtn = createElement(
"button",
{
class: "ac-btn account-delete-btn",
"data-testid": "delete-account-trigger",
},
"Delete Account",
);
// Inline confirmation area (hidden by default)
const confirmArea = createElement("div", {
@@ -514,9 +646,13 @@ function buildDeleteAccountSection(
"data-testid": "delete-account-confirm-area",
});
const warningText = createElement("div", {
style: "color:var(--red);font-size:13px;margin-bottom:12px;line-height:1.4",
}, "This action is permanent and cannot be undone. All your data will be deleted. Enter your password to confirm.");
const warningText = createElement(
"div",
{
style: "color:var(--red);font-size:13px;margin-bottom:12px;line-height:1.4",
},
"This action is permanent and cannot be undone. All your data will be deleted. Enter your password to confirm.",
);
const passwordInput = createElement("input", {
class: "form-input",
@@ -532,54 +668,77 @@ function buildDeleteAccountSection(
});
const btnRow = createElement("div", { style: "display:flex;gap:8px" });
const confirmBtn = createElement("button", {
class: "ac-btn account-delete-btn",
"data-testid": "delete-account-confirm",
}, "Confirm Delete");
const cancelBtn = createElement("button", {
class: "ac-btn",
style: "background:var(--bg-active)",
}, "Cancel");
const confirmBtn = createElement(
"button",
{
class: "ac-btn account-delete-btn",
"data-testid": "delete-account-confirm",
},
"Confirm Delete",
);
const cancelBtn = createElement(
"button",
{
class: "ac-btn",
style: "background:var(--bg-active)",
},
"Cancel",
);
appendChildren(btnRow, confirmBtn, cancelBtn);
appendChildren(confirmArea, warningText, passwordInput, errorEl, btnRow);
// Show confirmation area
deleteBtn.addEventListener("click", () => {
deleteBtn.style.display = "none";
confirmArea.style.display = "block";
passwordInput.value = "";
setText(errorEl, "");
passwordInput.focus();
}, { signal });
deleteBtn.addEventListener(
"click",
() => {
deleteBtn.style.display = "none";
confirmArea.style.display = "block";
passwordInput.value = "";
setText(errorEl, "");
passwordInput.focus();
},
{ signal },
);
// Cancel — hide confirmation
cancelBtn.addEventListener("click", () => {
confirmArea.style.display = "none";
deleteBtn.style.display = "";
passwordInput.value = "";
setText(errorEl, "");
}, { signal });
cancelBtn.addEventListener(
"click",
() => {
confirmArea.style.display = "none";
deleteBtn.style.display = "";
passwordInput.value = "";
setText(errorEl, "");
},
{ signal },
);
// Confirm delete
confirmBtn.addEventListener("click", () => {
const pw = passwordInput.value;
if (pw.length === 0) {
setText(errorEl, "Password is required.");
return;
}
setText(errorEl, "");
confirmBtn.disabled = true;
setText(confirmBtn, "Deleting...");
confirmBtn.addEventListener(
"click",
() => {
const pw = passwordInput.value;
if (pw.length === 0) {
setText(errorEl, "Password is required.");
return;
}
setText(errorEl, "");
confirmBtn.disabled = true;
setText(confirmBtn, "Deleting...");
void options.onDeleteAccount(pw).then(() => {
// Success — cleanup is handled by the callback (clears auth, navigates away)
}).catch((err: unknown) => {
setText(errorEl, err instanceof Error ? err.message : "Failed to delete account.");
confirmBtn.disabled = false;
setText(confirmBtn, "Confirm Delete");
});
}, { signal });
void options
.onDeleteAccount(pw)
.then(() => {
// Success — cleanup is handled by the callback (clears auth, navigates away)
})
.catch((err: unknown) => {
setText(errorEl, err instanceof Error ? err.message : "Failed to delete account.");
confirmBtn.disabled = false;
setText(confirmBtn, "Confirm Delete");
});
},
{ signal },
);
appendChildren(wrapper, separator, header, description, deleteBtn, confirmArea);
return wrapper;
@@ -608,13 +767,26 @@ export function buildAccountTab(
section.appendChild(buildStatusSelector(options, signal));
// Inline edit form
const editForm = createElement("div", { class: "setting-row", style: "display:none;margin-bottom:16px" });
const editInput = createElement("input", { class: "form-input", type: "text", placeholder: "New username" });
const editForm = createElement("div", {
class: "setting-row",
style: "display:none;margin-bottom:16px",
});
const editInput = createElement("input", {
class: "form-input",
type: "text",
placeholder: "New username",
});
const saveBtn = createElement("button", { class: "ac-btn" }, "Save");
const cancelBtn = createElement("button", { class: "ac-btn", style: "background:var(--bg-active)" }, "Cancel");
const cancelBtn = createElement(
"button",
{ class: "ac-btn", style: "background:var(--bg-active)" },
"Cancel",
);
appendChildren(editForm, editInput, saveBtn, cancelBtn);
const usernameError = createElement("div", { style: "color:var(--red);font-size:13px;margin-top:4px" });
const usernameError = createElement("div", {
style: "color:var(--red);font-size:13px;margin-top:4px",
});
editForm.appendChild(usernameError);
const openEditForm = () => {
@@ -626,26 +798,37 @@ export function buildAccountTab(
editUserProfileBtn.addEventListener("click", openEditForm, { signal });
editUsernameBtn.addEventListener("click", openEditForm, { signal });
cancelBtn.addEventListener("click", () => {
editForm.style.display = "none";
setText(usernameError, "");
}, { signal });
saveBtn.addEventListener("click", () => {
const newName = editInput.value.trim();
if (newName.length < 2 || newName.length > MAX_USERNAME_LEN) {
setText(usernameError, `Username must be 2\u2013${MAX_USERNAME_LEN} characters.`);
return;
}
setText(usernameError, "");
void options.onUpdateProfile(newName).then(() => {
setText(headerName, newName);
setText(usernameValue, newName);
cancelBtn.addEventListener(
"click",
() => {
editForm.style.display = "none";
}).catch((err: unknown) => {
setText(usernameError, err instanceof Error ? err.message : "Failed to update username.");
});
}, { signal });
setText(usernameError, "");
},
{ signal },
);
saveBtn.addEventListener(
"click",
() => {
const newName = editInput.value.trim();
if (newName.length < 2 || newName.length > MAX_USERNAME_LEN) {
setText(usernameError, `Username must be 2\u2013${MAX_USERNAME_LEN} characters.`);
return;
}
setText(usernameError, "");
void options
.onUpdateProfile(newName)
.then(() => {
setText(headerName, newName);
setText(usernameValue, newName);
editForm.style.display = "none";
})
.catch((err: unknown) => {
setText(usernameError, err instanceof Error ? err.message : "Failed to update username.");
});
},
{ signal },
);
section.appendChild(editForm);
@@ -47,7 +47,9 @@ export function buildAdvancedTab(signal: AbortSignal): HTMLDivElement {
const isOn = loadPref<boolean>(item.key, item.fallback);
const toggle = createToggle(isOn, {
signal,
onChange: (nowOn) => { savePref(item.key, nowOn); },
onChange: (nowOn) => {
savePref(item.key, nowOn);
},
});
appendChildren(row, info, toggle);
@@ -68,15 +70,25 @@ export function buildAdvancedTab(signal: AbortSignal): HTMLDivElement {
const devtoolsRow = createElement("div", { class: "setting-row" });
const devtoolsInfo = createElement("div", {});
const devtoolsLabel = createElement("div", { class: "setting-label" }, "Open DevTools");
const devtoolsDesc = createElement("div", { class: "setting-desc" }, "Open the browser developer tools for debugging");
const devtoolsDesc = createElement(
"div",
{ class: "setting-desc" },
"Open the browser developer tools for debugging",
);
appendChildren(devtoolsInfo, devtoolsLabel, devtoolsDesc);
const devtoolsBtn = createElement("button", { class: "ac-btn" }, "Open DevTools");
devtoolsBtn.addEventListener("click", () => {
void invoke("open_devtools").catch((err: unknown) => {
log.warn("DevTools not available", { error: err instanceof Error ? err.message : String(err) });
});
}, { signal });
devtoolsBtn.addEventListener(
"click",
() => {
void invoke("open_devtools").catch((err: unknown) => {
log.warn("DevTools not available", {
error: err instanceof Error ? err.message : String(err),
});
});
},
{ signal },
);
appendChildren(devtoolsRow, devtoolsInfo, devtoolsBtn);
section.appendChild(devtoolsRow);
@@ -90,90 +102,111 @@ export function buildAdvancedTab(signal: AbortSignal): HTMLDivElement {
section.appendChild(cacheTitle);
// Clear Image Cache
section.appendChild(buildCacheRow(
"Clear Image Cache",
"Remove cached images and link previews. They will be re-downloaded as needed.",
"Clear",
signal,
async (btn) => {
btn.textContent = "Clearing...";
btn.setAttribute("disabled", "");
try {
await clearImageCache();
btn.textContent = "Cleared!";
setTimeout(() => { btn.textContent = "Clear"; btn.removeAttribute("disabled"); }, 2000);
} catch (err) {
log.error("Failed to clear image cache", err);
btn.textContent = "Failed";
setTimeout(() => { btn.textContent = "Clear"; btn.removeAttribute("disabled"); }, 2000);
}
},
));
section.appendChild(
buildCacheRow(
"Clear Image Cache",
"Remove cached images and link previews. They will be re-downloaded as needed.",
"Clear",
signal,
async (btn) => {
btn.textContent = "Clearing...";
btn.setAttribute("disabled", "");
try {
await clearImageCache();
btn.textContent = "Cleared!";
setTimeout(() => {
btn.textContent = "Clear";
btn.removeAttribute("disabled");
}, 2000);
} catch (err) {
log.error("Failed to clear image cache", err);
btn.textContent = "Failed";
setTimeout(() => {
btn.textContent = "Clear";
btn.removeAttribute("disabled");
}, 2000);
}
},
),
);
// Clear Log Files
section.appendChild(buildCacheRow(
"Clear Log Files",
"Remove persisted client log files from disk.",
"Clear",
signal,
async (btn) => {
btn.textContent = "Clearing...";
btn.setAttribute("disabled", "");
try {
await clearLogFiles();
btn.textContent = "Cleared!";
setTimeout(() => { btn.textContent = "Clear"; btn.removeAttribute("disabled"); }, 2000);
} catch (err) {
log.error("Failed to clear log files", err);
btn.textContent = "Failed";
setTimeout(() => { btn.textContent = "Clear"; btn.removeAttribute("disabled"); }, 2000);
}
},
));
section.appendChild(
buildCacheRow(
"Clear Log Files",
"Remove persisted client log files from disk.",
"Clear",
signal,
async (btn) => {
btn.textContent = "Clearing...";
btn.setAttribute("disabled", "");
try {
await clearLogFiles();
btn.textContent = "Cleared!";
setTimeout(() => {
btn.textContent = "Clear";
btn.removeAttribute("disabled");
}, 2000);
} catch (err) {
log.error("Failed to clear log files", err);
btn.textContent = "Failed";
setTimeout(() => {
btn.textContent = "Clear";
btn.removeAttribute("disabled");
}, 2000);
}
},
),
);
// Clear All Cache (nuclear option)
section.appendChild(buildCacheRow(
"Clear All Cache & Restart",
"Remove all cached data (images, logs, WebView storage) and restart the app. "
+ "Server profiles and credentials are preserved.",
"Clear & Restart",
signal,
async (btn) => {
// Two-step confirmation: first click shows warning, second click confirms
if (btn.dataset.confirmPending !== "true") {
btn.dataset.confirmPending = "true";
btn.textContent = "Are you sure? Click again";
btn.classList.add("ac-btn-danger");
const resetTimer = setTimeout(() => {
btn.dataset.confirmPending = "";
btn.textContent = "Clear & Restart";
btn.classList.remove("ac-btn-danger");
}, 3000);
// Store timer ID so it can be cleared if the button is clicked again
btn.dataset.resetTimer = String(resetTimer);
return;
}
// Second click — clear the pending state and proceed
const pendingTimer = btn.dataset.resetTimer;
if (pendingTimer) clearTimeout(Number(pendingTimer));
btn.dataset.confirmPending = "";
btn.textContent = "Clearing...";
btn.setAttribute("disabled", "");
try {
await clearImageCache();
await clearLogFiles();
clearLocalStoragePreservingUserData();
sessionStorage.clear();
log.info("All cache cleared, restarting app");
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
} catch (err) {
log.error("Failed to clear all cache", err);
btn.textContent = "Failed";
setTimeout(() => { btn.textContent = "Clear & Restart"; btn.removeAttribute("disabled"); }, 2000);
}
},
));
section.appendChild(
buildCacheRow(
"Clear All Cache & Restart",
"Remove all cached data (images, logs, WebView storage) and restart the app. " +
"Server profiles and credentials are preserved.",
"Clear & Restart",
signal,
async (btn) => {
// Two-step confirmation: first click shows warning, second click confirms
if (btn.dataset.confirmPending !== "true") {
btn.dataset.confirmPending = "true";
btn.textContent = "Are you sure? Click again";
btn.classList.add("ac-btn-danger");
const resetTimer = setTimeout(() => {
btn.dataset.confirmPending = "";
btn.textContent = "Clear & Restart";
btn.classList.remove("ac-btn-danger");
}, 3000);
// Store timer ID so it can be cleared if the button is clicked again
btn.dataset.resetTimer = String(resetTimer);
return;
}
// Second click — clear the pending state and proceed
const pendingTimer = btn.dataset.resetTimer;
if (pendingTimer) clearTimeout(Number(pendingTimer));
btn.dataset.confirmPending = "";
btn.textContent = "Clearing...";
btn.setAttribute("disabled", "");
try {
await clearImageCache();
await clearLogFiles();
clearLocalStoragePreservingUserData();
sessionStorage.clear();
log.info("All cache cleared, restarting app");
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
} catch (err) {
log.error("Failed to clear all cache", err);
btn.textContent = "Failed";
setTimeout(() => {
btn.textContent = "Clear & Restart";
btn.removeAttribute("disabled");
}, 2000);
}
},
),
);
return section;
}
@@ -196,7 +229,13 @@ function buildCacheRow(
appendChildren(info, labelEl, descEl);
const btn = createElement("button", { class: "ac-btn" }, btnText);
btn.addEventListener("click", () => { onClick(btn); }, { signal });
btn.addEventListener(
"click",
() => {
onClick(btn);
},
{ signal },
);
appendChildren(row, info, btn);
return row;
@@ -223,7 +262,9 @@ async function clearImageCache(): Promise<void> {
req.onblocked = () => {
if (blockedTimer !== null) return;
blockedTimer = setTimeout(() => {
finish(() => reject(new Error("Image cache is still in use. Close active media and try again.")));
finish(() =>
reject(new Error("Image cache is still in use. Close active media and try again.")),
);
}, IMAGE_CACHE_DELETE_BLOCK_TIMEOUT_MS);
};
});
@@ -16,17 +16,13 @@ function getDefaultAccent(themeName: string): string {
const customTheme = loadCustomTheme(themeName);
const accent = customTheme?.colors["--accent"];
return typeof accent === "string" && /^#[\da-fA-F]{3,8}$/.test(accent)
? accent
: FALLBACK_ACCENT;
return typeof accent === "string" && /^#[\da-fA-F]{3,8}$/.test(accent) ? accent : FALLBACK_ACCENT;
}
export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const activeThemeName = getActiveThemeName();
const currentTheme = activeThemeName in THEMES
? activeThemeName as ThemeName
: null;
const currentTheme = activeThemeName in THEMES ? (activeThemeName as ThemeName) : null;
const currentFontSize = loadPref<number>("fontSize", 16);
const currentCompact = loadPref<boolean>("compactMode", false);
let hasStoredAccent = localStorage.getItem("owncord:settings:accentColor") !== null;
@@ -37,13 +33,17 @@ export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
const themeRow = createElement("div", { class: "theme-options", role: "radiogroup" });
for (const name of Object.keys(THEMES) as ThemeName[]) {
const isActive = name === currentTheme;
const btn = createElement("button", {
class: `theme-opt ${name}${isActive ? " active" : ""}`,
role: "radio",
tabindex: "0",
"aria-checked": isActive ? "true" : "false",
"aria-label": name.charAt(0).toUpperCase() + name.slice(1),
}, name.charAt(0).toUpperCase() + name.slice(1));
const btn = createElement(
"button",
{
class: `theme-opt ${name}${isActive ? " active" : ""}`,
role: "radio",
tabindex: "0",
"aria-checked": isActive ? "true" : "false",
"aria-label": name.charAt(0).toUpperCase() + name.slice(1),
},
name.charAt(0).toUpperCase() + name.slice(1),
);
const activateTheme = (): void => {
applyTheme(name);
@@ -60,12 +60,16 @@ export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
};
btn.addEventListener("click", activateTheme, { signal });
btn.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
activateTheme();
}
}, { signal });
btn.addEventListener(
"keydown",
(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
activateTheme();
}
},
{ signal },
);
themeRow.appendChild(btn);
}
@@ -82,12 +86,16 @@ export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
value: String(currentFontSize),
});
const fontLabel = createElement("span", { class: "slider-val" }, `${currentFontSize}px`);
fontSlider.addEventListener("input", () => {
const size = Number(fontSlider.value);
setText(fontLabel, `${size}px`);
document.documentElement.style.setProperty("--font-size", `${size}px`);
savePref("fontSize", size);
}, { signal });
fontSlider.addEventListener(
"input",
() => {
const size = Number(fontSlider.value);
setText(fontLabel, `${size}px`);
document.documentElement.style.setProperty("--font-size", `${size}px`);
savePref("fontSize", size);
},
{ signal },
);
appendChildren(fontRow, fontSlider, fontLabel);
appendChildren(section, fontHeader, fontRow);
@@ -177,25 +185,33 @@ export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement {
};
swatch.addEventListener("click", activateSwatch, { signal });
swatch.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
activateSwatch();
}
}, { signal });
swatch.addEventListener(
"keydown",
(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
activateSwatch();
}
},
{ signal },
);
swatchesRow.appendChild(swatch);
}
hexInput.addEventListener("input", () => {
const raw = hexInput.value.replace(/[^0-9a-fA-F]/g, "").slice(0, 6);
hexInput.value = raw;
if (raw.length === 6) {
const color = `#${raw}`;
saveAccent(color);
syncDisplayedAccent(color);
}
}, { signal });
hexInput.addEventListener(
"input",
() => {
const raw = hexInput.value.replace(/[^0-9a-fA-F]/g, "").slice(0, 6);
hexInput.value = raw;
if (raw.length === 6) {
const color = `#${raw}`;
saveAccent(color);
syncDisplayedAccent(color);
}
},
{ signal },
);
appendChildren(hexInputRow, hexPrefix, hexInput);
appendChildren(section, accentHeader, swatchesRow, hexInputRow);
@@ -14,73 +14,99 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
const pttRow = createElement("div", { class: "keybind-row" });
const pttLabel = createElement("span", { class: "setting-label" }, "Push to Talk");
let currentVk = loadPref<number>("pttVk", 0);
const pttValue = createElement("button", {
class: "kbd",
style: "cursor: pointer; min-width: 80px; text-align: center;",
title: "Click to set keybind",
"aria-label": "Push to Talk keybind — click to capture",
}, currentVk !== 0 ? vkName(currentVk) : "Not set");
const pttClear = createElement("button", {
class: "ac-btn",
style: `margin-left: 8px; font-size: 12px; padding: 4px 10px; ${currentVk !== 0 ? "" : "display: none;"}`,
}, "Clear");
const pttValue = createElement(
"button",
{
class: "kbd",
style: "cursor: pointer; min-width: 80px; text-align: center;",
title: "Click to set keybind",
"aria-label": "Push to Talk keybind — click to capture",
},
currentVk !== 0 ? vkName(currentVk) : "Not set",
);
const pttClear = createElement(
"button",
{
class: "ac-btn",
style: `margin-left: 8px; font-size: 12px; padding: 4px 10px; ${currentVk !== 0 ? "" : "display: none;"}`,
},
"Clear",
);
let capturing = false;
pttValue.addEventListener("click", () => {
if (capturing) return;
capturing = true;
pttValue.textContent = "Press any key...";
pttValue.style.borderColor = "var(--accent)";
pttValue.style.color = "var(--accent)";
pttValue.addEventListener(
"click",
() => {
if (capturing) return;
capturing = true;
pttValue.textContent = "Press any key...";
pttValue.style.borderColor = "var(--accent)";
pttValue.style.color = "var(--accent)";
// Use Rust-side key detection (supports mouse buttons, works globally).
// Returns 0 on timeout (10s) if the user didn't press anything.
void captureKeyPress().then((vk) => {
capturing = false;
pttValue.style.borderColor = "";
pttValue.style.color = "";
if (vk === 0) {
// Timed out — restore previous value
setText(pttValue, currentVk !== 0 ? vkName(currentVk) : "Not set");
return;
}
currentVk = vk;
setText(pttValue, vkName(vk));
pttClear.style.display = "";
void updatePttKey(vk);
}).catch(() => {
// Fallback: capture via JS keydown (dev mode without Tauri)
capturing = false;
pttValue.style.borderColor = "";
pttValue.style.color = "";
setText(pttValue, currentVk !== 0 ? vkName(currentVk) : "Not set");
});
}, { signal });
// Use Rust-side key detection (supports mouse buttons, works globally).
// Returns 0 on timeout (10s) if the user didn't press anything.
void captureKeyPress()
.then((vk) => {
capturing = false;
pttValue.style.borderColor = "";
pttValue.style.color = "";
if (vk === 0) {
// Timed out — restore previous value
setText(pttValue, currentVk !== 0 ? vkName(currentVk) : "Not set");
return;
}
currentVk = vk;
setText(pttValue, vkName(vk));
pttClear.style.display = "";
void updatePttKey(vk);
})
.catch(() => {
// Fallback: capture via JS keydown (dev mode without Tauri)
capturing = false;
pttValue.style.borderColor = "";
pttValue.style.color = "";
setText(pttValue, currentVk !== 0 ? vkName(currentVk) : "Not set");
});
},
{ signal },
);
pttClear.addEventListener("click", (e) => {
e.stopPropagation();
currentVk = 0;
setText(pttValue, "Not set");
pttClear.style.display = "none";
void updatePttKey(0);
}, { signal });
pttClear.addEventListener(
"click",
(e) => {
e.stopPropagation();
currentVk = 0;
setText(pttValue, "Not set");
pttClear.style.display = "none";
void updatePttKey(0);
},
{ signal },
);
appendChildren(pttRow, pttLabel, pttValue, pttClear);
section.appendChild(pttRow);
// PTT hint
const pttHint = createElement("div", {
style: "font-size: 11px; color: var(--text-micro); margin: 4px 0 16px 0; line-height: 1.4;",
}, "PTT works globally and does not hijack the key \u2014 you can still type and use other apps normally. Mouse buttons (Mouse 4/5) also work.");
const pttHint = createElement(
"div",
{
style: "font-size: 11px; color: var(--text-micro); margin: 4px 0 16px 0; line-height: 1.4;",
},
"PTT works globally and does not hijack the key \u2014 you can still type and use other apps normally. Mouse buttons (Mouse 4/5) also work.",
);
section.appendChild(pttHint);
// ── Navigation section ────────────────────────────────────
section.appendChild(createElement("div", { class: "settings-separator" }));
const navHeader = createElement("div", {
class: "keybind-section-header",
}, "Navigation");
const navHeader = createElement(
"div",
{
class: "keybind-section-header",
},
"Navigation",
);
section.appendChild(navHeader);
const navBinds: [string, string][] = [
@@ -90,7 +116,8 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
];
for (const [label, shortcut] of navBinds) {
const row = createElement("div", { class: "keybind-row" });
appendChildren(row,
appendChildren(
row,
createElement("span", { class: "setting-label" }, label),
createElement("span", { class: "kbd" }, shortcut),
);
@@ -100,9 +127,13 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
// ── Communication section ──────────────────────────────────
section.appendChild(createElement("div", { class: "settings-separator" }));
const commHeader = createElement("div", {
class: "keybind-section-header",
}, "Communication");
const commHeader = createElement(
"div",
{
class: "keybind-section-header",
},
"Communication",
);
section.appendChild(commHeader);
const commBinds: [string, string][] = [
@@ -112,7 +143,8 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
];
for (const [label, shortcut] of commBinds) {
const row = createElement("div", { class: "keybind-row" });
appendChildren(row,
appendChildren(
row,
createElement("span", { class: "setting-label" }, label),
createElement("span", { class: "kbd" }, shortcut),
);
@@ -122,9 +154,13 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
// ── Messages section ───────────────────────────────────────
section.appendChild(createElement("div", { class: "settings-separator" }));
const msgHeader = createElement("div", {
class: "keybind-section-header",
}, "Messages");
const msgHeader = createElement(
"div",
{
class: "keybind-section-header",
},
"Messages",
);
section.appendChild(msgHeader);
const msgBinds: [string, string][] = [
@@ -133,7 +169,8 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement {
];
for (const [label, shortcut] of msgBinds) {
const row = createElement("div", { class: "keybind-row" });
appendChildren(row,
appendChildren(
row,
createElement("span", { class: "setting-label" }, label),
createElement("span", { class: "kbd" }, shortcut),
);
@@ -7,7 +7,7 @@ import { getLogBuffer, clearLogBuffer, addLogListener, setLogLevel } from "@lib/
import type { LogEntry, LogLevel } from "@lib/logger";
import type { TabName } from "../SettingsOverlay";
import { getSessionDebugInfo } from "@lib/livekitSession";
import { loadPref, savePref } from "./helpers";
import { savePref } from "./helpers";
// ---------------------------------------------------------------------------
// Constants
@@ -35,16 +35,26 @@ function formatLogEntry(entry: LogEntry): HTMLDivElement {
const time = entry.timestamp.slice(11, 23); // HH:MM:SS.mmm
const level = entry.level.toUpperCase().padEnd(5);
const text = `${time} ${level} [${entry.component}] ${entry.message}`;
const textEl = createElement("span", {
style: `color: ${LOG_LEVEL_COLORS[entry.level]}`,
}, text);
const textEl = createElement(
"span",
{
style: `color: ${LOG_LEVEL_COLORS[entry.level]}`,
},
text,
);
row.appendChild(textEl);
if (entry.data !== undefined) {
const dataStr = typeof entry.data === "string" ? entry.data : JSON.stringify(entry.data, null, 2);
const dataEl = createElement("pre", {
style: "margin: 2px 0 0 0; color: #999; font-size: 11px; white-space: pre-wrap; word-break: break-all;",
}, dataStr);
const dataStr =
typeof entry.data === "string" ? entry.data : JSON.stringify(entry.data, null, 2);
const dataEl = createElement(
"pre",
{
style:
"margin: 2px 0 0 0; color: #999; font-size: 11px; white-space: pre-wrap; word-break: break-all;",
},
dataStr,
);
row.appendChild(dataEl);
}
@@ -94,12 +104,13 @@ export interface LogsTabHandle {
cleanup(): void;
}
export function createLogsTab(
getActiveTab: () => TabName,
signal: AbortSignal,
): LogsTabHandle {
export function createLogsTab(getActiveTab: () => TabName, signal: AbortSignal): LogsTabHandle {
let logListEl: HTMLDivElement | null = null;
let logFilterLevel: LogLevel | "all" = readMigratedStringPref("logs_filter_level", "all", LOG_FILTER_LEVELS);
let logFilterLevel: LogLevel | "all" = readMigratedStringPref(
"logs_filter_level",
"all",
LOG_FILTER_LEVELS,
);
let unsubLogListener: (() => void) | null = null;
function renderLogEntries(): void {
@@ -120,13 +131,23 @@ export function createLogsTab(
const section = createElement("div", { class: "settings-pane active" });
// Version display
const versionEl = createElement("div", {
style: "font-size: 12px; color: var(--text-muted); margin: -8px 0 12px 0;",
}, "Client version: loading...");
const versionEl = createElement(
"div",
{
style: "font-size: 12px; color: var(--text-muted); margin: -8px 0 12px 0;",
},
"Client version: loading...",
);
section.appendChild(versionEl);
void import("@tauri-apps/api/app").then(({ getVersion }) =>
getVersion().then((v) => { versionEl.textContent = `Client version: v${v}`; }),
).catch(() => { versionEl.textContent = "Client version: unknown"; });
void import("@tauri-apps/api/app")
.then(({ getVersion }) =>
getVersion().then((v) => {
versionEl.textContent = `Client version: v${v}`;
}),
)
.catch(() => {
versionEl.textContent = "Client version: unknown";
});
// Controls row
const controls = createElement("div", {
@@ -134,9 +155,14 @@ export function createLogsTab(
});
// Filter dropdown
const filterLabel = createElement("span", { class: "setting-label", style: "margin: 0;" }, "Filter:");
const filterLabel = createElement(
"span",
{ class: "setting-label", style: "margin: 0;" },
"Filter:",
);
const filterSelect = createElement("select", {
style: "background: var(--bg-tertiary); color: var(--text-normal); border: 1px solid var(--bg-active); border-radius: 4px; padding: 4px 8px; font-size: 13px;",
style:
"background: var(--bg-tertiary); color: var(--text-normal); border: 1px solid var(--bg-active); border-radius: 4px; padding: 4px 8px; font-size: 13px;",
});
for (const lvl of LOG_FILTER_LEVELS) {
const opt = createElement("option", { value: lvl }, lvl.toUpperCase());
@@ -144,71 +170,116 @@ export function createLogsTab(
filterSelect.appendChild(opt);
}
filterSelect.value = logFilterLevel;
filterSelect.addEventListener("change", () => {
logFilterLevel = filterSelect.value as LogLevel | "all";
savePref("logs_filter_level", logFilterLevel);
renderLogEntries();
}, { signal });
filterSelect.addEventListener(
"change",
() => {
logFilterLevel = filterSelect.value as LogLevel | "all";
savePref("logs_filter_level", logFilterLevel);
renderLogEntries();
},
{ signal },
);
// Log level selector
const levelLabel = createElement("span", { class: "setting-label", style: "margin: 0 0 0 16px;" }, "Min Level:");
const levelLabel = createElement(
"span",
{ class: "setting-label", style: "margin: 0 0 0 16px;" },
"Min Level:",
);
const levelSelect = createElement("select", {
style: "background: var(--bg-tertiary); color: var(--text-normal); border: 1px solid var(--bg-active); border-radius: 4px; padding: 4px 8px; font-size: 13px;",
style:
"background: var(--bg-tertiary); color: var(--text-normal); border: 1px solid var(--bg-active); border-radius: 4px; padding: 4px 8px; font-size: 13px;",
});
for (const lvl of LOG_MIN_LEVELS) {
const opt = createElement("option", { value: lvl }, lvl.toUpperCase());
levelSelect.appendChild(opt);
}
const savedMinLevel = readMigratedStringPref<LogLevel | "">("logs_min_level", "", ["", ...LOG_MIN_LEVELS]);
const savedMinLevel = readMigratedStringPref<LogLevel | "">("logs_min_level", "", [
"",
...LOG_MIN_LEVELS,
]);
if (savedMinLevel !== "") {
levelSelect.value = savedMinLevel;
setLogLevel(savedMinLevel);
}
levelSelect.addEventListener("change", () => {
const level = levelSelect.value as LogLevel;
setLogLevel(level);
savePref("logs_min_level", level);
}, { signal });
levelSelect.addEventListener(
"change",
() => {
const level = levelSelect.value as LogLevel;
setLogLevel(level);
savePref("logs_min_level", level);
},
{ signal },
);
// Copy All button
const copyBtn = createElement("button", {
class: "ac-btn",
style: "margin-left: auto;",
}, "Copy All");
copyBtn.addEventListener("click", () => {
const entries = getLogBuffer();
const filtered = logFilterLevel === "all"
? entries
: entries.filter((e) => e.level === logFilterLevel);
const text = filtered.map((e) => {
const time = e.timestamp.slice(11, 23);
const level = e.level.toUpperCase().padEnd(5);
const base = `${time} ${level} [${e.component}] ${e.message}`;
if (e.data === undefined) return base;
const dataStr = typeof e.data === "string" ? e.data : JSON.stringify(e.data, null, 2);
return `${base}\n${dataStr}`;
}).join("\n");
void navigator.clipboard.writeText(text).then(() => {
copyBtn.textContent = "Copied!";
setTimeout(() => { copyBtn.textContent = "Copy All"; }, 1500);
}).catch(() => {
copyBtn.textContent = "Failed to copy";
setTimeout(() => { copyBtn.textContent = "Copy All"; }, 1500);
});
}, { signal });
const copyBtn = createElement(
"button",
{
class: "ac-btn",
style: "margin-left: auto;",
},
"Copy All",
);
copyBtn.addEventListener(
"click",
() => {
const entries = getLogBuffer();
const filtered =
logFilterLevel === "all" ? entries : entries.filter((e) => e.level === logFilterLevel);
const text = filtered
.map((e) => {
const time = e.timestamp.slice(11, 23);
const level = e.level.toUpperCase().padEnd(5);
const base = `${time} ${level} [${e.component}] ${e.message}`;
if (e.data === undefined) return base;
const dataStr = typeof e.data === "string" ? e.data : JSON.stringify(e.data, null, 2);
return `${base}\n${dataStr}`;
})
.join("\n");
void navigator.clipboard
.writeText(text)
.then(() => {
copyBtn.textContent = "Copied!";
setTimeout(() => {
copyBtn.textContent = "Copy All";
}, 1500);
})
.catch(() => {
copyBtn.textContent = "Failed to copy";
setTimeout(() => {
copyBtn.textContent = "Copy All";
}, 1500);
});
},
{ signal },
);
// Clear button
const clearBtn = createElement("button", { class: "ac-btn" }, "Clear Logs");
clearBtn.addEventListener("click", () => {
clearLogBuffer();
renderLogEntries();
}, { signal });
clearBtn.addEventListener(
"click",
() => {
clearLogBuffer();
renderLogEntries();
},
{ signal },
);
// Refresh button
const refreshBtn = createElement("button", { class: "ac-btn" }, "Refresh");
refreshBtn.addEventListener("click", () => renderLogEntries(), { signal });
appendChildren(controls, filterLabel, filterSelect, levelLabel, levelSelect, copyBtn, clearBtn, refreshBtn);
appendChildren(
controls,
filterLabel,
filterSelect,
levelLabel,
levelSelect,
copyBtn,
clearBtn,
refreshBtn,
);
section.appendChild(controls);
// Voice diagnostics panel
@@ -216,7 +287,8 @@ export function createLogsTab(
section.appendChild(diagHeader);
const diagPanel = createElement("div", {
style: "background: var(--bg-tertiary); border-radius: 8px; padding: 10px; margin-bottom: 12px; font-family: monospace; font-size: 12px; line-height: 1.6; color: var(--text-muted);",
style:
"background: var(--bg-tertiary); border-radius: 8px; padding: 10px; margin-bottom: 12px; font-family: monospace; font-size: 12px; line-height: 1.6; color: var(--text-muted);",
});
function refreshDiag(): void {
@@ -225,19 +297,38 @@ export function createLogsTab(
}
refreshDiag();
const diagRefresh = createElement("button", { class: "ac-btn", style: "margin-top: 6px;" }, "Refresh Diagnostics");
const diagRefresh = createElement(
"button",
{ class: "ac-btn", style: "margin-top: 6px;" },
"Refresh Diagnostics",
);
diagRefresh.addEventListener("click", refreshDiag, { signal });
const diagCopy = createElement("button", { class: "ac-btn", style: "margin: 6px 0 0 6px;" }, "Copy Diagnostics");
diagCopy.addEventListener("click", () => {
void navigator.clipboard.writeText(diagPanel.textContent ?? "").then(() => {
diagCopy.textContent = "Copied!";
setTimeout(() => { diagCopy.textContent = "Copy Diagnostics"; }, 1500);
}).catch(() => {
diagCopy.textContent = "Failed to copy";
setTimeout(() => { diagCopy.textContent = "Copy Diagnostics"; }, 1500);
});
}, { signal });
const diagCopy = createElement(
"button",
{ class: "ac-btn", style: "margin: 6px 0 0 6px;" },
"Copy Diagnostics",
);
diagCopy.addEventListener(
"click",
() => {
void navigator.clipboard
.writeText(diagPanel.textContent ?? "")
.then(() => {
diagCopy.textContent = "Copied!";
setTimeout(() => {
diagCopy.textContent = "Copy Diagnostics";
}, 1500);
})
.catch(() => {
diagCopy.textContent = "Failed to copy";
setTimeout(() => {
diagCopy.textContent = "Copy Diagnostics";
}, 1500);
});
},
{ signal },
);
section.appendChild(diagPanel);
const diagBtns = createElement("div", { style: "display: flex; flex-wrap: wrap;" });
@@ -245,15 +336,20 @@ export function createLogsTab(
section.appendChild(diagBtns);
// Log count
const countEl = createElement("div", {
style: "font-size: 12px; color: #888; margin: 12px 0 4px 0;",
}, `${getLogBuffer().length} entries`);
const countEl = createElement(
"div",
{
style: "font-size: 12px; color: #888; margin: 12px 0 4px 0;",
},
`${getLogBuffer().length} entries`,
);
section.appendChild(countEl);
// Log list (scrollable)
logListEl = createElement("div", {
class: "log-viewer",
style: "max-height: 60vh; overflow-y: auto; background: var(--bg-tertiary); border-radius: 8px; padding: 8px;",
style:
"max-height: 60vh; overflow-y: auto; background: var(--bg-tertiary); border-radius: 8px; padding: 8px;",
});
section.appendChild(logListEl);
@@ -9,10 +9,30 @@ export function buildNotificationsTab(signal: AbortSignal): HTMLDivElement {
const section = createElement("div", { class: "settings-pane active" });
const toggles: ReadonlyArray<{ key: string; label: string; desc: string; fallback: boolean }> = [
{ key: "desktopNotifications", label: "Desktop Notifications", desc: "Show desktop notifications for messages", fallback: true },
{ key: "flashTaskbar", label: "Flash Taskbar", desc: "Flash taskbar on new messages", fallback: true },
{ key: "suppressEveryone", label: "Suppress @everyone", desc: "Mute @everyone and @here mentions", fallback: false },
{ key: "notificationSounds", label: "Notification Sounds", desc: "Play sounds for notifications", fallback: true },
{
key: "desktopNotifications",
label: "Desktop Notifications",
desc: "Show desktop notifications for messages",
fallback: true,
},
{
key: "flashTaskbar",
label: "Flash Taskbar",
desc: "Flash taskbar on new messages",
fallback: true,
},
{
key: "suppressEveryone",
label: "Suppress @everyone",
desc: "Mute @everyone and @here mentions",
fallback: false,
},
{
key: "notificationSounds",
label: "Notification Sounds",
desc: "Play sounds for notifications",
fallback: true,
},
];
for (const item of toggles) {
@@ -25,7 +45,9 @@ export function buildNotificationsTab(signal: AbortSignal): HTMLDivElement {
const isOn = loadPref<boolean>(item.key, item.fallback);
const toggle = createToggle(isOn, {
signal,
onChange: (nowOn) => { savePref(item.key, nowOn); },
onChange: (nowOn) => {
savePref(item.key, nowOn);
},
});
appendChildren(row, info, toggle);
@@ -44,7 +44,9 @@ export function buildTextImagesTab(signal: AbortSignal): HTMLDivElement {
const isOn = loadPref<boolean>(item.key, item.fallback);
const toggle = createToggle(isOn, {
signal,
onChange: (nowOn) => { savePref(item.key, nowOn); },
onChange: (nowOn) => {
savePref(item.key, nowOn);
},
});
appendChildren(row, info, toggle);
section.appendChild(row);
@@ -4,7 +4,14 @@
import { createElement, appendChildren, setText } from "@lib/dom";
import { loadPref, savePref, createToggle } from "./helpers";
import { switchInputDevice, switchOutputDevice, setVoiceSensitivity, setInputVolume, setOutputVolume, reapplyAudioProcessing } from "@lib/livekitSession";
import {
switchInputDevice,
switchOutputDevice,
setVoiceSensitivity,
setInputVolume,
setOutputVolume,
reapplyAudioProcessing,
} from "@lib/livekitSession";
export interface VoiceAudioTabHandle {
build(): HTMLDivElement;
@@ -19,13 +26,19 @@ export function createVoiceAudioTab(signal: AbortSignal): VoiceAudioTabHandle {
let invalidateCameraPreviewRequest: (() => void) | null = null;
function cleanupMic(): void {
if (micAnimFrame !== null) { cancelAnimationFrame(micAnimFrame); micAnimFrame = null; }
if (micAnimFrame !== null) {
cancelAnimationFrame(micAnimFrame);
micAnimFrame = null;
}
invalidateCameraPreviewRequest?.();
if (micStream !== null) {
for (const track of micStream.getTracks()) track.stop();
micStream = null;
}
if (micAudioCtx !== null) { void micAudioCtx.close(); micAudioCtx = null; }
if (micAudioCtx !== null) {
void micAudioCtx.close();
micAudioCtx = null;
}
// Also stop camera preview
if (cameraPreviewStream !== null) {
for (const track of cameraPreviewStream.getTracks()) track.stop();
@@ -36,19 +49,24 @@ export function createVoiceAudioTab(signal: AbortSignal): VoiceAudioTabHandle {
function build(): HTMLDivElement {
// Clean up any previous mic/camera stream before rebuilding
cleanupMic();
return buildVoiceAudioTabInner(signal, (stream, ctx, frame) => {
micStream = stream;
micAudioCtx = ctx;
micAnimFrame = frame;
}, (stream) => {
// Stop old camera tracks before registering new stream
if (cameraPreviewStream !== null && cameraPreviewStream !== stream) {
for (const track of cameraPreviewStream.getTracks()) track.stop();
}
cameraPreviewStream = stream;
}, (invalidate) => {
invalidateCameraPreviewRequest = invalidate;
});
return buildVoiceAudioTabInner(
signal,
(stream, ctx, frame) => {
micStream = stream;
micAudioCtx = ctx;
micAnimFrame = frame;
},
(stream) => {
// Stop old camera tracks before registering new stream
if (cameraPreviewStream !== null && cameraPreviewStream !== stream) {
for (const track of cameraPreviewStream.getTracks()) track.stop();
}
cameraPreviewStream = stream;
},
(invalidate) => {
invalidateCameraPreviewRequest = invalidate;
},
);
}
function cleanup(): void {
@@ -98,11 +116,15 @@ function buildVoiceAudioTabInner(
value: String(savedInputVolume),
});
const inputVolumeLabel = createElement("span", { class: "slider-val" }, `${savedInputVolume}%`);
inputVolumeSlider.addEventListener("input", () => {
const val = Number(inputVolumeSlider.value);
setText(inputVolumeLabel, `${val}%`);
setInputVolume(val);
}, { signal });
inputVolumeSlider.addEventListener(
"input",
() => {
const val = Number(inputVolumeSlider.value);
setText(inputVolumeLabel, `${val}%`);
setInputVolume(val);
},
{ signal },
);
appendChildren(inputVolumeRow, inputVolumeSlider, inputVolumeLabel);
section.appendChild(inputVolumeRow);
@@ -146,22 +168,32 @@ function buildVoiceAudioTabInner(
}
// Drag the threshold handle
meterThreshold.addEventListener("pointerdown", (e: PointerEvent) => {
e.preventDefault();
meterThreshold.setPointerCapture(e.pointerId);
const onMove = (ev: PointerEvent): void => { applySensitivity(sensitivityFromPointer(ev.clientX)); };
const onUp = (): void => {
meterThreshold.removeEventListener("pointermove", onMove);
meterThreshold.removeEventListener("pointerup", onUp);
};
meterThreshold.addEventListener("pointermove", onMove, { signal });
meterThreshold.addEventListener("pointerup", onUp, { signal });
}, { signal });
meterThreshold.addEventListener(
"pointerdown",
(e: PointerEvent) => {
e.preventDefault();
meterThreshold.setPointerCapture(e.pointerId);
const onMove = (ev: PointerEvent): void => {
applySensitivity(sensitivityFromPointer(ev.clientX));
};
const onUp = (): void => {
meterThreshold.removeEventListener("pointermove", onMove);
meterThreshold.removeEventListener("pointerup", onUp);
};
meterThreshold.addEventListener("pointermove", onMove, { signal });
meterThreshold.addEventListener("pointerup", onUp, { signal });
},
{ signal },
);
// Click on the meter bar to jump the threshold
meterBar.addEventListener("click", (e: MouseEvent) => {
applySensitivity(sensitivityFromPointer(e.clientX));
}, { signal });
meterBar.addEventListener(
"click",
(e: MouseEvent) => {
applySensitivity(sensitivityFromPointer(e.clientX));
},
{ signal },
);
// Output device selector
const outputHeader = createElement("h3", {}, "Output Device");
@@ -188,19 +220,27 @@ function buildVoiceAudioTabInner(
value: String(savedOutputVolume),
});
const outputVolumeLabel = createElement("span", { class: "slider-val" }, `${savedOutputVolume}%`);
outputVolumeSlider.addEventListener("input", () => {
const val = Number(outputVolumeSlider.value);
setText(outputVolumeLabel, `${val}%`);
setOutputVolume(val);
}, { signal });
outputVolumeSlider.addEventListener(
"input",
() => {
const val = Number(outputVolumeSlider.value);
setText(outputVolumeLabel, `${val}%`);
setOutputVolume(val);
},
{ signal },
);
appendChildren(outputVolumeRow, outputVolumeSlider, outputVolumeLabel);
section.appendChild(outputVolumeRow);
// Stream quality selector
const qualityHeader = createElement("h3", {}, "Stream Quality");
const qualityDesc = createElement("p", {
style: "color:var(--text-muted);font-size:12px;margin:0 0 8px",
}, "Applies to camera and screenshare. Higher quality uses more bandwidth. Changes take effect on next voice join.");
const qualityDesc = createElement(
"p",
{
style: "color:var(--text-muted);font-size:12px;margin:0 0 8px",
},
"Applies to camera and screenshare. Higher quality uses more bandwidth. Changes take effect on next voice join.",
);
const qualitySelect = createElement("select", {
class: "form-input",
style: "width:100%;margin-bottom:16px",
@@ -218,9 +258,13 @@ function buildVoiceAudioTabInner(
qualitySelect.appendChild(opt);
}
qualitySelect.value = savedQuality;
qualitySelect.addEventListener("change", () => {
savePref("streamQuality", qualitySelect.value);
}, { signal });
qualitySelect.addEventListener(
"change",
() => {
savePref("streamQuality", qualitySelect.value);
},
{ signal },
);
section.appendChild(qualityHeader);
section.appendChild(qualityDesc);
section.appendChild(qualitySelect);
@@ -238,7 +282,8 @@ function buildVoiceAudioTabInner(
// Camera preview
const previewWrap = createElement("div", {
style: "margin-bottom:16px;border-radius:8px;overflow:hidden;background:#1e1f22;aspect-ratio:16/9;max-width:320px",
style:
"margin-bottom:16px;border-radius:8px;overflow:hidden;background:#1e1f22;aspect-ratio:16/9;max-width:320px",
});
const previewVideo = document.createElement("video");
previewVideo.autoplay = true;
@@ -260,18 +305,27 @@ function buildVoiceAudioTabInner(
for (const d of devices) {
if (d.kind === "audioinput") {
const opt = createElement("option", { value: d.deviceId },
d.label || `Microphone (${d.deviceId.slice(0, 8)})`);
const opt = createElement(
"option",
{ value: d.deviceId },
d.label || `Microphone (${d.deviceId.slice(0, 8)})`,
);
if (d.deviceId === savedInput) opt.setAttribute("selected", "");
inputSelect.appendChild(opt);
} else if (d.kind === "audiooutput") {
const opt = createElement("option", { value: d.deviceId },
d.label || `Speaker (${d.deviceId.slice(0, 8)})`);
const opt = createElement(
"option",
{ value: d.deviceId },
d.label || `Speaker (${d.deviceId.slice(0, 8)})`,
);
if (d.deviceId === savedOutput) opt.setAttribute("selected", "");
outputSelect.appendChild(opt);
} else if (d.kind === "videoinput") {
const opt = createElement("option", { value: d.deviceId },
d.label || `Camera (${d.deviceId.slice(0, 8)})`);
const opt = createElement(
"option",
{ value: d.deviceId },
d.label || `Camera (${d.deviceId.slice(0, 8)})`,
);
if (d.deviceId === savedVideo) opt.setAttribute("selected", "");
videoSelect.appendChild(opt);
}
@@ -282,21 +336,32 @@ function buildVoiceAudioTabInner(
if (savedOutput) outputSelect.value = savedOutput;
if (savedVideo) videoSelect.value = savedVideo;
} catch {
const errOpt = createElement("option", { value: "", disabled: "" },
"Could not enumerate devices");
const errOpt = createElement(
"option",
{ value: "", disabled: "" },
"Could not enumerate devices",
);
inputSelect.appendChild(errOpt);
}
})();
inputSelect.addEventListener("change", () => {
savePref("audioInputDevice", inputSelect.value);
void switchInputDevice(inputSelect.value);
}, { signal });
inputSelect.addEventListener(
"change",
() => {
savePref("audioInputDevice", inputSelect.value);
void switchInputDevice(inputSelect.value);
},
{ signal },
);
outputSelect.addEventListener("change", () => {
savePref("audioOutputDevice", outputSelect.value);
void switchOutputDevice(outputSelect.value);
}, { signal });
outputSelect.addEventListener(
"change",
() => {
savePref("audioOutputDevice", outputSelect.value);
void switchOutputDevice(outputSelect.value);
},
{ signal },
);
// Race guard: prevent stale getUserMedia results from overwriting a newer request
let cameraRequestId = 0;
@@ -348,10 +413,14 @@ function buildVoiceAudioTabInner(
})();
}
videoSelect.addEventListener("change", () => {
savePref("videoInputDevice", videoSelect.value);
startCameraPreview(videoSelect.value);
}, { signal });
videoSelect.addEventListener(
"change",
() => {
savePref("videoInputDevice", videoSelect.value);
startCameraPreview(videoSelect.value);
},
{ signal },
);
// Start initial camera preview only if a device has been explicitly selected
const savedVideoDevice = loadPref<string>("videoInputDevice", "");
@@ -415,11 +484,36 @@ function buildVoiceAudioTabInner(
})();
// ── Audio processing toggles ──────────────────────────────────────
const audioToggles: ReadonlyArray<{ key: string; label: string; desc: string; fallback: boolean }> = [
{ key: "echoCancellation", label: "Echo Cancellation", desc: "Reduce echo from speakers feeding back into microphone", fallback: true },
{ key: "noiseSuppression", label: "Noise Suppression", desc: "Filter out background noise from your microphone", fallback: true },
{ key: "autoGainControl", label: "Automatic Gain Control", desc: "Automatically adjust microphone volume", fallback: true },
{ key: "enhancedNoiseSuppression", label: "Enhanced Noise Suppression", desc: "ML-powered noise removal (RNNoise) — filters keyboard, pets, and other non-voice sounds", fallback: false },
const audioToggles: ReadonlyArray<{
key: string;
label: string;
desc: string;
fallback: boolean;
}> = [
{
key: "echoCancellation",
label: "Echo Cancellation",
desc: "Reduce echo from speakers feeding back into microphone",
fallback: true,
},
{
key: "noiseSuppression",
label: "Noise Suppression",
desc: "Filter out background noise from your microphone",
fallback: true,
},
{
key: "autoGainControl",
label: "Automatic Gain Control",
desc: "Automatically adjust microphone volume",
fallback: true,
},
{
key: "enhancedNoiseSuppression",
label: "Enhanced Noise Suppression",
desc: "ML-powered noise removal (RNNoise) — filters keyboard, pets, and other non-voice sounds",
fallback: false,
},
];
for (const item of audioToggles) {
@@ -12,10 +12,30 @@ import { applyThemeByName } from "@lib/themes";
export const STORAGE_PREFIX = "owncord:settings:";
export const THEMES = {
dark: { "--bg-primary": "#313338", "--bg-secondary": "#2b2d31", "--bg-tertiary": "#1e1f22", "--text-normal": "#dbdee1" },
"neon-glow": { "--bg-primary": "#1a1b1e", "--bg-secondary": "#111214", "--bg-tertiary": "#0d0e10", "--text-normal": "#dbdee1" },
midnight: { "--bg-primary": "#1a1a2e", "--bg-secondary": "#16213e", "--bg-tertiary": "#0f3460", "--text-normal": "#e0e0e0" },
light: { "--bg-primary": "#ffffff", "--bg-secondary": "#f2f3f5", "--bg-tertiary": "#e3e5e8", "--text-normal": "#313338" },
dark: {
"--bg-primary": "#313338",
"--bg-secondary": "#2b2d31",
"--bg-tertiary": "#1e1f22",
"--text-normal": "#dbdee1",
},
"neon-glow": {
"--bg-primary": "#1a1b1e",
"--bg-secondary": "#111214",
"--bg-tertiary": "#0d0e10",
"--text-normal": "#dbdee1",
},
midnight: {
"--bg-primary": "#1a1a2e",
"--bg-secondary": "#16213e",
"--bg-tertiary": "#0f3460",
"--text-normal": "#e0e0e0",
},
light: {
"--bg-primary": "#ffffff",
"--bg-secondary": "#f2f3f5",
"--bg-tertiary": "#e3e5e8",
"--text-normal": "#313338",
},
} as const;
export type ThemeName = keyof typeof THEMES;
@@ -72,12 +92,16 @@ export function createToggle(
}
toggle.addEventListener("click", doToggle, { signal: opts.signal });
toggle.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
doToggle();
}
}, { signal: opts.signal });
toggle.addEventListener(
"keydown",
(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
doToggle();
}
},
{ signal: opts.signal },
);
return toggle;
}
+7 -2
View File
@@ -124,7 +124,10 @@ export class AudioElements {
for (const el of detachedEls) audioEls.delete(el);
if (audioEls.size === 0) this.screenshareAudioElements.delete(userId);
}
log.debug("Screenshare audio track unsubscribed and detached", { userId, trackSid: track.sid });
log.debug("Screenshare audio track unsubscribed and detached", {
userId,
trackSid: track.sid,
});
} else {
for (const el of track.detach()) el.remove();
if (track.sid !== undefined) this.remoteMicAudioElements.delete(track.sid);
@@ -166,7 +169,9 @@ export class AudioElements {
}
}
getUserVolume(userId: number): number { return getSavedUserVolume(userId); }
getUserVolume(userId: number): number {
return getSavedUserVolume(userId);
}
setOutputVolume(volume: number): void {
const clamped = Math.max(0, Math.min(200, volume));
+47 -19
View File
@@ -151,14 +151,28 @@ export class AudioPipeline {
const micPub = this.room.localParticipant.getTrackPublication(Track.Source.Microphone);
if (micPub?.track?.sender !== undefined) {
const originalTrack = micPub.track.mediaStreamTrack;
void micPub.track.sender.replaceTrack(originalTrack).catch((err) => log.debug("Failed to replace track during teardown", err));
void micPub.track.sender
.replaceTrack(originalTrack)
.catch((err) => log.debug("Failed to replace track during teardown", err));
}
}
if (this.audioPipelineGain !== null) { this.audioPipelineGain.disconnect(); this.audioPipelineGain = null; }
if (this.audioPipelineAnalyser !== null) { this.audioPipelineAnalyser.disconnect(); this.audioPipelineAnalyser = null; }
if (this.audioPipelineDest !== null) { this.audioPipelineDest.disconnect(); this.audioPipelineDest = null; }
if (this.audioPipelineCtx !== null) { void this.audioPipelineCtx.close(); this.audioPipelineCtx = null; }
if (this.audioPipelineGain !== null) {
this.audioPipelineGain.disconnect();
this.audioPipelineGain = null;
}
if (this.audioPipelineAnalyser !== null) {
this.audioPipelineAnalyser.disconnect();
this.audioPipelineAnalyser = null;
}
if (this.audioPipelineDest !== null) {
this.audioPipelineDest.disconnect();
this.audioPipelineDest = null;
}
if (this.audioPipelineCtx !== null) {
void this.audioPipelineCtx.close();
this.audioPipelineCtx = null;
}
this.vadGated = false;
}
@@ -167,7 +181,11 @@ export class AudioPipeline {
updatePipelineGain(): void {
if (this.audioPipelineGain === null || this.audioPipelineCtx === null) return;
const effectiveGain = this.vadGated ? 0 : this.currentInputGain;
this.audioPipelineGain.gain.setTargetAtTime(effectiveGain, this.audioPipelineCtx.currentTime, 0.015);
this.audioPipelineGain.gain.setTargetAtTime(
effectiveGain,
this.audioPipelineCtx.currentTime,
0.015,
);
}
// --- Volume/sensitivity ---
@@ -192,7 +210,10 @@ export class AudioPipeline {
this.stopVadPolling();
if (clamped >= 100) {
// Ensure ungated
if (this.vadGated) { this.vadGated = false; this.updatePipelineGain(); }
if (this.vadGated) {
this.vadGated = false;
this.updatePipelineGain();
}
} else {
this.startVadPolling();
}
@@ -211,9 +232,13 @@ export class AudioPipeline {
private _vadUsingWorklet = false;
/** Latest RMS value from VAD (for UI indicator bar). */
get lastVadRms(): number { return this._lastVadRms; }
get lastVadRms(): number {
return this._lastVadRms;
}
/** Whether VAD is using AudioWorklet (true) or setTimeout fallback (false). */
get vadUsingWorklet(): boolean { return this._vadUsingWorklet; }
get vadUsingWorklet(): boolean {
return this._vadUsingWorklet;
}
/** Start VAD — tries AudioWorklet first, falls back to setTimeout polling. */
startVadPolling(): void {
@@ -223,19 +248,22 @@ export class AudioPipeline {
const sensitivity = loadPref<number>("voiceSensitivity", 50);
if (sensitivity >= 100) return;
const threshold = ((100 - sensitivity) / 100) * 0.10;
const threshold = ((100 - sensitivity) / 100) * 0.1;
// Try AudioWorklet first
const gen = this._pipelineGeneration;
this.audioPipelineCtx.audioWorklet.addModule("/vad-worklet.js").then(() => {
if (gen !== this._pipelineGeneration) return; // Torn down while loading
if (this.audioPipelineCtx === null) return;
this.startVadWorklet(threshold);
}).catch((err) => {
if (gen !== this._pipelineGeneration) return;
log.warn("AudioWorklet unavailable, falling back to setTimeout VAD", err);
this.startVadFallback(threshold);
});
this.audioPipelineCtx.audioWorklet
.addModule("/vad-worklet.js")
.then(() => {
if (gen !== this._pipelineGeneration) return; // Torn down while loading
if (this.audioPipelineCtx === null) return;
this.startVadWorklet(threshold);
})
.catch((err) => {
if (gen !== this._pipelineGeneration) return;
log.warn("AudioWorklet unavailable, falling back to setTimeout VAD", err);
this.startVadFallback(threshold);
});
}
/** Start VAD via AudioWorklet (preferred — runs on audio thread). */
+15 -11
View File
@@ -53,9 +53,7 @@ interface PrevSnapshot {
/** Collect stats from both publisher and subscriber PeerConnections.
* RTT is typically on the subscriber PC in LiveKit's SFU model. */
async function collectAllStats(
room: Room,
): Promise<RTCStatsReport[]> {
async function collectAllStats(room: Room): Promise<RTCStatsReport[]> {
try {
const engine = room.engine as unknown as Record<string, unknown>;
const pcManager = engine.pcManager as
@@ -103,8 +101,10 @@ function extractMetrics(reports: RTCStatsReport[]): {
rtt = rawRtt * 1000;
}
// Use max across candidate-pairs (avoid double-counting across PCs)
if (typeof entry.bytesSent === "number" && entry.bytesSent > totalUp) totalUp = entry.bytesSent;
if (typeof entry.bytesReceived === "number" && entry.bytesReceived > totalDown) totalDown = entry.bytesReceived;
if (typeof entry.bytesSent === "number" && entry.bytesSent > totalUp)
totalUp = entry.bytesSent;
if (typeof entry.bytesReceived === "number" && entry.bytesReceived > totalDown)
totalDown = entry.bytesReceived;
}
if (entry.type === "outbound-rtp") {
@@ -122,14 +122,14 @@ function extractMetrics(reports: RTCStatsReport[]): {
return { rtt, totalUp, totalDown, outPackets, inPackets, outBytes, inBytes };
}
export function createConnectionStatsPoller(
getRoom: () => Room | null,
): ConnectionStatsPoller {
export function createConnectionStatsPoller(getRoom: () => Room | null): ConnectionStatsPoller {
let current: ConnectionStats = EMPTY_STATS;
let prev: PrevSnapshot = { timestamp: Date.now(), outBytes: 0, inBytes: 0 };
let intervalId: ReturnType<typeof setInterval> | null = null;
const listeners = new Set<(stats: ConnectionStats) => void>();
const qualityChangeListeners = new Set<(quality: QualityLevel, prevQuality: QualityLevel) => void>();
const qualityChangeListeners = new Set<
(quality: QualityLevel, prevQuality: QualityLevel) => void
>();
let lastQuality: QualityLevel = "excellent";
let qualityDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const QUALITY_DEBOUNCE_MS = 3000;
@@ -205,9 +205,13 @@ export function createConnectionStatsPoller(
};
}
function onQualityChanged(cb: (quality: QualityLevel, prevQuality: QualityLevel) => void): () => void {
function onQualityChanged(
cb: (quality: QualityLevel, prevQuality: QualityLevel) => void,
): () => void {
qualityChangeListeners.add(cb);
return () => { qualityChangeListeners.delete(cb); };
return () => {
qualityChangeListeners.delete(cb);
};
}
return { start, stop, getStats, onUpdate, onQualityChanged };
+1 -3
View File
@@ -54,9 +54,7 @@ export async function saveCredential(
* Load a credential from Windows Credential Manager.
* Returns null if not found or Tauri unavailable.
*/
export async function loadCredential(
host: string,
): Promise<SavedCredential | null> {
export async function loadCredential(host: string): Promise<SavedCredential | null> {
const invoke = await getInvoke();
if (!invoke) {
return null;
+2 -2
View File
@@ -78,7 +78,7 @@ export class DeviceManager {
const savedInput = loadPref<string>("audioInputDevice", "");
// Check if the saved input device was removed
if (savedInput !== "" && !devices.some(d => d.deviceId === savedInput)) {
if (savedInput !== "" && !devices.some((d) => d.deviceId === savedInput)) {
log.warn("Saved audio input device removed — falling back to default", { savedInput });
// Reset to default
savePref("audioInputDevice", "");
@@ -102,7 +102,7 @@ export class DeviceManager {
// Check output device
const outputDevices = await Room.getLocalDevices("audiooutput");
const savedOutput = loadPref<string>("audioOutputDevice", "");
if (savedOutput !== "" && !outputDevices.some(d => d.deviceId === savedOutput)) {
if (savedOutput !== "" && !outputDevices.some((d) => d.deviceId === savedOutput)) {
log.warn("Saved audio output device removed — falling back to default", { savedOutput });
savePref("audioOutputDevice", "");
this.onToast?.("Audio output device disconnected — switched to default");
+5 -17
View File
@@ -87,12 +87,7 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
unsubs.push(
ws.on(S.AUTH_OK, (payload) => {
setAuth(
authStore.getState().token ?? "",
payload.user,
payload.server_name,
payload.motd,
);
setAuth(authStore.getState().token ?? "", payload.user, payload.server_name, payload.motd);
}),
);
@@ -118,8 +113,8 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
// send voice_leave to clean up the stale state. The server should
// have already cleaned this up, but this handles edge cases.
const currentUserId = authStore.getState().user?.id ?? 0;
const inVoicePerReady = currentUserId !== 0 &&
payload.voice_states.some((vs) => vs.user_id === currentUserId);
const inVoicePerReady =
currentUserId !== 0 && payload.voice_states.some((vs) => vs.user_id === currentUserId);
if (inVoicePerReady && !isVoiceConnected()) {
log.warn("Stale voice state detected in ready payload — sending voice_leave");
ws.send({ type: "voice_leave", payload: {} });
@@ -176,9 +171,7 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
user: payload.user.username,
});
addMessage(payload);
const activeId = channelsStore.select(
(s) => s.activeChannelId,
);
const activeId = channelsStore.select((s) => s.activeChannelId);
// Check if this is a DM channel and whether the message is from self.
const dmChannels = dmStore.getState().channels;
@@ -209,12 +202,7 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
payload.timestamp,
);
} else {
updateDmLastMessage(
payload.channel_id,
payload.id,
payload.content,
payload.timestamp,
);
updateDmLastMessage(payload.channel_id, payload.id, payload.content, payload.timestamp);
}
}
+1 -4
View File
@@ -51,10 +51,7 @@ export function setText(el: Element, text: string): void {
/**
* Append multiple children to a parent element.
*/
export function appendChildren(
parent: Element,
...children: (Element | string)[]
): void {
export function appendChildren(parent: Element, ...children: (Element | string)[]): void {
for (const child of children) {
if (typeof child === "string") {
parent.appendChild(document.createTextNode(child));
+1 -5
View File
@@ -244,11 +244,7 @@ export function createIcon(name: IconName, size = 24): SVGSVGElement {
/** Create a signal-strength icon with per-bar coloring based on quality level.
* Bars are colored by the quality thresholds; unfilled bars use --bg-active. */
export function createSignalIcon(
barsLit: number,
color: string,
size = 16,
): SVGSVGElement {
export function createSignalIcon(barsLit: number, color: string, size = 16): SVGSVGElement {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("width", String(size));
svg.setAttribute("height", String(size));
+4 -20
View File
@@ -5,14 +5,7 @@
// Rotation: keeps the most recent MAX_LOG_FILES days of logs.
import { appLogDir, join } from "@tauri-apps/api/path";
import {
mkdir,
writeTextFile,
readDir,
remove,
exists,
readTextFile,
} from "@tauri-apps/plugin-fs";
import { mkdir, writeTextFile, readDir, remove, exists, readTextFile } from "@tauri-apps/plugin-fs";
import { type LogEntry, addLogListener, createLogger } from "./logger";
const log = createLogger("logPersistence");
@@ -95,18 +88,12 @@ async function rotateOldFiles(): Promise<void> {
try {
const entries = await readDir(logDir);
const jsonlFiles = entries
.filter(
(e) =>
e.name?.endsWith(".jsonl") && !e.isDirectory,
)
.filter((e) => e.name?.endsWith(".jsonl") && !e.isDirectory)
.map((e) => e.name)
.sort();
if (jsonlFiles.length > MAX_LOG_FILES) {
const toRemove = jsonlFiles.slice(
0,
jsonlFiles.length - MAX_LOG_FILES,
);
const toRemove = jsonlFiles.slice(0, jsonlFiles.length - MAX_LOG_FILES);
for (const file of toRemove) {
await remove(`${logDir}/${file}`);
}
@@ -193,10 +180,7 @@ export async function readAllPersistedLogs(): Promise<string> {
try {
const entries = await readDir(logDir);
const jsonlFiles = entries
.filter(
(e) =>
e.name?.endsWith(".jsonl") && !e.isDirectory,
)
.filter((e) => e.name?.endsWith(".jsonl") && !e.isDirectory)
.map((e) => e.name)
.sort();
+1 -3
View File
@@ -36,9 +36,7 @@ function serializeData(data: unknown): unknown {
if (typeof data === "object" && data !== null) {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
result[key] = value instanceof Error
? { error: value.message, stack: value.stack }
: value;
result[key] = value instanceof Error ? { error: value.message, stack: value.stack } : value;
}
return result;
}
+14 -12
View File
@@ -68,9 +68,7 @@ export function createModal(
const overlay = createElement("div", overlayBaseAttrs);
// Build modal container
const modalClass = className !== undefined
? `modal ${className}`
: "modal";
const modalClass = className !== undefined ? `modal ${className}` : "modal";
const modal = createElement("div", { class: modalClass });
modal.appendChild(content);
overlay.appendChild(modal);
@@ -115,16 +113,20 @@ export function createModal(
// If an external signal is provided, clean up when it aborts
if (signal !== undefined) {
signal.addEventListener("abort", () => {
if (!closed) {
closed = true;
overlay.remove();
onClose?.();
if (!ac.signal.aborted) {
ac.abort();
signal.addEventListener(
"abort",
() => {
if (!closed) {
closed = true;
overlay.remove();
onClose?.();
if (!ac.signal.aborted) {
ac.abort();
}
}
}
}, { signal: ac.signal });
},
{ signal: ac.signal },
);
}
container.appendChild(overlay);
@@ -53,9 +53,11 @@ async function loadRNNoise(): Promise<RNNoiseModule> {
/** Check if AudioWorklet is available in this browser context. */
function supportsAudioWorklet(): boolean {
try {
return typeof AudioWorkletNode !== "undefined"
&& typeof AudioContext !== "undefined"
&& "audioWorklet" in AudioContext.prototype;
return (
typeof AudioWorkletNode !== "undefined" &&
typeof AudioContext !== "undefined" &&
"audioWorklet" in AudioContext.prototype
);
} catch {
return false;
}
@@ -127,7 +129,7 @@ async function createScriptProcessorPipeline(
let inputRingOffset = 0;
const OUT_RING_CAPACITY = 50;
const outRing: Float32Array[] = new Array(OUT_RING_CAPACITY);
const outRing: Float32Array[] = Array.from({ length: OUT_RING_CAPACITY });
let outWriteIdx = 0;
let outReadIdx = 0;
let outCount = 0;
+12 -4
View File
@@ -19,7 +19,11 @@ export function syncOsMotionListener(enabled: boolean): void {
const raw = localStorage.getItem("owncord:settings:reducedMotion");
let manual = false;
if (raw !== null) {
try { manual = JSON.parse(raw) === true; } catch { /* corrupted — default false */ }
try {
manual = JSON.parse(raw) === true;
} catch {
/* corrupted — default false */
}
}
document.documentElement.classList.toggle("reduced-motion", manual);
return;
@@ -28,7 +32,11 @@ export function syncOsMotionListener(enabled: boolean): void {
ac = new AbortController();
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
document.documentElement.classList.toggle("reduced-motion", mq.matches);
mq.addEventListener("change", (e: MediaQueryListEvent) => {
document.documentElement.classList.toggle("reduced-motion", e.matches);
}, { signal: ac.signal });
mq.addEventListener(
"change",
(e: MediaQueryListEvent) => {
document.documentElement.classList.toggle("reduced-motion", e.matches);
},
{ signal: ac.signal },
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { Permission } from './types';
import { Permission } from "./types";
/** Bitmask with every permission bit set. */
const ALL_PERMISSIONS = 0x7FFFFFFF;
const ALL_PERMISSIONS = 0x7fffffff;
/**
* Returns true if `userPerms` includes the given permission bit.
+3 -6
View File
@@ -40,8 +40,7 @@ export const ServerMessageType = {
DM_CHANNEL_CLOSE: "dm_channel_close",
} as const;
export type ServerMessageTypeValue =
(typeof ServerMessageType)[keyof typeof ServerMessageType];
export type ServerMessageTypeValue = (typeof ServerMessageType)[keyof typeof ServerMessageType];
// ---------------------------------------------------------------------------
// Client → Server message types
@@ -68,8 +67,7 @@ export const ClientMessageType = {
VOICE_TOKEN_REFRESH: "voice_token_refresh",
} as const;
export type ClientMessageTypeValue =
(typeof ClientMessageType)[keyof typeof ClientMessageType];
export type ClientMessageTypeValue = (typeof ClientMessageType)[keyof typeof ClientMessageType];
// ---------------------------------------------------------------------------
// Unified MessageType — all message types in one object for convenience
@@ -80,5 +78,4 @@ export const MessageType = {
...ClientMessageType,
} as const;
export type MessageTypeValue =
(typeof MessageType)[keyof typeof MessageType];
export type MessageTypeValue = (typeof MessageType)[keyof typeof MessageType];
+48 -15
View File
@@ -16,20 +16,53 @@ let pttUnsubscribe: (() => void) | null = null;
// Well-known virtual key code names for display
const VK_NAMES: ReadonlyMap<number, string> = new Map([
[0x01, "Mouse Left"], [0x02, "Mouse Right"], [0x04, "Mouse Middle"],
[0x05, "Mouse 4"], [0x06, "Mouse 5"],
[0x08, "Backspace"], [0x09, "Tab"], [0x0D, "Enter"], [0x1B, "Escape"],
[0x20, "Space"], [0x21, "Page Up"], [0x22, "Page Down"],
[0x23, "End"], [0x24, "Home"],
[0x25, "Arrow Left"], [0x26, "Arrow Up"], [0x27, "Arrow Right"], [0x28, "Arrow Down"],
[0x2D, "Insert"], [0x2E, "Delete"],
[0x70, "F1"], [0x71, "F2"], [0x72, "F3"], [0x73, "F4"],
[0x74, "F5"], [0x75, "F6"], [0x76, "F7"], [0x77, "F8"],
[0x78, "F9"], [0x79, "F10"], [0x7A, "F11"], [0x7B, "F12"],
[0x7C, "F13"], [0x7D, "F14"], [0x7E, "F15"], [0x7F, "F16"],
[0xC0, "`"], [0xBD, "-"], [0xBB, "="],
[0xDB, "["], [0xDD, "]"], [0xDC, "\\"],
[0xBA, ";"], [0xDE, "'"], [0xBC, ","], [0xBE, "."], [0xBF, "/"],
[0x01, "Mouse Left"],
[0x02, "Mouse Right"],
[0x04, "Mouse Middle"],
[0x05, "Mouse 4"],
[0x06, "Mouse 5"],
[0x08, "Backspace"],
[0x09, "Tab"],
[0x0d, "Enter"],
[0x1b, "Escape"],
[0x20, "Space"],
[0x21, "Page Up"],
[0x22, "Page Down"],
[0x23, "End"],
[0x24, "Home"],
[0x25, "Arrow Left"],
[0x26, "Arrow Up"],
[0x27, "Arrow Right"],
[0x28, "Arrow Down"],
[0x2d, "Insert"],
[0x2e, "Delete"],
[0x70, "F1"],
[0x71, "F2"],
[0x72, "F3"],
[0x73, "F4"],
[0x74, "F5"],
[0x75, "F6"],
[0x76, "F7"],
[0x77, "F8"],
[0x78, "F9"],
[0x79, "F10"],
[0x7a, "F11"],
[0x7b, "F12"],
[0x7c, "F13"],
[0x7d, "F14"],
[0x7e, "F15"],
[0x7f, "F16"],
[0xc0, "`"],
[0xbd, "-"],
[0xbb, "="],
[0xdb, "["],
[0xdd, "]"],
[0xdc, "\\"],
[0xba, ";"],
[0xde, "'"],
[0xbc, ","],
[0xbe, "."],
[0xbf, "/"],
]);
/** Get a human-readable name for a virtual key code. */
@@ -38,7 +71,7 @@ export function vkName(vk: number): string {
// 0-9 keys
if (vk >= 0x30 && vk <= 0x39) return String.fromCharCode(vk);
// A-Z keys
if (vk >= 0x41 && vk <= 0x5A) return String.fromCharCode(vk);
if (vk >= 0x41 && vk <= 0x5a) return String.fromCharCode(vk);
// Numpad 0-9
if (vk >= 0x60 && vk <= 0x69) return `Numpad ${vk - 0x60}`;
return `Key 0x${vk.toString(16).toUpperCase()}`;
+2 -5
View File
@@ -16,10 +16,7 @@ export interface MountableComponent {
* Safely mount a component, catching any errors during rendering.
* On failure, displays a fallback UI instead of crashing the app.
*/
export function safeMount(
component: MountableComponent,
container: Element,
): void {
export function safeMount(component: MountableComponent, container: Element): void {
try {
component.mount(container);
} catch (err) {
@@ -67,7 +64,7 @@ export function installGlobalErrorHandlers(): void {
window.addEventListener("unhandledrejection", (event) => {
const reason =
event.reason instanceof Error
? event.reason.stack ?? event.reason.message
? (event.reason.stack ?? event.reason.message)
: String(event.reason);
// Tauri plugin-http GC cleanup: when a consumed Response body is finalized,
+30 -20
View File
@@ -122,11 +122,15 @@ function showPreview(
}
// Screen reader announcement
const announcement = createElement("span", {
role: "status",
"aria-live": "polite",
class: "sr-only",
}, `Showing stream preview for ${username}`);
const announcement = createElement(
"span",
{
role: "status",
"aria-live": "polite",
class: "sr-only",
},
`Showing stream preview for ${username}`,
);
previewDiv.appendChild(announcement);
// Close when mouse leaves the preview div (but not if moving back to row)
@@ -177,7 +181,8 @@ function hidePreview(row: HTMLElement): void {
// Preview is a sibling after the row
const next = row.nextElementSibling;
const previewDiv = (next !== null && next.classList.contains("vu-preview")) ? next as HTMLElement : null;
const previewDiv =
next !== null && next.classList.contains("vu-preview") ? (next as HTMLElement) : null;
if (previewDiv === null) {
previewTimers.delete(row);
return;
@@ -236,7 +241,11 @@ export function attachStreamPreview(
state.animation = window.setTimeout(() => {
// Check if mouse is now over the preview sibling
const preview = row.nextElementSibling;
if (preview !== null && preview.classList.contains("vu-preview") && preview.matches(":hover")) {
if (
preview !== null &&
preview.classList.contains("vu-preview") &&
preview.matches(":hover")
) {
return; // Mouse moved to preview — keep it open
}
hidePreview(row);
@@ -266,18 +275,19 @@ export function attachStreamPreview(
* any open previews when the user scrolls. WebView2 doesn't always
* fire mouseleave on scroll.
*/
export function attachScrollCollapse(
container: HTMLElement,
signal: AbortSignal,
): void {
container.addEventListener("scroll", () => {
const openPreviews = container.querySelectorAll<HTMLElement>(".vu-preview");
for (const preview of openPreviews) {
// Preview is a sibling after the row — get the preceding voice-user-item
const row = preview.previousElementSibling;
if (row !== null && row.classList.contains("voice-user-item")) {
hidePreview(row as HTMLElement);
export function attachScrollCollapse(container: HTMLElement, signal: AbortSignal): void {
container.addEventListener(
"scroll",
() => {
const openPreviews = container.querySelectorAll<HTMLElement>(".vu-preview");
for (const preview of openPreviews) {
// Preview is a sibling after the row — get the preceding voice-user-item
const row = preview.previousElementSibling;
if (row !== null && row.classList.contains("voice-user-item")) {
hidePreview(row as HTMLElement);
}
}
}
}, { signal, passive: true });
},
{ signal, passive: true },
);
}
+3 -4
View File
@@ -4,7 +4,8 @@
// Tenor API key — defaults to Google's public anonymous test key from
// https://developers.google.com/tenor/guides/quickstart
// Override via VITE_TENOR_API_KEY at build time for production use.
const TENOR_API_KEY = import.meta.env.VITE_TENOR_API_KEY ?? "AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCYQ";
const TENOR_API_KEY =
import.meta.env.VITE_TENOR_API_KEY ?? "AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCYQ";
const TENOR_BASE = "https://tenor.googleapis.com/v2";
const DEFAULT_LIMIT = 20;
@@ -103,9 +104,7 @@ export async function searchGifs(
/**
* Fetch currently trending GIFs from Tenor.
*/
export async function getTrendingGifs(
limit: number = DEFAULT_LIMIT,
): Promise<readonly TenorGif[]> {
export async function getTrendingGifs(limit: number = DEFAULT_LIMIT): Promise<readonly TenorGif[]> {
const params = new URLSearchParams({
key: TENOR_API_KEY,
limit: String(limit),
+5 -10
View File
@@ -43,6 +43,7 @@ export function listThemeNames(): readonly string[] {
*/
export function applyThemeByName(name: string): void {
// Remove all existing theme- classes
// oxlint-disable-next-line no-useless-spread -- snapshot needed: classList mutates during iteration
for (const cls of [...document.body.classList]) {
if (cls.startsWith("theme-")) {
document.body.classList.remove(cls);
@@ -100,11 +101,7 @@ export function getActiveThemeName(): string {
const legacyRaw = localStorage.getItem(STORAGE_KEY_LEGACY);
if (legacyRaw !== null) {
const legacyName: unknown = JSON.parse(legacyRaw);
if (
typeof legacyName === "string" &&
legacyName.length > 0 &&
isKnownThemeName(legacyName)
) {
if (typeof legacyName === "string" && legacyName.length > 0 && isKnownThemeName(legacyName)) {
localStorage.setItem(STORAGE_KEY_ACTIVE, legacyName);
return legacyName;
}
@@ -118,10 +115,7 @@ export function getActiveThemeName(): string {
/** Persists a custom theme to localStorage. */
export function saveCustomTheme(theme: OwnCordTheme): void {
localStorage.setItem(
STORAGE_KEY_CUSTOM_PREFIX + theme.name,
JSON.stringify(theme),
);
localStorage.setItem(STORAGE_KEY_CUSTOM_PREFIX + theme.name, JSON.stringify(theme));
}
/** Loads a custom theme by name, or null if not found / parse error / invalid shape. */
@@ -131,7 +125,8 @@ export function loadCustomTheme(name: string): OwnCordTheme | null {
try {
const parsed: unknown = JSON.parse(raw);
if (
typeof parsed !== "object" || parsed === null ||
typeof parsed !== "object" ||
parsed === null ||
typeof (parsed as Record<string, unknown>).name !== "string" ||
typeof (parsed as Record<string, unknown>).colors !== "object"
) {
+1 -5
View File
@@ -28,10 +28,6 @@ export function teardownToast(): void {
* Show a toast notification globally. No-ops silently if the toast
* container has not been initialized yet.
*/
export function showToast(
message: string,
type: ToastType = "info",
durationMs?: number,
): void {
export function showToast(message: string, type: ToastType = "info", durationMs?: number): void {
instance?.show(message, type, durationMs);
}
+97 -80
View File
@@ -136,7 +136,17 @@ let currentPage: { destroy?(): void } | null = null;
/** Run health checks for a list of profiles and update the connect page. */
function runHealthChecks(
connectPage: { updateHealthStatus(host: string, status: { status: string; latencyMs: number | null; version: string | null; onlineUsers: number | null }): void },
connectPage: {
updateHealthStatus(
host: string,
status: {
status: string;
latencyMs: number | null;
version: string | null;
onlineUsers: number | null;
},
): void;
},
profiles: readonly { host: string }[],
): void {
for (const profile of profiles) {
@@ -190,14 +200,16 @@ function renderPage(pageId: "connect" | "main"): void {
log.info("Dispatcher wired, connecting WS");
// Save credential for auto-reconnect. Warn user if it fails.
saveCredential(host, username, token, password).then((ok) => {
if (!ok) {
log.warn("Credential save failed — auto-login will not work for this server");
setTransientError("Could not save credentials — auto-login won't work");
}
}).catch(() => {
// saveCredential already catches internally; this is defence-in-depth
});
saveCredential(host, username, token, password)
.then((ok) => {
if (!ok) {
log.warn("Credential save failed — auto-login will not work for this server");
setTransientError("Could not save credentials — auto-login won't work");
}
})
.catch(() => {
// saveCredential already catches internally; this is defence-in-depth
});
const unsubState = ws.onStateChange((wsState) => {
log.debug("WS state change", { state: wsState });
@@ -232,7 +244,12 @@ function renderPage(pageId: "connect" | "main"): void {
if (pageId === "connect") {
// Helper to get the profile list for the ConnectPage
function getProfileList(): readonly { name: string; host: string; id?: string; username?: string }[] {
function getProfileList(): readonly {
name: string;
host: string;
id?: string;
username?: string;
}[] {
const saved = profileManager.getAll();
if (saved.length > 0) return saved;
// Fallback: show a default local server entry
@@ -260,78 +277,81 @@ function renderPage(pageId: "connect" | "main"): void {
void profileManager.saveProfiles();
}
const connectPage = createConnectPage({
async onLogin(host, username, password) {
api.setConfig({ host });
const result = await api.login(username, password);
if (result.requires_2fa) {
pendingTotpHost = host;
pendingTotpPartialToken = result.partial_token ?? "";
pendingTotpUsername = username;
connectPage.showTotp();
return;
}
if (result.token) {
const connectPage = createConnectPage(
{
async onLogin(host, username, password) {
api.setConfig({ host });
const result = await api.login(username, password);
if (result.requires_2fa) {
pendingTotpHost = host;
pendingTotpPartialToken = result.partial_token ?? "";
pendingTotpUsername = username;
connectPage.showTotp();
return;
}
if (result.token) {
const remember = connectPage.getRememberPassword();
const savedPassword = remember ? password : undefined;
ensureProfileExists(host, username, remember);
wirePostAuth(host, result.token, username, savedPassword);
}
},
async onRegister(host, username, password, inviteCode) {
api.setConfig({ host });
const result = await api.register(username, password, inviteCode);
const remember = connectPage.getRememberPassword();
const savedPassword = remember ? password : undefined;
ensureProfileExists(host, username, remember);
wirePostAuth(host, result.token, username, savedPassword);
}
},
async onRegister(host, username, password, inviteCode) {
api.setConfig({ host });
const result = await api.register(username, password, inviteCode);
const remember = connectPage.getRememberPassword();
const savedPassword = remember ? password : undefined;
ensureProfileExists(host, username, remember);
wirePostAuth(host, result.token, username, savedPassword);
},
async onTotpSubmit(code) {
if (!pendingTotpPartialToken) {
log.error("TOTP submit without pending partial token");
return;
}
try {
const result = await api.verifyTotp(code, pendingTotpPartialToken);
if (result.token) {
const remember = connectPage.getRememberPassword();
const savedPassword = remember ? connectPage.getPassword() : undefined;
ensureProfileExists(pendingTotpHost, pendingTotpUsername, remember);
wirePostAuth(pendingTotpHost, result.token, pendingTotpUsername, savedPassword);
},
async onTotpSubmit(code) {
if (!pendingTotpPartialToken) {
log.error("TOTP submit without pending partial token");
return;
}
} finally {
// Clear sensitive partial token immediately after use (success or failure)
pendingTotpPartialToken = "";
}
try {
const result = await api.verifyTotp(code, pendingTotpPartialToken);
if (result.token) {
const remember = connectPage.getRememberPassword();
const savedPassword = remember ? connectPage.getPassword() : undefined;
ensureProfileExists(pendingTotpHost, pendingTotpUsername, remember);
wirePostAuth(pendingTotpHost, result.token, pendingTotpUsername, savedPassword);
}
} finally {
// Clear sensitive partial token immediately after use (success or failure)
pendingTotpPartialToken = "";
}
},
onAddProfile(name, host) {
profileManager.addProfile({
name,
host,
username: "",
autoConnect: false,
rememberPassword: false,
color: "#5865F2",
});
void profileManager.saveProfiles();
connectPage.refreshProfiles(getProfileList());
// Check health for the new profile
runHealthChecks(connectPage, getProfileList());
},
onDeleteProfile(profileId) {
profileManager.removeProfile(profileId);
void profileManager.saveProfiles();
connectPage.refreshProfiles(getProfileList());
},
onToggleAutoLogin(profileId, enabled) {
profileManager.setAutoLogin(enabled ? profileId : null);
void profileManager.saveProfiles();
connectPage.refreshProfiles(getProfileList());
},
onAutoLoginCancel() {
autoLoginCancelled = true;
},
},
onAddProfile(name, host) {
profileManager.addProfile({
name,
host,
username: "",
autoConnect: false,
rememberPassword: false,
color: "#5865F2",
});
void profileManager.saveProfiles();
connectPage.refreshProfiles(getProfileList());
// Check health for the new profile
runHealthChecks(connectPage, getProfileList());
},
onDeleteProfile(profileId) {
profileManager.removeProfile(profileId);
void profileManager.saveProfiles();
connectPage.refreshProfiles(getProfileList());
},
onToggleAutoLogin(profileId, enabled) {
profileManager.setAutoLogin(enabled ? profileId : null);
void profileManager.saveProfiles();
connectPage.refreshProfiles(getProfileList());
},
onAutoLoginCancel() {
autoLoginCancelled = true;
},
}, getProfileList());
getProfileList(),
);
let autoLoginCancelled = false;
@@ -368,10 +388,7 @@ function renderPage(pageId: "connect" | "main"): void {
if (quickSwitchTarget !== null) {
sessionStorage.removeItem("owncord:quick-switch-target");
const targetProfile = profileManager.getAll().find((p) => p.host === quickSwitchTarget);
connectPage.selectServer(
quickSwitchTarget,
targetProfile?.username ?? undefined,
);
connectPage.selectServer(quickSwitchTarget, targetProfile?.username ?? undefined);
return; // Skip auto-login when switching servers
}
+6 -7
View File
@@ -22,12 +22,7 @@ import type { SimpleProfile } from "./connect-page/ServerPanel";
/** Callbacks for external wiring (API integration added later). */
export interface ConnectPageCallbacks {
onLogin(host: string, username: string, password: string): Promise<void>;
onRegister(
host: string,
username: string,
password: string,
inviteCode: string,
): Promise<void>;
onRegister(host: string, username: string, password: string, inviteCode: string): Promise<void>;
onTotpSubmit(code: string): Promise<void>;
onAddProfile?(name: string, host: string): void;
onDeleteProfile?(profileId: string): void;
@@ -184,7 +179,11 @@ export function createConnectPage(
branding.appendChild(logoSvg);
const brandName = createElement("div", { class: "brand-name" }, "OwnCord");
const brandTag = createElement("div", { class: "brand-tagline" }, "Self-hosted chat \u2014 Your server, your rules");
const brandTag = createElement(
"div",
{ class: "brand-tagline" },
"Self-hosted chat \u2014 Your server, your rules",
);
appendChildren(branding, brandName, brandTag);
serverPanel.element.insertBefore(branding, serverPanel.element.firstChild);
@@ -1,12 +1,7 @@
// LoginForm — login/register form sub-component for ConnectPage.
// Pure extraction from ConnectPage.ts. No behavior changes.
import {
createElement,
setText,
appendChildren,
qs,
} from "@lib/dom";
import { createElement, setText, appendChildren, qs } from "@lib/dom";
import { createIcon } from "@lib/icons";
// ---------------------------------------------------------------------------
@@ -128,9 +123,16 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
const grad = document.createElementNS("http://www.w3.org/2000/svg", "linearGradient");
grad.setAttribute("id", "oc-grad-form");
grad.setAttribute("x1", "0%"); grad.setAttribute("y1", "0%");
grad.setAttribute("x2", "100%"); grad.setAttribute("y2", "0%");
for (const [offset, color] of [["0%","#f97316"],["30%","#ec4899"],["65%","#8b5cf6"],["100%","#06b6d4"]] as const) {
grad.setAttribute("x1", "0%");
grad.setAttribute("y1", "0%");
grad.setAttribute("x2", "100%");
grad.setAttribute("y2", "0%");
for (const [offset, color] of [
["0%", "#f97316"],
["30%", "#ec4899"],
["65%", "#8b5cf6"],
["100%", "#06b6d4"],
] as const) {
const stop = document.createElementNS("http://www.w3.org/2000/svg", "stop");
stop.setAttribute("offset", offset);
stop.setAttribute("style", `stop-color:${color}`);
@@ -139,20 +141,34 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
const filter = document.createElementNS("http://www.w3.org/2000/svg", "filter");
filter.setAttribute("id", "oc-glow-form");
const blur = document.createElementNS("http://www.w3.org/2000/svg", "feGaussianBlur");
blur.setAttribute("stdDeviation", "4"); blur.setAttribute("result", "blur");
blur.setAttribute("stdDeviation", "4");
blur.setAttribute("result", "blur");
filter.appendChild(blur);
const comp = document.createElementNS("http://www.w3.org/2000/svg", "feComposite");
comp.setAttribute("in", "SourceGraphic"); comp.setAttribute("in2", "blur"); comp.setAttribute("operator", "over");
comp.setAttribute("in", "SourceGraphic");
comp.setAttribute("in2", "blur");
comp.setAttribute("operator", "over");
filter.appendChild(comp);
defs.appendChild(grad); defs.appendChild(filter);
defs.appendChild(grad);
defs.appendChild(filter);
logoSvg.appendChild(defs);
for (const [opacity, filterAttr] of [["0.4", "url(#oc-glow-form)"], [null, null]] as const) {
for (const [opacity, filterAttr] of [
["0.4", "url(#oc-glow-form)"],
[null, null],
] as const) {
const t = document.createElementNS("http://www.w3.org/2000/svg", "text");
t.setAttribute("x", "60"); t.setAttribute("y", "56"); t.setAttribute("text-anchor", "middle");
t.setAttribute("x", "60");
t.setAttribute("y", "56");
t.setAttribute("text-anchor", "middle");
t.setAttribute("font-family", "'Segoe UI',system-ui,sans-serif");
t.setAttribute("font-size", "68"); t.setAttribute("font-weight", "900");
t.setAttribute("fill", "url(#oc-grad-form)"); t.setAttribute("letter-spacing", "-4");
if (opacity) { t.setAttribute("opacity", opacity); t.setAttribute("class", "oc-glow-layer"); }
t.setAttribute("font-size", "68");
t.setAttribute("font-weight", "900");
t.setAttribute("fill", "url(#oc-grad-form)");
t.setAttribute("letter-spacing", "-4");
if (opacity) {
t.setAttribute("opacity", opacity);
t.setAttribute("class", "oc-glow-layer");
}
if (filterAttr) t.setAttribute("filter", filterAttr);
t.textContent = "OC";
logoSvg.appendChild(t);
@@ -192,10 +208,14 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
type: "checkbox",
id: "remember-password",
});
const rememberLabel = createElement("label", {
for: "remember-password",
class: "remember-password-label",
}, "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)
@@ -219,7 +239,16 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
toggleModeBtn = createElement("a", {}, "Need an account? Register");
formSwitch.appendChild(toggleModeBtn);
appendChildren(form, hostGroup, usernameGroup, passwordGroup, rememberGroup, inviteGroup, submitBtn, formSwitch);
appendChildren(
form,
hostGroup,
usernameGroup,
passwordGroup,
rememberGroup,
inviteGroup,
submitBtn,
formSwitch,
);
// Wire form events
form.addEventListener("submit", handleFormSubmit, { signal });
@@ -284,9 +313,13 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
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.");
const description = createElement(
"p",
{
class: "totp-subtitle",
},
"Enter the 6-digit code from your authenticator app.",
);
totpInput = createElement("input", {
class: "form-input",
@@ -298,15 +331,23 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
autocomplete: "one-time-code",
});
totpSubmitBtn = createElement("button", {
class: "btn-primary",
type: "button",
}, "Verify");
totpSubmitBtn = createElement(
"button",
{
class: "btn-primary",
type: "button",
},
"Verify",
);
const cancelBtn = createElement("button", {
class: "totp-back",
type: "button",
}, "Cancel");
const cancelBtn = createElement(
"button",
{
class: "totp-back",
type: "button",
},
"Cancel",
);
totpSubmitBtn.addEventListener("click", handleTotpSubmit, { signal });
cancelBtn.addEventListener("click", handleTotpCancel, { signal });
@@ -329,7 +370,9 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
}
function buildAutoConnectOverlay(): HTMLDivElement {
const overlay = createElement("div", { class: "auto-connect-overlay auto-connect-overlay--hidden" });
const overlay = createElement("div", {
class: "auto-connect-overlay auto-connect-overlay--hidden",
});
const card = createElement("div", { class: "auto-connect-card" });
const spinner = createElement("div", { class: "auto-connect-spinner" });
@@ -339,15 +382,23 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
const title = createElement("h2", { class: "auto-connect-title" }, "Auto-connecting...");
autoConnectServerName = createElement("span", { class: "auto-connect-server" });
const cancelBtn = createElement("button", {
class: "btn-ghost auto-connect-cancel",
type: "button",
}, "Cancel");
const cancelBtn = createElement(
"button",
{
class: "btn-ghost auto-connect-cancel",
type: "button",
},
"Cancel",
);
cancelBtn.addEventListener("click", () => {
transitionTo("idle");
onAutoLoginCancel?.();
}, { signal });
cancelBtn.addEventListener(
"click",
() => {
transitionTo("idle");
onAutoLoginCancel?.();
},
{ signal },
);
appendChildren(card, spinner, title, autoConnectServerName, cancelBtn);
overlay.appendChild(card);
@@ -389,7 +440,8 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
}
function updateSubmitButton(): void {
const isLoading = formState === "loading" || formState === "connecting" || formState === "auto-connecting";
const isLoading =
formState === "loading" || formState === "connecting" || formState === "auto-connecting";
submitBtn.disabled = isLoading;
submitBtn.classList.toggle("loading", isLoading);
@@ -451,7 +503,8 @@ export function createLoginForm(opts: LoginFormOptions): LoginFormApi {
}
function updateFormInputsDisabled(): void {
const disable = formState === "loading" || formState === "connecting" || formState === "auto-connecting";
const disable =
formState === "loading" || formState === "connecting" || formState === "auto-connecting";
hostInput.disabled = disable;
usernameInput.disabled = disable;
passwordInput.disabled = disable;
@@ -1,12 +1,7 @@
// 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 { createElement, setText, appendChildren, clearChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { HealthStatus, ServerProfile } from "@lib/profiles";
import { loadCredential } from "@lib/credentials";
@@ -23,8 +18,14 @@ export interface SimpleProfile {
/** Color palette for server icons. */
const ICON_COLORS = [
"#5865F2", "#57F287", "#FEE75C", "#EB459E", "#ED4245",
"#3BA55D", "#FAA61A", "#5865F2",
"#5865F2",
"#57F287",
"#FEE75C",
"#EB459E",
"#ED4245",
"#3BA55D",
"#FAA61A",
"#5865F2",
];
function getIconColor(name: string): string {
@@ -70,10 +71,20 @@ export function createServerPanel(
opts: ServerPanelOptions,
initialProfiles: readonly SimpleProfile[],
): ServerPanelApi {
const { signal, onServerClick, onCredentialLoaded, onAddProfile, onDeleteProfile, onToggleAutoLogin } = opts;
const {
signal,
onServerClick,
onCredentialLoaded,
onAddProfile,
onDeleteProfile,
onToggleAutoLogin,
} = opts;
// Map of host -> DOM elements for health status updates
const healthElements = new Map<string, { dot: HTMLDivElement; latency: HTMLSpanElement; onlineUsers: HTMLSpanElement }>();
const healthElements = new Map<
string,
{ dot: HTMLDivElement; latency: HTMLSpanElement; onlineUsers: HTMLSpanElement }
>();
// Cached DOM references
let serverListEl: HTMLDivElement;
@@ -310,17 +321,25 @@ export function createServerPanel(
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 });
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).key === "Enter") handleSave();
}, { signal });
hostAddrInput.addEventListener(
"keydown",
(e) => {
if (e.key === "Enter") handleSave();
},
{ signal },
);
// Mount onto the panel's closest connect-page root
const root = panelEl.closest(".connect-page") ?? document.body;
@@ -63,9 +63,7 @@ export interface ChannelController {
// Factory
// ---------------------------------------------------------------------------
export function createChannelController(
opts: ChannelControllerOptions,
): ChannelController {
export function createChannelController(opts: ChannelControllerOptions): ChannelController {
const {
ws,
api,
@@ -174,13 +172,15 @@ export function createChannelController(
const action = currentlyPinned
? api.unpinMessage(chId, msgId)
: api.pinMessage(chId, msgId);
action.then(() => {
setMessagePinned(chId, msgId, !currentlyPinned);
showToast(currentlyPinned ? "Message unpinned" : "Message pinned", "success");
}).catch((err) => {
log.error("Pin/unpin failed", { error: String(err) });
showToast("Failed to pin/unpin message", "error");
});
action
.then(() => {
setMessagePinned(chId, msgId, !currentlyPinned);
showToast(currentlyPinned ? "Message unpinned" : "Message pinned", "success");
})
.catch((err) => {
log.error("Pin/unpin failed", { error: String(err) });
showToast("Failed to pin/unpin message", "error");
});
},
});
messageList.mount(slots.messagesSlot);
@@ -251,18 +251,22 @@ export function createChannelController(
messageInput.mount(slots.inputSlot);
// Arrow-up edit: listen for edit-last-message bubbling from MessageInput
slots.inputSlot.addEventListener("edit-last-message", () => {
const msgs = getChannelMessages(channelId);
const myId = getCurrentUserId();
// Find the last message sent by the current user (array is chronological)
for (let i = msgs.length - 1; i >= 0; i--) {
const m = msgs[i]!;
if (m.user.id === myId && !m.deleted) {
messageInput?.startEdit(m.id, m.content);
break;
slots.inputSlot.addEventListener(
"edit-last-message",
() => {
const msgs = getChannelMessages(channelId);
const myId = getCurrentUserId();
// Find the last message sent by the current user (array is chronological)
for (let i = msgs.length - 1; i >= 0; i--) {
const m = msgs[i]!;
if (m.user.id === myId && !m.deleted) {
messageInput?.startEdit(m.id, m.content);
break;
}
}
}
}, { signal });
},
{ signal },
);
// Update header
if (chatHeaderRefs !== null && channelType === "dm") {
@@ -39,8 +39,11 @@ export function createMemberPickerModal(opts: MemberPickerOptions): MountableCom
// Build the content that goes inside the modal
const content = createElement("div", { style: "padding:20px;" });
const title = createElement("h3", {}, "New Direct Message");
const subtitle = createElement("p", { style: "color:var(--text-secondary);font-size:0.85rem;margin:0 0 8px;" },
"Select a member to start a conversation");
const subtitle = createElement(
"p",
{ style: "color:var(--text-secondary);font-size:0.85rem;margin:0 0 8px;" },
"Select a member to start a conversation",
);
const listContainer = createElement("div", {
class: "dm-member-picker-list",
style: "max-height:300px;overflow-y:auto;",
@@ -54,13 +57,18 @@ export function createMemberPickerModal(opts: MemberPickerOptions): MountableCom
});
const avatar = createElement("div", {
class: "dm-avatar",
style: "width:28px;height:28px;border-radius:50%;background:#5865F2;display:flex;align-items:center;justify-content:center;font-size:0.75rem;color:white;flex-shrink:0;",
style:
"width:28px;height:28px;border-radius:50%;background:#5865F2;display:flex;align-items:center;justify-content:center;font-size:0.75rem;color:white;flex-shrink:0;",
});
setText(avatar, member.username.charAt(0).toUpperCase());
const nameEl = createElement("span", {}, member.username);
const statusEl = createElement("span", {
style: `font-size:0.75rem;margin-left:auto;color:${member.status === "online" ? "var(--green)" : "var(--text-micro)"};`,
}, member.status);
const statusEl = createElement(
"span",
{
style: `font-size:0.75rem;margin-left:auto;color:${member.status === "online" ? "var(--green)" : "var(--text-micro)"};`,
},
member.status,
);
appendChildren(item, avatar, nameEl, statusEl);
item.addEventListener("click", () => {
@@ -72,10 +80,14 @@ export function createMemberPickerModal(opts: MemberPickerOptions): MountableCom
listContainer.appendChild(item);
}
const cancelBtn = createElement("button", {
class: "btn btn-secondary",
style: "margin-top:12px;width:100%;",
}, "Cancel");
const cancelBtn = createElement(
"button",
{
class: "btn btn-secondary",
style: "margin-top:12px;width:100%;",
},
"Cancel",
);
cancelBtn.addEventListener("click", () => {
if (modalInstance !== null) {
modalInstance.close();
@@ -67,15 +67,10 @@ export interface MessageController {
loadOlderMessages(channelId: number, signal: AbortSignal): Promise<void>;
}
export function createMessageController(
opts: MessageControllerOptions,
): MessageController {
export function createMessageController(opts: MessageControllerOptions): MessageController {
const { api, showError } = opts;
async function loadMessages(
channelId: number,
signal: AbortSignal,
): Promise<void> {
async function loadMessages(channelId: number, signal: AbortSignal): Promise<void> {
if (isChannelLoaded(channelId)) {
log.debug("Messages already loaded", { channelId });
return;
@@ -101,10 +96,7 @@ export function createMessageController(
}
}
async function loadOlderMessages(
channelId: number,
signal: AbortSignal,
): Promise<void> {
async function loadOlderMessages(channelId: number, signal: AbortSignal): Promise<void> {
const messages = getChannelMessages(channelId);
if (messages.length === 0) return;
const oldest = messages[0]!;
@@ -24,12 +24,11 @@ const log = createLogger("overlays");
export function mapInviteResponse(r: InviteResponse): InviteItem {
const extra = r as unknown as Record<string, unknown>;
const createdBy = typeof extra["created_by"] === "object"
&& extra["created_by"] !== null
? (extra["created_by"] as { username?: string }).username ?? "unknown"
: "unknown";
const uses = r.use_count
?? (typeof extra["uses"] === "number" ? (extra["uses"]) : 0);
const createdBy =
typeof extra["created_by"] === "object" && extra["created_by"] !== null
? ((extra["created_by"] as { username?: string }).username ?? "unknown")
: "unknown";
const uses = r.use_count ?? (typeof extra["uses"] === "number" ? extra["uses"] : 0);
return {
code: r.code,
createdBy,
@@ -135,7 +134,6 @@ export interface InviteManagerController {
export function createInviteManagerController(opts: {
readonly api: ApiClient;
readonly getRoot: () => HTMLDivElement | null;
}): InviteManagerController {
let instance: MountableComponent | null = null;
@@ -243,12 +241,15 @@ export function createPinnedPanelController(opts: {
}
},
onUnpin: (msgId: number) => {
void opts.api.unpinMessage(channelId, msgId).then(() => {
close();
}).catch((err: unknown) => {
log.error("Failed to unpin message", { msgId, error: String(err) });
showToast("Failed to unpin message", "error");
});
void opts.api
.unpinMessage(channelId, msgId)
.then(() => {
close();
})
.catch((err: unknown) => {
log.error("Failed to unpin message", { msgId, error: String(err) });
showToast("Failed to unpin message", "error");
});
},
onClose: close,
});
@@ -30,9 +30,7 @@ export interface ReactionController {
// Factory
// ---------------------------------------------------------------------------
export function createReactionController(
opts: ReactionControllerOptions,
): ReactionController {
export function createReactionController(opts: ReactionControllerOptions): ReactionController {
const { ws, reactionsLimiter, getChannelId, showError } = opts;
function sendReaction(msgId: number, emoji: string): void {
@@ -57,9 +55,7 @@ export function createReactionController(
}
function openPicker(msgId: number): void {
const reactBtn = document.querySelector(
`[data-testid="msg-react-${msgId}"]`,
);
const reactBtn = document.querySelector(`[data-testid="msg-react-${msgId}"]`);
if (reactBtn === null) return;
// Close any existing reaction picker (including proper cleanup)
@@ -32,10 +32,7 @@ export interface DmHelperDeps {
* Switch the UI to a specific DM conversation. Saves the current non-DM
* channel so it can be restored when the user navigates back.
*/
export function selectDmConversation(
dmChannel: DmChannel,
deps: DmHelperDeps,
): void {
export function selectDmConversation(dmChannel: DmChannel, deps: DmHelperDeps): void {
// Save current channel so we can restore it when user clicks "Back"
// Only save if the current channel is a real text/voice channel, not another DM
const currentActive = channelsStore.getState().activeChannelId;
@@ -88,10 +85,7 @@ export function addDmToChannelsStore(dmChannel: DmChannel): void {
// ---------------------------------------------------------------------------
/** Create a DM with a user via the API and switch to it. */
export async function handleCreateDm(
recipientId: number,
deps: DmHelperDeps,
): Promise<void> {
export async function handleCreateDm(recipientId: number, deps: DmHelperDeps): Promise<void> {
try {
const result = await deps.api.createDm(recipientId);
const member = membersStore.getState().members.get(recipientId);
@@ -41,7 +41,9 @@ export interface SidebarMemberSectionResult {
// Factory
// ---------------------------------------------------------------------------
export function createSidebarMemberSection(opts: SidebarMemberSectionOptions): SidebarMemberSectionResult {
export function createSidebarMemberSection(
opts: SidebarMemberSectionOptions,
): SidebarMemberSectionResult {
const { api, getToast } = opts;
const unsubs: Array<() => void> = [];
@@ -74,28 +76,42 @@ export function createSidebarMemberSection(opts: SidebarMemberSectionOptions): S
let startY = 0;
let startHeight = 0;
resizeHandle.addEventListener("mousedown", (e: MouseEvent) => {
isDragging = true;
startY = e.clientY;
startHeight = memberListContainer.offsetHeight;
e.preventDefault();
}, { signal: resizeAbort.signal });
resizeHandle.addEventListener(
"mousedown",
(e: MouseEvent) => {
isDragging = true;
startY = e.clientY;
startHeight = memberListContainer.offsetHeight;
e.preventDefault();
},
{ signal: resizeAbort.signal },
);
document.addEventListener("mousemove", (e: MouseEvent) => {
if (!isDragging) return;
const delta = startY - e.clientY;
const maxH = window.innerHeight * 0.65;
const newHeight = Math.max(80, Math.min(startHeight + delta, maxH));
memberListContainer.style.height = `${newHeight}px`;
}, { signal: resizeAbort.signal });
document.addEventListener(
"mousemove",
(e: MouseEvent) => {
if (!isDragging) return;
const delta = startY - e.clientY;
const maxH = window.innerHeight * 0.65;
const newHeight = Math.max(80, Math.min(startHeight + delta, maxH));
memberListContainer.style.height = `${newHeight}px`;
},
{ signal: resizeAbort.signal },
);
document.addEventListener("mouseup", () => {
if (!isDragging) return;
isDragging = false;
localStorage.setItem(LS_KEY_HEIGHT, String(memberListContainer.offsetHeight));
}, { signal: resizeAbort.signal });
document.addEventListener(
"mouseup",
() => {
if (!isDragging) return;
isDragging = false;
localStorage.setItem(LS_KEY_HEIGHT, String(memberListContainer.offsetHeight));
},
{ signal: resizeAbort.signal },
);
unsubs.push(() => { resizeAbort.abort(); });
unsubs.push(() => {
resizeAbort.abort();
});
// --- Collapse state ---
const savedCollapsed = localStorage.getItem(LS_KEY_COLLAPSED);
@@ -46,9 +46,7 @@ export interface VideoModeController {
// Factory
// ---------------------------------------------------------------------------
export function createVideoModeController(
opts: VideoModeControllerOptions,
): VideoModeController {
export function createVideoModeController(opts: VideoModeControllerOptions): VideoModeController {
const { slots, videoGrid, getCurrentUserId } = opts;
let videoMode = false;
/** Track whether we've already added the local self-view tile. */
@@ -5,11 +5,7 @@
import { createLogger } from "@lib/logger";
import type { WsClient } from "@lib/ws";
import {
voiceStore,
joinVoiceChannel,
leaveVoiceChannel,
} from "@stores/voice.store";
import { voiceStore, joinVoiceChannel, leaveVoiceChannel } from "@stores/voice.store";
import {
leaveVoice as voiceSessionLeave,
setMuted as voiceSessionSetMuted,
+1 -6
View File
@@ -28,12 +28,7 @@ const INITIAL_STATE: AuthState = {
export const authStore = createStore<AuthState>(INITIAL_STATE);
/** Populate auth state after a successful auth_ok message. */
export function setAuth(
token: string,
user: UserWithRole,
serverName: string,
motd: string,
): void {
export function setAuth(token: string, user: UserWithRole, serverName: string, motd: string): void {
authStore.setState(() => ({
token,
user,
+8 -4
View File
@@ -63,7 +63,13 @@ export function updateDmLastMessage(
const rest = prev.channels.filter((c) => c.channelId !== channelId);
return {
channels: [
{ ...updated, lastMessageId: messageId, lastMessage: content, lastMessageAt: timestamp, unreadCount: updated.unreadCount + 1 },
{
...updated,
lastMessageId: messageId,
lastMessage: content,
lastMessageAt: timestamp,
unreadCount: updated.unreadCount + 1,
},
...rest,
],
};
@@ -95,8 +101,6 @@ export function updateDmLastMessagePreview(
/** Clear unread count for a DM channel. */
export function clearDmUnread(channelId: number): void {
dmStore.setState((prev) => ({
channels: prev.channels.map((c) =>
c.channelId === channelId ? { ...c, unreadCount: 0 } : c,
),
channels: prev.channels.map((c) => (c.channelId === channelId ? { ...c, unreadCount: 0 } : c)),
}));
}
@@ -4,11 +4,7 @@
*/
import { createStore } from "@lib/store";
import type {
ReadyMember,
MemberJoinPayload,
UserStatus,
} from "@lib/types";
import type { ReadyMember, MemberJoinPayload, UserStatus } from "@lib/types";
export interface Member {
readonly id: number;
@@ -135,9 +135,10 @@ export function setMessages(
hasMore: boolean,
): void {
const converted = messages.map(messageResponseToMessage).reverse();
const trimmed = converted.length > MAX_MESSAGES_PER_CHANNEL
? converted.slice(converted.length - MAX_MESSAGES_PER_CHANNEL)
: converted;
const trimmed =
converted.length > MAX_MESSAGES_PER_CHANNEL
? converted.slice(converted.length - MAX_MESSAGES_PER_CHANNEL)
: converted;
messagesStore.setState((prev) => {
const updatedMessages = new Map(prev.messagesByChannel);
updatedMessages.set(channelId, trimmed);
@@ -223,11 +224,7 @@ export function deleteMessage(payload: ChatDeletedPayload): void {
}
/** Toggle the pinned state of a message (optimistic update after API call). */
export function setMessagePinned(
channelId: number,
messageId: number,
pinned: boolean,
): void {
export function setMessagePinned(channelId: number, messageId: number, pinned: boolean): void {
messagesStore.setState((prev) => {
const channelMessages = prev.messagesByChannel.get(channelId);
if (!channelMessages) return prev;
@@ -243,10 +240,7 @@ export function setMessagePinned(
}
/** Track a pending outbound message send. */
export function addPendingSend(
correlationId: string,
channelId: number,
): void {
export function addPendingSend(correlationId: string, channelId: number): void {
messagesStore.setState((prev) => {
const updated = new Map(prev.pendingSends);
updated.set(correlationId, channelId);
@@ -255,11 +249,7 @@ export function addPendingSend(
}
/** Confirm a pending send — remove from pending map. */
export function confirmSend(
correlationId: string,
_messageId: number,
_timestamp: string,
): void {
export function confirmSend(correlationId: string, _messageId: number, _timestamp: string): void {
messagesStore.setState((prev) => {
const updated = new Map(prev.pendingSends);
updated.delete(correlationId);
@@ -289,10 +279,7 @@ export function clearChannelMessages(channelId: number): void {
}
/** Update reactions on a message from a reaction_update WS event. */
export function updateReaction(
payload: ReactionUpdatePayload,
currentUserId: number,
): void {
export function updateReaction(payload: ReactionUpdatePayload, currentUserId: number): void {
messagesStore.setState((prev) => {
const channelMessages = prev.messagesByChannel.get(payload.channel_id);
if (!channelMessages) return prev;
@@ -307,9 +294,7 @@ export function updateReaction(
const found = existing.find((r) => r.emoji === payload.emoji);
if (found !== undefined) {
const updatedReactions = existing.map((r) =>
r.emoji === payload.emoji
? { ...r, count: r.count + 1, me: r.me || isMe }
: r,
r.emoji === payload.emoji ? { ...r, count: r.count + 1, me: r.me || isMe } : r,
);
return { ...msg, reactions: updatedReactions };
}
@@ -322,9 +307,7 @@ export function updateReaction(
// action === "remove"
const updatedReactions = existing
.map((r) =>
r.emoji === payload.emoji
? { ...r, count: r.count - 1, me: isMe ? false : r.me }
: r,
r.emoji === payload.emoji ? { ...r, count: r.count - 1, me: isMe ? false : r.me } : r,
)
.filter((r) => r.count > 0);
return { ...msg, reactions: updatedReactions };
@@ -342,9 +325,7 @@ export function updateReaction(
/** Get messages for a channel, or empty array if none loaded. */
export function getChannelMessages(channelId: number): readonly Message[] {
return messagesStore.select(
(s) => s.messagesByChannel.get(channelId) ?? [],
);
return messagesStore.select((s) => s.messagesByChannel.get(channelId) ?? []);
}
/** Check whether initial messages have been loaded for a channel. */
+2 -7
View File
@@ -92,9 +92,7 @@ export function setTheme(theme: "dark" | "neon-glow" | "midnight" | "light"): vo
}
/** Set the WebSocket connection status. */
export function setConnectionStatus(
status: "connected" | "reconnecting" | "disconnected",
): void {
export function setConnectionStatus(status: "connected" | "reconnecting" | "disconnected"): void {
uiStore.setState((prev) => ({
...prev,
connectionStatus: status,
@@ -152,10 +150,7 @@ export function loadCollapsedCategories(serverHost: string): void {
function saveCollapsedCategories(categories: ReadonlySet<string>): void {
if (currentServerHost === null) return;
try {
localStorage.setItem(
COLLAPSED_KEY_PREFIX + currentServerHost,
JSON.stringify([...categories]),
);
localStorage.setItem(COLLAPSED_KEY_PREFIX + currentServerHost, JSON.stringify([...categories]));
} catch {
// localStorage may be unavailable or full — silently ignore
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />