1352 lines
42 KiB
JavaScript
1352 lines
42 KiB
JavaScript
"use strict";
|
|
|
|
const $ = (selector, root = document) => root.querySelector(selector);
|
|
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
|
|
|
const SENDABLE_TYPES = new Set([0, 1, 3, 5, 10, 11, 12]);
|
|
const TEXT_TYPES = new Set([0, 5]);
|
|
const VOICE_TYPES = new Set([2, 13]);
|
|
|
|
const state = {
|
|
user: null,
|
|
guilds: [],
|
|
guildId: null,
|
|
channelId: null,
|
|
channel: null,
|
|
messages: [],
|
|
dms: [],
|
|
voiceOverview: null,
|
|
voiceSelf: null,
|
|
health: null,
|
|
eventCount: 0,
|
|
lastEventSample: null,
|
|
eventRate: 0,
|
|
events: [],
|
|
visibleGuilds: null,
|
|
composerChannelId: null,
|
|
takeover: localStorage.getItem("veskslop-takeover") === "1",
|
|
consoleOpen: false,
|
|
consoleTab: "events",
|
|
voiceReloadAt: 0
|
|
};
|
|
|
|
/* Drafts survive re-renders and reloads. Nothing else is persisted client-side. */
|
|
|
|
const draftKey = channelId => `veskslop-draft:${channelId}`;
|
|
const TTS_DRAFT_KEY = "veskslop-tts-draft";
|
|
|
|
function isGuildVisible(guildId) {
|
|
return !state.visibleGuilds || state.visibleGuilds.has(guildId);
|
|
}
|
|
|
|
function visibleGuildsList() {
|
|
return state.guilds.filter(guild => isGuildVisible(guild.id));
|
|
}
|
|
|
|
function loadVisibleGuilds() {
|
|
try {
|
|
const raw = localStorage.getItem("veskslop-visible-guilds");
|
|
state.visibleGuilds = raw ? new Set(JSON.parse(raw)) : null;
|
|
} catch {
|
|
state.visibleGuilds = null;
|
|
}
|
|
}
|
|
|
|
function saveVisibleGuilds() {
|
|
if (!state.visibleGuilds || state.visibleGuilds.size === state.guilds.length) {
|
|
state.visibleGuilds = null;
|
|
localStorage.removeItem("veskslop-visible-guilds");
|
|
return;
|
|
}
|
|
localStorage.setItem("veskslop-visible-guilds", JSON.stringify([...state.visibleGuilds]));
|
|
}
|
|
|
|
/* Helpers */
|
|
|
|
function el(tag, props = {}, ...children) {
|
|
const node = document.createElement(tag);
|
|
|
|
for (const [key, value] of Object.entries(props)) {
|
|
if (value === undefined || value === null) continue;
|
|
if (key === "class") node.className = value;
|
|
else if (key === "text") node.textContent = value;
|
|
else if (key === "html") node.innerHTML = value;
|
|
else if (key.startsWith("on")) node.addEventListener(key.slice(2).toLowerCase(), value);
|
|
else if (key === "disabled") node.disabled = !!value;
|
|
else node.setAttribute(key, value);
|
|
}
|
|
|
|
for (const child of children.flat()) {
|
|
if (child === undefined || child === null) continue;
|
|
node.append(child.nodeType ? child : document.createTextNode(String(child)));
|
|
}
|
|
|
|
return node;
|
|
}
|
|
|
|
function icon(name, size = 13) {
|
|
const span = el("span", { class: "vk-icon" });
|
|
span.innerHTML = VK.icon(name, size);
|
|
return span;
|
|
}
|
|
|
|
function hydrateIcons(root = document) {
|
|
for (const span of $$("[data-icon]", root)) span.innerHTML = VK.icon(span.dataset.icon);
|
|
}
|
|
|
|
function toast(message, kind = "") {
|
|
const node = el("div", { class: `vk-toast ${kind}`, text: message });
|
|
$("#toasts").append(node);
|
|
setTimeout(() => node.remove(), 5000);
|
|
}
|
|
|
|
function monogram(name) {
|
|
const clean = String(name ?? "?").replace(/[^a-zA-Z0-9]/g, "");
|
|
return (clean[0] ?? "?").toUpperCase();
|
|
}
|
|
|
|
const CDN_BASE = "https://cdn.discordapp.com";
|
|
|
|
function cdnHashUrl(route, id, hash, size = 64) {
|
|
if (!hash) return null;
|
|
const ext = String(hash).startsWith("a_") ? "gif" : "png";
|
|
return `${CDN_BASE}/${route}/${id}/${hash}.${ext}?size=${size}`;
|
|
}
|
|
|
|
function defaultAvatarUrl(id) {
|
|
try {
|
|
return `${CDN_BASE}/embed/avatars/${Number(BigInt(String(id)) >> 22n) % 6}.png`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function userAvatarUrl(user, size = 64) {
|
|
if (!user?.id) return null;
|
|
return cdnHashUrl("avatars", user.id, user.avatar, size) ?? defaultAvatarUrl(user.id);
|
|
}
|
|
|
|
function guildIconUrl(guild, size = 64) {
|
|
return cdnHashUrl("icons", guild.id, guild.icon, size);
|
|
}
|
|
|
|
function dmIconUrl(dm, size = 64) {
|
|
if (dm?.icon) return cdnHashUrl("channel-icons", dm.id, dm.icon, size);
|
|
const recipient = dm?.recipients?.[0];
|
|
return recipient ? userAvatarUrl(recipient, size) : null;
|
|
}
|
|
|
|
function avatar(entity, { agent = false, size = "", url } = {}) {
|
|
const name = typeof entity === "string" ? entity : entity?.username ?? entity?.name ?? "?";
|
|
const resolved = url ?? (typeof entity === "object" && entity ? userAvatarUrl(entity) : null);
|
|
const node = el("span", { class: `vk-avatar ${agent ? "agent" : ""} ${size}`.trim() });
|
|
|
|
if (resolved) {
|
|
const img = el("img", { src: resolved, alt: "", loading: "lazy" });
|
|
img.addEventListener("error", () => {
|
|
img.remove();
|
|
node.textContent = monogram(name);
|
|
});
|
|
node.append(img);
|
|
} else {
|
|
node.textContent = monogram(name);
|
|
}
|
|
|
|
return node;
|
|
}
|
|
|
|
function dmAvatar(dm, opts = {}) {
|
|
return avatar(dmLabel(dm), { ...opts, url: dmIconUrl(dm) });
|
|
}
|
|
|
|
function guildMarkContent(mark, guild) {
|
|
const url = guildIconUrl(guild);
|
|
if (!url) {
|
|
mark.textContent = monogram(guild.name);
|
|
return;
|
|
}
|
|
|
|
const img = el("img", { src: url, alt: "", loading: "lazy" });
|
|
img.addEventListener("error", () => {
|
|
img.remove();
|
|
mark.textContent = monogram(guild.name);
|
|
});
|
|
mark.append(img);
|
|
}
|
|
|
|
function formatTime(timestamp) {
|
|
const date = new Date(timestamp);
|
|
return Number.isNaN(date.getTime()) ? "" : date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
}
|
|
|
|
function formatUptime(seconds) {
|
|
if (!Number.isFinite(seconds)) return "up —";
|
|
const days = Math.floor(seconds / 86400);
|
|
const hours = Math.floor((seconds % 86400) / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
if (days) return `up ${days}d ${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
|
|
if (hours) return `up ${hours}h ${String(minutes).padStart(2, "0")}m`;
|
|
return `up ${minutes}m`;
|
|
}
|
|
|
|
function currentGuild() {
|
|
return state.guilds.find(guild => guild.id === state.guildId) ?? null;
|
|
}
|
|
|
|
function findGuildChannel(channelId) {
|
|
for (const guild of state.guilds) {
|
|
const channel = guild.channels.find(item => item.id === channelId);
|
|
if (channel) return channel;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function dmById(channelId) {
|
|
return state.dms.find(dm => dm.id === channelId) ?? null;
|
|
}
|
|
|
|
function dmLabel(dm) {
|
|
if (dm.name) return dm.name;
|
|
const names = (dm.recipients ?? []).map(recipient => recipient.username);
|
|
if (!names.length) return "DM";
|
|
if (names.length <= 2) return names.join(", ");
|
|
return `${names[0]} + ${names.length - 1}`;
|
|
}
|
|
|
|
function channelLabel() {
|
|
if (!state.channel) return "No channel selected";
|
|
return state.channel.name ?? channelLabelFromDm();
|
|
}
|
|
|
|
function channelLabelFromDm() {
|
|
const dm = state.channelId ? dmById(state.channelId) : null;
|
|
return dm ? dmLabel(dm) : "Direct message";
|
|
}
|
|
|
|
/* Data loading */
|
|
|
|
async function loadState() {
|
|
let data;
|
|
try {
|
|
data = await VK.api.get("/api/state");
|
|
} catch {
|
|
renderIdentity();
|
|
renderGuildPicker();
|
|
renderGuildSwitcher();
|
|
return;
|
|
}
|
|
|
|
state.user = data.user ?? null;
|
|
state.guilds = data.guilds ?? [];
|
|
|
|
const visible = visibleGuildsList();
|
|
if (!state.guildId || !visible.some(guild => guild.id === state.guildId)) {
|
|
state.guildId = visible[0]?.id ?? null;
|
|
}
|
|
|
|
renderIdentity();
|
|
renderGuildPicker();
|
|
renderGuildSwitcher();
|
|
renderVisibleGuilds();
|
|
renderRail();
|
|
|
|
const guild = currentGuild();
|
|
if (!state.channelId && guild) {
|
|
const first = guild.channels.find(channel => SENDABLE_TYPES.has(channel.type));
|
|
if (first) await selectChannel(first.id, guild.id);
|
|
} else if (state.channelId) {
|
|
renderComposer();
|
|
}
|
|
}
|
|
|
|
async function loadDms() {
|
|
try {
|
|
const result = await VK.api.get("/api/dms");
|
|
state.dms = result.channels ?? [];
|
|
} catch {
|
|
state.dms = [];
|
|
}
|
|
renderDirect();
|
|
renderRightDms();
|
|
renderRail();
|
|
}
|
|
|
|
async function loadVoice() {
|
|
try {
|
|
state.voiceSelf = await VK.api.get("/api/voice");
|
|
} catch {
|
|
state.voiceSelf = null;
|
|
}
|
|
|
|
if (state.guildId) {
|
|
try {
|
|
const overview = await VK.api.get(`/api/voice/overview?guildId=${encodeURIComponent(state.guildId)}`);
|
|
state.voiceOverview = overview.channels ?? [];
|
|
} catch {
|
|
state.voiceOverview = [];
|
|
}
|
|
} else {
|
|
state.voiceOverview = [];
|
|
}
|
|
|
|
renderVoiceRail();
|
|
renderRightVoice();
|
|
renderCallStrip();
|
|
}
|
|
|
|
async function loadMessages({ refresh = false } = {}) {
|
|
if (!state.channelId) {
|
|
state.messages = [];
|
|
renderMessages();
|
|
return;
|
|
}
|
|
|
|
const channelId = state.channelId;
|
|
|
|
try {
|
|
// refresh fetches the latest 50 from Discord and fills the client cache.
|
|
// Attachments stay CDN links; nothing is downloaded or cached locally.
|
|
const result = await VK.api.get(
|
|
`/api/messages?channelId=${encodeURIComponent(channelId)}&limit=50${refresh ? "&refresh=1" : ""}`
|
|
);
|
|
if (channelId !== state.channelId) return;
|
|
state.messages = result.messages ?? [];
|
|
} catch (error) {
|
|
if (channelId !== state.channelId) return;
|
|
state.messages = [];
|
|
toast(error.message, "error");
|
|
}
|
|
|
|
renderMessages();
|
|
}
|
|
|
|
let refreshing = false;
|
|
|
|
/** Reload everything from the daemon without touching any draft input. */
|
|
async function refreshAll() {
|
|
if (refreshing) return;
|
|
refreshing = true;
|
|
|
|
try {
|
|
await loadState();
|
|
await loadDms();
|
|
await loadVoice();
|
|
if (state.channelId) await loadMessages({ refresh: true });
|
|
} catch (error) {
|
|
toast(error.message, "error");
|
|
} finally {
|
|
refreshing = false;
|
|
}
|
|
}
|
|
|
|
async function selectGuild(guildId) {
|
|
state.guildId = guildId;
|
|
state.channelId = null;
|
|
state.channel = null;
|
|
$("#guild-menu").hidden = true;
|
|
|
|
renderGuildPicker();
|
|
renderGuildSwitcher();
|
|
renderRail();
|
|
renderRightVoice();
|
|
|
|
const guild = currentGuild();
|
|
const first = guild?.channels.find(channel => SENDABLE_TYPES.has(channel.type));
|
|
if (first) await selectChannel(first.id, guildId);
|
|
|
|
await loadVoice();
|
|
}
|
|
|
|
async function selectChannel(channelId, guildId) {
|
|
if (guildId && guildId !== state.guildId) state.guildId = guildId;
|
|
|
|
state.channelId = channelId;
|
|
state.channel = findGuildChannel(channelId);
|
|
|
|
if (!state.channel) {
|
|
const dm = dmById(channelId);
|
|
if (dm) state.channel = { id: dm.id, name: dmLabel(dm), type: dm.type, dm };
|
|
}
|
|
|
|
renderGuildPicker();
|
|
renderRail();
|
|
renderCenterHeader();
|
|
renderRightDms();
|
|
renderComposer();
|
|
await loadMessages({ refresh: true });
|
|
await loadVoice();
|
|
}
|
|
|
|
async function sendMessage() {
|
|
if (!state.takeover) {
|
|
toast("Composer is read-only — turn Takeover on to send", "error");
|
|
return;
|
|
}
|
|
|
|
const input = $("#composer-input");
|
|
const content = input?.value.trim() ?? "";
|
|
if (!content || !state.channelId) return;
|
|
|
|
try {
|
|
await VK.api.post("/api/messages", { channelId: state.channelId, content });
|
|
localStorage.removeItem(draftKey(state.channelId));
|
|
input.value = "";
|
|
autoGrow(input);
|
|
toast("Message sent", "ok");
|
|
await loadMessages();
|
|
} catch (error) {
|
|
toast(error.message, "error");
|
|
}
|
|
}
|
|
|
|
function autoGrow(input) {
|
|
input.style.height = "auto";
|
|
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
|
|
}
|
|
|
|
/* Header and rail rendering */
|
|
|
|
function renderIdentity() {
|
|
const footer = $("#agent-identity");
|
|
const name = state.user?.username ?? "not logged in";
|
|
|
|
footer.replaceChildren(
|
|
state.user ? avatar(state.user, { agent: true }) : avatar(name, { agent: true }),
|
|
el(
|
|
"span",
|
|
{ class: "vk-right-name" },
|
|
el("span", { class: "vk-agent-name", text: name }),
|
|
el("span", { class: "vk-agent-sub", text: "agent · headless" })
|
|
)
|
|
);
|
|
}
|
|
|
|
function renderGuildPicker() {
|
|
const guild = currentGuild();
|
|
$("#guild-picker-label").textContent = guild?.name ?? "Direct messages";
|
|
}
|
|
|
|
function renderGuildSwitcher() {
|
|
const root = $("#guild-switcher");
|
|
root.replaceChildren();
|
|
|
|
for (const guild of visibleGuildsList()) {
|
|
const button = el("button", {
|
|
class: `vk-guild-mark ${guild.id === state.guildId ? "active" : ""}`,
|
|
type: "button",
|
|
title: guild.name,
|
|
onclick: () => void selectGuild(guild.id)
|
|
});
|
|
guildMarkContent(button, guild);
|
|
root.append(button);
|
|
}
|
|
}
|
|
|
|
function setGuildVisible(guildId, visible) {
|
|
if (!state.visibleGuilds) state.visibleGuilds = new Set(state.guilds.map(guild => guild.id));
|
|
|
|
if (visible) state.visibleGuilds.add(guildId);
|
|
else state.visibleGuilds.delete(guildId);
|
|
saveVisibleGuilds();
|
|
|
|
if (state.guildId && !isGuildVisible(state.guildId)) {
|
|
const next = visibleGuildsList()[0];
|
|
if (next) void selectGuild(next.id);
|
|
}
|
|
|
|
renderGuildPicker();
|
|
renderGuildSwitcher();
|
|
renderVisibleGuilds();
|
|
}
|
|
|
|
function renderVisibleGuilds() {
|
|
const root = $("#guild-visibility");
|
|
if (!root) return;
|
|
root.replaceChildren();
|
|
|
|
if (!state.guilds.length) {
|
|
root.append(el("div", { class: "vk-empty", text: "No guilds cached." }));
|
|
return;
|
|
}
|
|
|
|
for (const guild of state.guilds) {
|
|
const checkbox = el("input", { type: "checkbox" });
|
|
checkbox.checked = isGuildVisible(guild.id);
|
|
checkbox.addEventListener("change", () => setGuildVisible(guild.id, checkbox.checked));
|
|
|
|
const mark = el("span", { class: "vk-guild-icon-sm" });
|
|
guildMarkContent(mark, guild);
|
|
|
|
root.append(el("label", { class: "vk-visibility-row" }, checkbox, mark, el("span", { text: guild.name })));
|
|
}
|
|
}
|
|
|
|
function voiceMarks(member) {
|
|
const marks = el("span", { class: "vk-marks" });
|
|
|
|
if (member.selfMute || member.mute) {
|
|
marks.append(el("span", { class: "neutral", title: member.mute ? "muted by server" : "self-muted", html: VK.icon("micSlash") }));
|
|
}
|
|
if (member.selfDeaf || member.deaf) {
|
|
marks.append(el("span", { class: "neutral", title: "deafened", html: VK.icon("speakerSlash") }));
|
|
}
|
|
|
|
return marks;
|
|
}
|
|
|
|
function renderRail() {
|
|
renderTextChannels();
|
|
renderVoiceRail();
|
|
renderDirect();
|
|
}
|
|
|
|
function renderTextChannels() {
|
|
const root = $("#text-channels");
|
|
root.replaceChildren();
|
|
const guild = currentGuild();
|
|
if (!guild) return;
|
|
|
|
const channels = guild.channels.filter(channel => TEXT_TYPES.has(channel.type));
|
|
if (!channels.length) {
|
|
root.append(el("div", { class: "vk-empty", text: "No text channels cached." }));
|
|
return;
|
|
}
|
|
|
|
for (const channel of channels) {
|
|
root.append(
|
|
el(
|
|
"button",
|
|
{
|
|
class: `vk-rail-row ${channel.id === state.channelId ? "active" : ""}`,
|
|
type: "button",
|
|
onclick: () => void selectChannel(channel.id)
|
|
},
|
|
el("span", { class: "vk-icon vk-dim", html: VK.icon("hash") }),
|
|
el("span", { class: "vk-rail-name", text: channel.name })
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
function renderVoiceRail() {
|
|
const root = $("#voice-channels");
|
|
if (!root) return;
|
|
root.replaceChildren();
|
|
|
|
const guild = currentGuild();
|
|
if (!guild) return;
|
|
|
|
const voiceChannels = guild.channels.filter(channel => VOICE_TYPES.has(channel.type));
|
|
if (!voiceChannels.length) {
|
|
root.append(el("div", { class: "vk-empty", text: "No voice channels cached." }));
|
|
return;
|
|
}
|
|
|
|
const occupied = new Map((state.voiceOverview ?? []).map(entry => [entry.channelId, entry]));
|
|
|
|
for (const channel of voiceChannels) {
|
|
const call = occupied.get(channel.id);
|
|
const active = state.voiceSelf?.channelId === channel.id;
|
|
|
|
if (call?.members?.length) {
|
|
const block = el("div", { class: "vk-rail-voice" });
|
|
block.append(
|
|
el(
|
|
"div",
|
|
{ class: "vk-rail-voice-head" },
|
|
el("span", { class: "vk-icon", style: "color: var(--color-accent)", html: VK.icon("volume") }),
|
|
el("span", { class: "vk-rail-name", style: "font-weight:500", text: channel.name }),
|
|
el(
|
|
"span",
|
|
{ class: "vk-rail-trail" },
|
|
el("span", {
|
|
style: `display:inline-block;width:5px;height:5px;border-radius:50%;margin-right:5px;background:var(--color-${active ? "accent" : "neutral-500"})`
|
|
}),
|
|
`${call.members.length}`
|
|
)
|
|
)
|
|
);
|
|
|
|
const members = el("div", { class: "vk-rail-voice-members" });
|
|
for (const member of call.members) {
|
|
members.append(
|
|
el(
|
|
"span",
|
|
{ class: "vk-voice-member" },
|
|
el("span", {
|
|
class: "vk-name",
|
|
style: member.id === state.user?.id ? "color: var(--color-accent-300)" : "",
|
|
text: member.username
|
|
}),
|
|
voiceMarks(member)
|
|
)
|
|
);
|
|
}
|
|
block.append(members);
|
|
root.append(block);
|
|
} else {
|
|
root.append(
|
|
el(
|
|
"button",
|
|
{
|
|
class: `vk-rail-row ${active ? "active" : ""}`,
|
|
type: "button",
|
|
onclick: () => joinVoice(channel.id)
|
|
},
|
|
el("span", { class: "vk-icon vk-dim", html: VK.icon("volume") }),
|
|
el("span", { class: "vk-rail-name", text: channel.name }),
|
|
el("span", { class: "vk-rail-trail", text: "0" })
|
|
)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderDirect() {
|
|
const root = $("#direct-list");
|
|
if (!root) return;
|
|
root.replaceChildren();
|
|
|
|
if (!state.dms.length) {
|
|
root.append(el("div", { class: "vk-empty", text: "No cached DMs." }));
|
|
return;
|
|
}
|
|
|
|
for (const dm of state.dms.slice(0, 12)) {
|
|
root.append(
|
|
el(
|
|
"button",
|
|
{
|
|
class: `vk-rail-row ${dm.id === state.channelId ? "active" : ""}`,
|
|
type: "button",
|
|
onclick: () => void selectChannel(dm.id)
|
|
},
|
|
dmAvatar(dm, { size: "sm" }),
|
|
el("span", { class: "vk-rail-name", text: dmLabel(dm) })
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
/* Center rendering */
|
|
|
|
function renderCenterHeader() {
|
|
const title = channelLabel();
|
|
const channel = state.channel;
|
|
const guild = currentGuild();
|
|
|
|
$("#channel-title").textContent = title;
|
|
$("#channel-icon").innerHTML = VK.icon(
|
|
channel && VOICE_TYPES.has(channel.type) ? "volume" : channel?.dm ? "speaker" : "hash"
|
|
);
|
|
|
|
let context = "";
|
|
if (channel?.dm) {
|
|
const count = channel.dm.recipients?.length ?? 0;
|
|
context = channel.dm.type === 3 ? `group · ${count} recipients` : "direct message";
|
|
} else if (guild) {
|
|
context = `${guild.name} · ${guild.channels.length} channels cached`;
|
|
}
|
|
|
|
$("#channel-context").textContent = context;
|
|
}
|
|
|
|
function renderMessages() {
|
|
const root = $("#message-list");
|
|
root.replaceChildren();
|
|
|
|
if (!state.channelId) {
|
|
root.append(el("div", { class: "vk-empty", text: "Pick a channel on the left." }));
|
|
return;
|
|
}
|
|
|
|
if (!state.messages.length) {
|
|
root.append(
|
|
el("div", {
|
|
class: "vk-empty",
|
|
text: "No cached messages. The client only holds channels it has loaded."
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
for (const message of state.messages) {
|
|
const author = message.author?.username ?? "unknown";
|
|
const isAgent = message.author?.id === state.user?.id;
|
|
const body = el(
|
|
"div",
|
|
{ class: "vk-message-body-wrap" },
|
|
el(
|
|
"span",
|
|
{ class: "vk-message-head" },
|
|
el("span", { class: "vk-message-author", text: author }),
|
|
isAgent ? el("span", { class: "tag tag-outline vk-tag-agent", text: "agent" }) : null,
|
|
el("span", { class: "vk-mono vk-dim", text: formatTime(message.timestamp) })
|
|
),
|
|
el("span", { class: "vk-message-body", text: message.content })
|
|
);
|
|
|
|
for (const attachment of message.attachments ?? []) {
|
|
if (!attachment) continue;
|
|
|
|
const filename = attachment.filename ?? "attachment";
|
|
const isImage =
|
|
String(attachment.contentType ?? "").startsWith("image/") ||
|
|
/\.(png|jpe?g|gif|webp|avif)$/i.test(filename);
|
|
|
|
if (isImage && attachment.url) {
|
|
const image = el("img", {
|
|
class: "vk-message-image",
|
|
src: attachment.url,
|
|
alt: filename,
|
|
loading: "lazy"
|
|
});
|
|
image.addEventListener("error", () => image.remove());
|
|
body.append(image);
|
|
} else if (attachment.url) {
|
|
body.append(
|
|
el("a", {
|
|
class: "vk-attachment",
|
|
href: attachment.url,
|
|
target: "_blank",
|
|
rel: "noreferrer",
|
|
text: filename
|
|
})
|
|
);
|
|
} else if (filename) {
|
|
body.append(el("span", { class: "vk-attachment", text: filename }));
|
|
}
|
|
}
|
|
|
|
root.append(el("div", { class: "vk-message" }, avatar(message.author ?? author, { agent: isAgent, size: "lg" }), body));
|
|
}
|
|
|
|
const scroll = $("#message-scroll");
|
|
scroll.scrollTop = scroll.scrollHeight;
|
|
}
|
|
|
|
function ttsToggleButton() {
|
|
return el("button", {
|
|
id: "tts-toggle",
|
|
class: "btn btn-secondary vk-icon-btn vk-tts-toggle",
|
|
type: "button",
|
|
title: "Text to speech",
|
|
onclick: toggleTts,
|
|
html: VK.icon("wave")
|
|
});
|
|
}
|
|
|
|
function composerPlaceholder() {
|
|
const target = channelLabel() === "No channel selected" ? "" : channelLabel();
|
|
return `Message ${target} as ${state.user?.username ?? "the account"}`;
|
|
}
|
|
|
|
function renderComposer() {
|
|
const wrap = $("#composer");
|
|
const existing = $("#composer-input");
|
|
|
|
if (state.takeover) {
|
|
wrap.classList.add("unlocked");
|
|
|
|
// Reuse the live input so typing and caret survive re-renders.
|
|
if (existing && state.composerChannelId === state.channelId) {
|
|
existing.placeholder = composerPlaceholder();
|
|
return;
|
|
}
|
|
|
|
const draft = state.channelId ? localStorage.getItem(draftKey(state.channelId)) ?? "" : "";
|
|
const input = el("textarea", {
|
|
id: "composer-input",
|
|
class: "vk-composer-input",
|
|
rows: "1",
|
|
placeholder: composerPlaceholder()
|
|
});
|
|
input.value = draft;
|
|
input.addEventListener("input", () => {
|
|
autoGrow(input);
|
|
if (state.channelId) localStorage.setItem(draftKey(state.channelId), input.value);
|
|
});
|
|
input.addEventListener("keydown", event => {
|
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
event.preventDefault();
|
|
void sendMessage();
|
|
}
|
|
});
|
|
|
|
state.composerChannelId = state.channelId;
|
|
wrap.replaceChildren(
|
|
ttsToggleButton(),
|
|
input,
|
|
el("button", {
|
|
id: "send",
|
|
class: "btn btn-primary vk-send",
|
|
type: "button",
|
|
text: "Send",
|
|
onclick: () => void sendMessage()
|
|
})
|
|
);
|
|
requestAnimationFrame(() => autoGrow(input));
|
|
return;
|
|
}
|
|
|
|
state.composerChannelId = null;
|
|
wrap.classList.remove("unlocked");
|
|
wrap.replaceChildren(
|
|
el("span", { class: "tag tag-neutral vk-readonly-tag", text: "read-only" }),
|
|
icon("lock"),
|
|
el("span", {
|
|
class: "vk-composer-copy",
|
|
text: `Composer locked — takeover is off. Flip Takeover to send as ${state.user?.username ?? "the account"}.`
|
|
}),
|
|
ttsToggleButton(),
|
|
el("button", { class: "btn btn-secondary vk-send", type: "button", text: "Send", disabled: true })
|
|
);
|
|
}
|
|
|
|
function toggleTts() {
|
|
const panel = $("#tts-panel");
|
|
panel.hidden = !panel.hidden;
|
|
$("#tts-target").textContent = `as ${state.user?.username ?? "account"} → ${channelLabel()}`;
|
|
}
|
|
|
|
/* Right column */
|
|
|
|
function renderRightVoice() {
|
|
const root = $("#right-voice");
|
|
if (!root) return;
|
|
root.replaceChildren();
|
|
|
|
const channels = (state.voiceOverview ?? []).filter(entry => entry.members?.length);
|
|
const rows = channels.flatMap(entry => entry.members.map(member => ({ member, entry })));
|
|
|
|
$("#voice-count").textContent = rows.length ? `${Math.min(rows.length, 7)} of ${rows.length} shown` : "";
|
|
|
|
if (!rows.length) {
|
|
root.append(el("div", { class: "vk-empty", text: "No voice channels occupied." }));
|
|
return;
|
|
}
|
|
|
|
for (const { member, entry } of rows.slice(0, 7)) {
|
|
const isSelf = member.id === state.user?.id;
|
|
|
|
root.append(
|
|
el(
|
|
"div",
|
|
{ class: `vk-right-row ${isSelf ? "highlight" : ""}` },
|
|
avatar(member),
|
|
el(
|
|
"span",
|
|
{ class: "vk-right-name" },
|
|
el("span", { class: "vk-name", text: member.username }),
|
|
el("span", { class: "vk-sub", text: entry.channelName ?? entry.channelId })
|
|
),
|
|
el(
|
|
"span",
|
|
{ class: "vk-right-trail" },
|
|
voiceMarks(member),
|
|
isSelf
|
|
? el("button", {
|
|
class: "btn btn-secondary vk-small-btn",
|
|
type: "button",
|
|
text: "View",
|
|
onclick: () => toast("Voice monitoring is not implemented yet", "error")
|
|
})
|
|
: el("button", {
|
|
class: "btn btn-primary vk-small-btn",
|
|
type: "button",
|
|
text: "Join",
|
|
onclick: () => joinVoice(entry.channelId)
|
|
})
|
|
)
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
function renderRightDms() {
|
|
const root = $("#right-dms");
|
|
if (!root) return;
|
|
root.replaceChildren();
|
|
|
|
$("#dm-count").textContent = state.dms.length ? `${state.dms.length} threads` : "";
|
|
|
|
if (!state.dms.length) {
|
|
root.append(el("div", { class: "vk-empty", text: "No cached DMs." }));
|
|
return;
|
|
}
|
|
|
|
for (const dm of state.dms.slice(0, 8)) {
|
|
root.append(
|
|
el(
|
|
"button",
|
|
{
|
|
class: `vk-rail-row ${dm.id === state.channelId ? "active" : ""}`,
|
|
type: "button",
|
|
onclick: () => void selectChannel(dm.id)
|
|
},
|
|
dmAvatar(dm, { size: "sm" }),
|
|
el("span", { class: "vk-rail-name", text: dmLabel(dm) })
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
function renderCallStrip() {
|
|
const strip = $("#call-strip");
|
|
const call = state.voiceSelf;
|
|
|
|
if (!call?.channelId || call.channelId === state.channelId) {
|
|
strip.hidden = true;
|
|
strip.replaceChildren();
|
|
return;
|
|
}
|
|
|
|
strip.replaceChildren(
|
|
el(
|
|
"span",
|
|
{ class: "vk-call-title" },
|
|
el("span", { class: "vk-call-live" }),
|
|
el("span", { class: "vk-call-name", text: call.channelName ?? call.channelId }),
|
|
el("span", { class: "vk-mono vk-dim", text: `${call.members?.length ?? 0} in call` })
|
|
),
|
|
el(
|
|
"div",
|
|
{ class: "vk-call-pills" },
|
|
...(call.members ?? []).map(member =>
|
|
el(
|
|
"span",
|
|
{ class: "vk-strip-pill" },
|
|
avatar(member, { size: "sm" }),
|
|
el("span", { class: "vk-strip-name", text: member.username }),
|
|
voiceMarks(member)
|
|
)
|
|
)
|
|
),
|
|
el(
|
|
"div",
|
|
{ class: "vk-call-actions" },
|
|
el("button", {
|
|
class: "btn btn-secondary vk-small-btn",
|
|
type: "button",
|
|
text: "Return to call",
|
|
onclick: () => void selectChannel(call.channelId)
|
|
}),
|
|
el("button", {
|
|
class: "btn btn-secondary vk-small-btn",
|
|
type: "button",
|
|
text: "Leave",
|
|
onclick: () => voiceAction("/api/voice/leave")
|
|
})
|
|
)
|
|
);
|
|
|
|
strip.hidden = false;
|
|
}
|
|
|
|
async function joinVoice(channelId) {
|
|
await voiceAction("/api/voice/join", { channelId });
|
|
}
|
|
|
|
async function voiceAction(path, body) {
|
|
try {
|
|
await VK.api.post(path, body ?? {});
|
|
toast("Voice action accepted", "ok");
|
|
await loadVoice();
|
|
} catch (error) {
|
|
toast(error.message, "error");
|
|
}
|
|
}
|
|
|
|
/* Console */
|
|
|
|
function addEvent(event) {
|
|
state.events.unshift(event);
|
|
if (state.events.length > 300) state.events.pop();
|
|
state.eventCount++;
|
|
|
|
const log = $("#event-log");
|
|
if (log) {
|
|
log.prepend(
|
|
el(
|
|
"div",
|
|
{ class: "vk-event-line" },
|
|
el("span", { class: "vk-event-time", text: new Date().toLocaleTimeString() }),
|
|
el("span", { class: "vk-event-type", text: event.type ?? "event" }),
|
|
el("span", { text: JSON.stringify(event) })
|
|
)
|
|
);
|
|
|
|
while (log.childElementCount > 200) log.lastElementChild.remove();
|
|
}
|
|
|
|
const ticker = $("#ticker-line");
|
|
if (ticker) ticker.textContent = `${event.type ?? "event"} ${JSON.stringify(event).slice(0, 180)}`;
|
|
|
|
syncConsoleCount();
|
|
}
|
|
|
|
function syncConsoleCount() {
|
|
const count = $("#console-count");
|
|
if (count) count.textContent = `${state.eventCount} events`;
|
|
}
|
|
|
|
function setConsoleOpen(open) {
|
|
state.consoleOpen = open;
|
|
$("#console-drawer").hidden = !open;
|
|
$("#console-caret").innerHTML = VK.icon(open ? "caretDown" : "caretUp");
|
|
}
|
|
|
|
function setConsoleTab(tab) {
|
|
state.consoleTab = tab;
|
|
for (const button of $$(".vk-console-tab")) button.classList.toggle("active", button.dataset.consoleTab === tab);
|
|
for (const panel of $$(".vk-console-panel")) panel.hidden = panel.id !== `console-panel-${tab}`;
|
|
if (!state.consoleOpen) setConsoleOpen(true);
|
|
}
|
|
|
|
/* Settings */
|
|
|
|
function openSettings() {
|
|
$("#settings-overlay").hidden = false;
|
|
void refreshCoreStatus();
|
|
void refreshAuth();
|
|
void refreshLogs();
|
|
}
|
|
|
|
function closeSettings() {
|
|
$("#settings-overlay").hidden = true;
|
|
}
|
|
|
|
function statusRow(label, value) {
|
|
return el(
|
|
"div",
|
|
{ class: "vk-status-row" },
|
|
el("span", { class: "vk-status-label", text: label }),
|
|
el("span", { class: "vk-status-value" }, value)
|
|
);
|
|
}
|
|
|
|
function badge(text, kind = "") {
|
|
return el("span", { class: `tag ${kind === "ok" ? "tag-accent" : "tag-neutral"}`, text });
|
|
}
|
|
|
|
function renderCoreStatus(status) {
|
|
const card = $("#core-status");
|
|
const process = status.process ?? {};
|
|
const rows = [
|
|
statusRow("bridge", badge(status.bridgeConnected ? "connected" : "disconnected", status.bridgeConnected ? "ok" : "")),
|
|
statusRow("process", badge(process.running ? "running" : "stopped", process.running ? "ok" : "")),
|
|
statusRow("pid", el("code", { text: process.pid ?? "—" })),
|
|
statusRow("managed", el("span", { text: process.managed ? "yes" : process.running ? "external" : "no" })),
|
|
statusRow("started", el("span", { text: process.startedAt ? new Date(process.startedAt).toLocaleString() : "—" })),
|
|
statusRow("command", el("code", { text: process.command ?? "—" }))
|
|
];
|
|
|
|
if (process.lastExit) {
|
|
const exit = process.lastExit;
|
|
const text = exit.signal ? `signal ${exit.signal}` : `code ${exit.code ?? "?"}`;
|
|
rows.push(statusRow("last exit", badge(text, exit.code === 0 ? "ok" : "")));
|
|
}
|
|
|
|
card.replaceChildren(...rows);
|
|
|
|
if (process.output?.length) {
|
|
card.append(el("pre", { class: "vk-core-output", text: process.output.join("\n") }));
|
|
}
|
|
}
|
|
|
|
async function refreshCoreStatus() {
|
|
try {
|
|
renderCoreStatus(await VK.api.get("/api/core/status"));
|
|
} catch (error) {
|
|
toast(error.message, "error");
|
|
}
|
|
}
|
|
|
|
async function coreAction(path, label) {
|
|
try {
|
|
renderCoreStatus(await VK.api.post(path));
|
|
toast(`${label} requested`, "ok");
|
|
void refreshHealth();
|
|
} catch (error) {
|
|
toast(error.message, "error");
|
|
}
|
|
}
|
|
|
|
function renderAuth(status) {
|
|
const card = $("#auth-status");
|
|
const session = status.session;
|
|
const rows = [
|
|
statusRow("token file", el("code", { text: status.tokenFile })),
|
|
statusRow("token", badge(status.hasToken ? "present" : "missing", status.hasToken ? "ok" : "")),
|
|
statusRow("session", el("span", { text: session?.state ?? "none" }))
|
|
];
|
|
|
|
if (session?.user) rows.push(statusRow("user", el("span", { text: `${session.user.tag} — ${session.user.id}` })));
|
|
if (session?.captcha) rows.push(statusRow("captcha sitekey", el("code", { text: session.captcha.sitekey ?? "—" })));
|
|
if (session?.error) rows.push(statusRow("error", el("span", { text: session.error })));
|
|
|
|
card.replaceChildren(...rows);
|
|
|
|
const qr = $("#auth-qr");
|
|
if (status.qrDataUrl) {
|
|
qr.replaceChildren(el("img", { src: status.qrDataUrl, alt: "Discord login QR" }));
|
|
qr.hidden = false;
|
|
} else {
|
|
qr.replaceChildren();
|
|
qr.hidden = true;
|
|
}
|
|
|
|
$("#auth-captcha").hidden = session?.state !== "captcha";
|
|
}
|
|
|
|
async function refreshAuth() {
|
|
try {
|
|
renderAuth(await VK.api.get("/api/auth"));
|
|
} catch (error) {
|
|
toast(error.message, "error");
|
|
}
|
|
}
|
|
|
|
async function authAction(path, label) {
|
|
try {
|
|
renderAuth(await VK.api.post(path));
|
|
if (label) toast(label, "ok");
|
|
} catch (error) {
|
|
toast(error.message, "error");
|
|
}
|
|
}
|
|
|
|
async function refreshLogs() {
|
|
const root = $("#action-log");
|
|
try {
|
|
const entries = await VK.api.get("/api/logs?limit=100");
|
|
root.replaceChildren();
|
|
|
|
if (!entries.length) {
|
|
root.append(el("div", { class: "vk-empty", text: "Action log is empty." }));
|
|
return;
|
|
}
|
|
|
|
for (const entry of [...entries].reverse()) {
|
|
root.append(
|
|
el(
|
|
"div",
|
|
{ class: "vk-log-line" },
|
|
el("span", { class: "vk-log-time", text: entry.timestamp?.slice(11, 19) ?? "" }),
|
|
el("span", { class: "vk-log-type", text: entry.type ?? "?" }),
|
|
el("span", { text: JSON.stringify(entry).slice(0, 220) })
|
|
)
|
|
);
|
|
}
|
|
} catch (error) {
|
|
root.replaceChildren(el("div", { class: "vk-empty", text: error.message }));
|
|
}
|
|
}
|
|
|
|
/* Health */
|
|
|
|
async function refreshHealth() {
|
|
try {
|
|
const health = await VK.api.get("/api/health");
|
|
state.health = health;
|
|
|
|
const now = Date.now();
|
|
if (state.lastEventSample) {
|
|
const seconds = (now - state.lastEventSample.at) / 1000;
|
|
const delta = (health.events ?? 0) - state.lastEventSample.events;
|
|
if (seconds > 0) state.eventRate = delta / seconds;
|
|
}
|
|
state.lastEventSample = { at: now, events: health.events ?? 0 };
|
|
|
|
const dot = $("#health-dot");
|
|
dot.className = `vk-dot ${health.coreConnected ? "online" : "offline"}`;
|
|
$("#health-bridge").textContent = health.coreConnected ? "bridge ok" : "bridge down";
|
|
$("#health-uptime").textContent = formatUptime(health.uptimeSeconds);
|
|
$("#health-events").textContent = `${state.eventRate.toFixed(1)} ev/s`;
|
|
|
|
const mcpDot = $("#mcp-dot");
|
|
mcpDot.className = `vk-dot ${health.coreConnected ? "online" : "offline"}`;
|
|
$("#mcp-state").textContent = health.coreConnected ? "mcp connected" : "mcp offline";
|
|
} catch {
|
|
$("#health-dot").className = "vk-dot offline";
|
|
$("#health-bridge").textContent = "daemon down";
|
|
}
|
|
}
|
|
|
|
/* Live events */
|
|
|
|
function applyHello(event) {
|
|
if (typeof event.coreConnected !== "boolean") return;
|
|
$("#health-dot").className = `vk-dot ${event.coreConnected ? "online" : "offline"}`;
|
|
$("#health-bridge").textContent = event.coreConnected ? "bridge ok" : "bridge down";
|
|
const mcpDot = $("#mcp-dot");
|
|
if (mcpDot) mcpDot.className = `vk-dot ${event.coreConnected ? "online" : "offline"}`;
|
|
|
|
// The page may have loaded before the bridge was up.
|
|
if (event.coreConnected && !state.user) void refreshAll();
|
|
}
|
|
|
|
function renderRightFavourites() {
|
|
const root = $("#right-favourites");
|
|
if (!root) return;
|
|
root.replaceChildren(el("div", { class: "vk-empty", text: "Favourites are not implemented yet." }));
|
|
}
|
|
|
|
function handleLiveEvent(event) {
|
|
if (event.type === "MESSAGE_CREATE" && event.channelId === state.channelId) {
|
|
void loadMessages();
|
|
}
|
|
|
|
if (event.type === "VOICE_STATE_UPDATE") {
|
|
const now = Date.now();
|
|
if (now - state.voiceReloadAt > 1000) {
|
|
state.voiceReloadAt = now;
|
|
void loadVoice();
|
|
}
|
|
}
|
|
|
|
if (event.type === "CORE_STATUS") {
|
|
void refreshHealth();
|
|
if (event.coreConnected) void refreshAll();
|
|
if (!$("#settings-overlay").hidden) void refreshCoreStatus();
|
|
}
|
|
|
|
if (event.type === "AUTH_QR" && !$("#settings-overlay").hidden) {
|
|
void refreshAuth();
|
|
}
|
|
}
|
|
|
|
/* Wiring */
|
|
|
|
function init() {
|
|
hydrateIcons();
|
|
loadVisibleGuilds();
|
|
|
|
const takeover = $("#takeover");
|
|
takeover.checked = state.takeover;
|
|
$("#takeover-label").textContent = state.takeover ? "Takeover on" : "Takeover";
|
|
takeover.addEventListener("change", () => {
|
|
state.takeover = takeover.checked;
|
|
localStorage.setItem("veskslop-takeover", state.takeover ? "1" : "0");
|
|
$("#takeover-label").textContent = state.takeover ? "Takeover on" : "Takeover";
|
|
renderComposer();
|
|
});
|
|
|
|
$("#guild-picker").addEventListener("click", () => {
|
|
const menu = $("#guild-menu");
|
|
menu.hidden = !menu.hidden;
|
|
if (menu.hidden) return;
|
|
|
|
menu.replaceChildren(
|
|
...visibleGuildsList().map(guild =>
|
|
el("button", {
|
|
class: `vk-menu-item ${guild.id === state.guildId ? "active" : ""}`,
|
|
type: "button",
|
|
text: guild.name,
|
|
onclick: () => void selectGuild(guild.id)
|
|
})
|
|
)
|
|
);
|
|
});
|
|
|
|
$("#settings-open").addEventListener("click", openSettings);
|
|
$("#settings-close").addEventListener("click", closeSettings);
|
|
$("#settings-overlay").addEventListener("click", event => {
|
|
if (event.target === $("#settings-overlay")) closeSettings();
|
|
});
|
|
|
|
$("#core-start").addEventListener("click", () => void coreAction("/api/core/start", "Start"));
|
|
$("#core-stop").addEventListener("click", () => void coreAction("/api/core/stop", "Stop"));
|
|
$("#core-restart").addEventListener("click", () => void coreAction("/api/core/restart", "Restart"));
|
|
|
|
$("#auth-start").addEventListener("click", () => void authAction("/api/auth/qr", "QR login started"));
|
|
$("#auth-cancel").addEventListener("click", () => void authAction("/api/auth/qr/cancel", "Login cancelled"));
|
|
$("#auth-restart").addEventListener("click", async () => {
|
|
try {
|
|
renderCoreStatus(await VK.api.post("/api/core/restart", { forceToken: true }));
|
|
toast("Restarting with the token file", "ok");
|
|
} catch (error) {
|
|
toast(error.message, "error");
|
|
}
|
|
});
|
|
|
|
$("#auth-captcha").addEventListener("submit", async event => {
|
|
event.preventDefault();
|
|
const key = $("#auth-captcha-key").value.trim();
|
|
if (!key) return;
|
|
|
|
try {
|
|
await VK.api.post("/api/auth/qr/captcha", { key });
|
|
$("#auth-captcha-key").value = "";
|
|
toast("Captcha submitted", "ok");
|
|
void refreshAuth();
|
|
} catch (error) {
|
|
toast(error.message, "error");
|
|
}
|
|
});
|
|
|
|
$("#logs-refresh").addEventListener("click", () => void refreshLogs());
|
|
|
|
$("#console-toggle").addEventListener("click", () => setConsoleOpen(!state.consoleOpen));
|
|
for (const tab of $$(".vk-console-tab")) {
|
|
tab.addEventListener("click", () => setConsoleTab(tab.dataset.consoleTab));
|
|
}
|
|
|
|
$("#raw-events").addEventListener("click", () => setConsoleTab("events"));
|
|
$("#channel-refresh").addEventListener("click", () => void loadMessages({ refresh: true }));
|
|
|
|
$("#guilds-show-all").addEventListener("click", () => {
|
|
state.visibleGuilds = null;
|
|
localStorage.removeItem("veskslop-visible-guilds");
|
|
renderVisibleGuilds();
|
|
renderGuildSwitcher();
|
|
renderGuildPicker();
|
|
});
|
|
|
|
const ttsInput = $("#tts-input");
|
|
ttsInput.value = localStorage.getItem(TTS_DRAFT_KEY) ?? "";
|
|
ttsInput.addEventListener("input", () => localStorage.setItem(TTS_DRAFT_KEY, ttsInput.value));
|
|
|
|
$("#tts-close").addEventListener("click", () => {
|
|
$("#tts-panel").hidden = true;
|
|
});
|
|
$("#tts-speak").addEventListener("click", () => toast("TTS is not implemented yet", "error"));
|
|
|
|
document.addEventListener("keydown", event => {
|
|
if (event.key !== "Escape") return;
|
|
if (!$("#settings-overlay").hidden) closeSettings();
|
|
else if (state.consoleOpen) setConsoleOpen(false);
|
|
});
|
|
|
|
VK.bus.on("event", event => {
|
|
if (event.type === "hello") {
|
|
applyHello(event);
|
|
return;
|
|
}
|
|
|
|
addEvent(event);
|
|
handleLiveEvent(event);
|
|
});
|
|
|
|
VK.connectEvents();
|
|
|
|
setConsoleTab("events");
|
|
setConsoleOpen(false);
|
|
syncConsoleCount();
|
|
renderRightFavourites();
|
|
|
|
void loadState();
|
|
void loadDms();
|
|
void loadVoice();
|
|
void refreshHealth();
|
|
|
|
setInterval(refreshHealth, 5000);
|
|
setInterval(() => {
|
|
if (!$("#settings-overlay").hidden) {
|
|
if (!$("#auth-qr").hidden || $("#auth-captcha").hidden === false) void refreshAuth();
|
|
}
|
|
}, 3000);
|
|
}
|
|
|
|
init();
|