mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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()}`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user