2405 lines
79 KiB
JavaScript
2405 lines
79 KiB
JavaScript
const state = {
|
|
sessionActive: false,
|
|
me: null,
|
|
guilds: [],
|
|
channels: [],
|
|
dmConversations: [],
|
|
members: [],
|
|
voicePresence: new Map(),
|
|
selectedGuildId: null,
|
|
selectedTextChannelId: null,
|
|
selectedDmUserId: null,
|
|
selectedDmDisplayName: null,
|
|
selectedVoiceChannelId: null,
|
|
voice: {
|
|
ws: null,
|
|
joinedChannelId: null,
|
|
localStream: null,
|
|
rawStream: null,
|
|
videoStream: null,
|
|
screenStream: null,
|
|
peerConnections: new Map(),
|
|
videoSenders: new Map(),
|
|
screenSenders: new Map(),
|
|
muted: false,
|
|
sharingVideo: false,
|
|
sharingScreen: false,
|
|
deepFilterProcessor: null,
|
|
deepFilterModule: null,
|
|
viewMode: 'chat', // 'chat' or 'video'
|
|
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
|
peerGainNodes: new Map(), // userId -> GainNode
|
|
peerAudioNodes: new Map(), // userId -> { sourceNode, compressorNode, gainNode }
|
|
visibleVolumeSliders: new Set(), // userIds whose sliders are visible
|
|
},
|
|
voicePresencePollId: null,
|
|
chatWs: null,
|
|
lastMessageId: null,
|
|
onlineUsers: new Set(),
|
|
idleUsers: new Set(),
|
|
userVolumes: new Map(), // userId -> volume (0.0 to 2.0)
|
|
};
|
|
|
|
// --- Storage ---
|
|
const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId";
|
|
|
|
function storageGet(key) {
|
|
return localStorage.getItem(key);
|
|
}
|
|
|
|
function storageSet(key, value) {
|
|
localStorage.setItem(key, value);
|
|
}
|
|
|
|
function storageRemove(key) {
|
|
localStorage.removeItem(key);
|
|
}
|
|
|
|
function getWsUrl(path) {
|
|
const proto = location.protocol === "https:" ? "wss" : "ws";
|
|
return `${proto}://${location.host}${path}`;
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const el = {
|
|
authScreen: document.getElementById("auth-screen"),
|
|
loginBtn: document.getElementById("login-btn"),
|
|
main: document.getElementById("main"),
|
|
status: document.getElementById("status"),
|
|
|
|
// Guilds
|
|
guildList: document.getElementById("guild-list"),
|
|
addGuildBtn: document.getElementById("add-guild-btn"),
|
|
guildTitle: document.getElementById("guild-title"),
|
|
createInviteBtn: document.getElementById("create-invite-btn"),
|
|
inviteCopiedBadge: document.getElementById("invite-copied-badge"),
|
|
|
|
// Channels
|
|
channelList: document.getElementById("channel-list"),
|
|
voiceChannelList: document.getElementById("voice-channel-list"),
|
|
addTextBtn: document.getElementById("add-text-btn"),
|
|
addVoiceBtn: document.getElementById("add-voice-btn"),
|
|
dmList: document.getElementById("dm-list"),
|
|
channelTitle: document.getElementById("channel-title"),
|
|
|
|
// Messages
|
|
messageList: document.getElementById("message-list"),
|
|
messageForm: document.getElementById("message-form"),
|
|
messageBody: document.getElementById("message-body"),
|
|
mediaUploadBtn: document.getElementById("media-upload-btn"),
|
|
mediaFileInput: document.getElementById("media-file-input"),
|
|
|
|
// User Panel
|
|
userName: document.getElementById("user-name"),
|
|
userAvatar: document.getElementById("user-avatar"),
|
|
logoutBtn: document.getElementById("logout-btn"),
|
|
updateNotifier: document.getElementById("update-notifier"),
|
|
updateDownloadBtn: document.getElementById("update-download-btn"),
|
|
updateInstallBtn: document.getElementById("update-install-btn"),
|
|
|
|
// Voice Connection
|
|
voiceConnection: document.getElementById("voice-connection"),
|
|
vcChannelName: document.getElementById("vc-channel-name"),
|
|
voiceVideoBtn: document.getElementById("voice-video-btn"),
|
|
voiceScreenBtn: document.getElementById("voice-screen-btn"),
|
|
voiceMuteBtn: document.getElementById("voice-mute-btn"),
|
|
voiceLeaveBtn: document.getElementById("voice-leave-btn"),
|
|
videoGrid: document.getElementById("video-grid"),
|
|
messageForm: document.getElementById("message-form"),
|
|
messageInputWrapper: document.querySelector(".chat-input-wrapper"),
|
|
|
|
// Members
|
|
memberList: document.getElementById("member-list"),
|
|
|
|
// Modals
|
|
modalContainer: document.getElementById("modal-container"),
|
|
guildForm: document.getElementById("guild-form"),
|
|
guildName: document.getElementById("guild-name"),
|
|
modalCancel: document.getElementById("modal-cancel"),
|
|
|
|
channelModal: document.getElementById("channel-modal"),
|
|
channelForm: document.getElementById("channel-form"),
|
|
channelName: document.getElementById("channel-name"),
|
|
channelModalCancel: document.getElementById("channel-modal-cancel"),
|
|
|
|
// Sound Board
|
|
soundboard: document.getElementById('soundboard'),
|
|
soundboardGrid: document.getElementById('soundboard-grid'),
|
|
addSoundBtn: document.getElementById('add-sound-btn'),
|
|
soundModal: document.getElementById('sound-modal'),
|
|
soundForm: document.getElementById('sound-form'),
|
|
soundName: document.getElementById('sound-name'),
|
|
soundIcon: document.getElementById('sound-icon'),
|
|
soundFile: document.getElementById('sound-file'),
|
|
soundModalCancel: document.getElementById('sound-modal-cancel'),
|
|
soundSubmitBtn: document.getElementById('sound-submit-btn'),
|
|
uploadLimitModal: document.getElementById('upload-limit-modal'),
|
|
uploadLimitMessage: document.getElementById('upload-limit-message'),
|
|
uploadLimitOk: document.getElementById('upload-limit-ok'),
|
|
|
|
// Mobile
|
|
mobileMenuBtn: document.getElementById("mobile-menu-btn"),
|
|
mobileMembersBtn: document.getElementById("mobile-members-btn"),
|
|
overlay: document.getElementById("overlay"),
|
|
utilitySidebar: document.getElementById("utility-sidebar"),
|
|
|
|
// GIF Picker
|
|
gifBtn: document.getElementById("gif-btn"),
|
|
gifPicker: document.getElementById("gif-picker"),
|
|
gifPickerClose: document.getElementById("gif-picker-close"),
|
|
gifSearchInput: document.getElementById("gif-search-input"),
|
|
gifResults: document.getElementById("gif-results"),
|
|
};
|
|
|
|
function stopVoicePresencePolling() {
|
|
if (!state.voicePresencePollId) return;
|
|
clearInterval(state.voicePresencePollId);
|
|
state.voicePresencePollId = null;
|
|
}
|
|
|
|
function closeChatWs() {
|
|
if (!state.chatWs) return;
|
|
const ws = state.chatWs;
|
|
state.chatWs = null;
|
|
ws.onclose = null;
|
|
ws.onmessage = null;
|
|
ws.onerror = null;
|
|
try {
|
|
ws.close();
|
|
} catch { }
|
|
}
|
|
|
|
async function showLoggedOutState(message = "Session expired, please log in again") {
|
|
state.sessionActive = false;
|
|
stopVoicePresencePolling();
|
|
closeChatWs();
|
|
await leaveVoice();
|
|
|
|
state.me = null;
|
|
state.guilds = [];
|
|
state.channels = [];
|
|
state.dmConversations = [];
|
|
state.members = [];
|
|
state.voicePresence.clear();
|
|
state.selectedGuildId = null;
|
|
state.selectedTextChannelId = null;
|
|
state.selectedDmUserId = null;
|
|
state.selectedDmDisplayName = null;
|
|
state.selectedVoiceChannelId = null;
|
|
state.lastMessageId = null;
|
|
state.onlineUsers.clear();
|
|
state.idleUsers.clear();
|
|
state.userVolumes.clear();
|
|
state.voice.viewMode = 'chat';
|
|
|
|
el.userName.textContent = "Username";
|
|
el.userAvatar.textContent = "U";
|
|
el.status.textContent = message;
|
|
el.soundboard.classList.add("hidden");
|
|
el.voiceConnection.classList.add("hidden");
|
|
renderGuilds();
|
|
renderChannels();
|
|
renderDMs();
|
|
renderMembers();
|
|
renderMessages([]);
|
|
updateHeaderLabels();
|
|
el.main.classList.add("hidden");
|
|
el.authScreen.classList.remove("hidden");
|
|
}
|
|
|
|
// --- API Helpers ---
|
|
|
|
async function api(path, options = {}) {
|
|
const headers = {
|
|
...(options.headers || {}),
|
|
};
|
|
if (!headers["content-type"] && options.body && !(options.body instanceof FormData)) {
|
|
headers["content-type"] = "application/json";
|
|
}
|
|
|
|
const res = await fetch(path, {
|
|
...options,
|
|
headers,
|
|
credentials: "same-origin",
|
|
});
|
|
|
|
if (res.status === 401) {
|
|
await showLoggedOutState("Session expired, please log in again");
|
|
const err = new Error("Session expired, please log in again");
|
|
err.isAuthError = true;
|
|
throw err;
|
|
}
|
|
|
|
if (!res.ok) {
|
|
let detail = "request failed";
|
|
try {
|
|
const body = await res.json();
|
|
detail = body.error || detail;
|
|
} catch { }
|
|
throw new Error(`${res.status}: ${detail}`);
|
|
}
|
|
|
|
if (res.status === 204) return null;
|
|
return res.json();
|
|
}
|
|
|
|
// --- Utils ---
|
|
|
|
function shortName(name) {
|
|
if (!name || typeof name !== 'string') return "?";
|
|
const trimmed = name.trim();
|
|
if (!trimmed) return "?";
|
|
const words = trimmed.split(/\s+/).filter(w => w.length > 0).slice(0, 2);
|
|
if (words.length === 0) return "?";
|
|
if (words.length === 1) return words[0].substring(0, 2).toUpperCase();
|
|
return words.map((w) => w[0]?.toUpperCase() || "").join("");
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
if (s === null || s === undefined) return "";
|
|
return String(s)
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
const GIF_URL_REGEX = /(https?:\/\/[^\s]+(?:\.gif|\/giphy\.gif)[^\s]*)/gi;
|
|
|
|
function normalizeMediaUrl(raw) {
|
|
try {
|
|
const url = new URL(raw, location.origin);
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
return null;
|
|
}
|
|
return url.href;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function formatBytes(size) {
|
|
if (!Number.isFinite(size) || size < 1024) return `${size || 0} B`;
|
|
const units = ["KB", "MB", "GB"];
|
|
let value = size;
|
|
let unitIndex = -1;
|
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
value /= 1024;
|
|
unitIndex += 1;
|
|
}
|
|
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
|
}
|
|
|
|
const MAX_MEDIA_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
|
|
function buildMessageBody(body) {
|
|
const fragment = document.createDocumentFragment();
|
|
const text = String(body || "");
|
|
let cursor = 0;
|
|
|
|
for (const match of text.matchAll(new RegExp(GIF_URL_REGEX))) {
|
|
const [rawUrl] = match;
|
|
const start = match.index ?? 0;
|
|
if (start > cursor) {
|
|
fragment.appendChild(document.createTextNode(text.slice(cursor, start)));
|
|
}
|
|
|
|
const safeUrl = normalizeMediaUrl(rawUrl);
|
|
if (safeUrl) {
|
|
const gifWrap = document.createElement("div");
|
|
gifWrap.className = "msg-gif";
|
|
|
|
const image = document.createElement("img");
|
|
image.loading = "lazy";
|
|
image.src = safeUrl;
|
|
gifWrap.appendChild(image);
|
|
fragment.appendChild(gifWrap);
|
|
} else {
|
|
fragment.appendChild(document.createTextNode(rawUrl));
|
|
}
|
|
|
|
cursor = start + rawUrl.length;
|
|
}
|
|
|
|
if (cursor < text.length) {
|
|
fragment.appendChild(document.createTextNode(text.slice(cursor)));
|
|
}
|
|
|
|
return fragment;
|
|
}
|
|
|
|
function buildAttachments(attachments = []) {
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
for (const attachment of attachments) {
|
|
const safeUrl = normalizeMediaUrl(attachment.media_url);
|
|
if (!safeUrl) continue;
|
|
|
|
const mime = attachment.mime_type || "application/octet-stream";
|
|
const fileName = attachment.original_filename || "file";
|
|
const size = formatBytes(attachment.size_bytes);
|
|
|
|
if (mime.startsWith("image/")) {
|
|
const link = document.createElement("a");
|
|
link.className = "msg-attachment msg-attachment-image";
|
|
link.href = safeUrl;
|
|
link.target = "_blank";
|
|
link.rel = "noopener noreferrer";
|
|
|
|
const image = document.createElement("img");
|
|
image.src = safeUrl;
|
|
image.alt = fileName;
|
|
image.loading = "lazy";
|
|
link.appendChild(image);
|
|
fragment.appendChild(link);
|
|
continue;
|
|
}
|
|
|
|
if (mime.startsWith("video/")) {
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "msg-attachment msg-attachment-video";
|
|
|
|
const video = document.createElement("video");
|
|
video.controls = true;
|
|
video.preload = "metadata";
|
|
video.src = safeUrl;
|
|
wrapper.appendChild(video);
|
|
fragment.appendChild(wrapper);
|
|
continue;
|
|
}
|
|
|
|
if (mime.startsWith("audio/")) {
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "msg-attachment msg-attachment-audio";
|
|
|
|
const audio = document.createElement("audio");
|
|
audio.controls = true;
|
|
audio.preload = "metadata";
|
|
audio.src = safeUrl;
|
|
wrapper.appendChild(audio);
|
|
|
|
const link = document.createElement("a");
|
|
link.href = safeUrl;
|
|
link.target = "_blank";
|
|
link.rel = "noopener noreferrer";
|
|
link.textContent = fileName;
|
|
wrapper.appendChild(link);
|
|
|
|
fragment.appendChild(wrapper);
|
|
continue;
|
|
}
|
|
|
|
const link = document.createElement("a");
|
|
link.className = "msg-attachment msg-attachment-file";
|
|
link.href = safeUrl;
|
|
link.target = "_blank";
|
|
link.rel = "noopener noreferrer";
|
|
|
|
const icon = document.createElement("i");
|
|
icon.setAttribute("data-lucide", "file");
|
|
link.appendChild(icon);
|
|
|
|
const name = document.createElement("span");
|
|
name.textContent = fileName;
|
|
link.appendChild(name);
|
|
|
|
const meta = document.createElement("small");
|
|
meta.textContent = size;
|
|
link.appendChild(meta);
|
|
|
|
fragment.appendChild(link);
|
|
}
|
|
|
|
return fragment;
|
|
}
|
|
|
|
function showMediaUploadLimitError(file) {
|
|
const selectedSize = formatBytes(file?.size || 0);
|
|
if (el.uploadLimitModal && el.uploadLimitMessage) {
|
|
el.uploadLimitMessage.textContent = `Uploads are limited to 50 MB per file. Selected file size: ${selectedSize}.`;
|
|
el.uploadLimitModal.classList.remove("hidden");
|
|
return;
|
|
}
|
|
alert(`Uploads are limited to 50 MB per file.\nSelected file size: ${selectedSize}.`);
|
|
}
|
|
|
|
function formatDate(isoString) {
|
|
const d = new Date(isoString);
|
|
const now = new Date();
|
|
const isToday = d.toDateString() === now.toDateString();
|
|
|
|
if (isToday) {
|
|
return `Today at ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
|
}
|
|
return d.toLocaleDateString();
|
|
}
|
|
|
|
function scrollMessagesToBottom() {
|
|
if (!el.messageList) return;
|
|
el.messageList.scrollTop = el.messageList.scrollHeight;
|
|
}
|
|
|
|
let audioCtx = null;
|
|
|
|
// Global interaction listener to unlock AudioContext (autoplay policy)
|
|
window.addEventListener('click', () => {
|
|
if (audioCtx && audioCtx.state === 'suspended') {
|
|
audioCtx.resume();
|
|
}
|
|
}, { once: true });
|
|
|
|
function playSound(type) {
|
|
try {
|
|
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
if (audioCtx.state === 'suspended') audioCtx.resume();
|
|
|
|
const sounds = {
|
|
message: { freq: 880, type: 'sine', duration: 0.1, volume: 0.1 },
|
|
dm: { freq: [660, 880], type: 'sine', duration: 0.15, volume: 0.1 },
|
|
join: { freq: [440, 880], type: 'sine', duration: 0.2, volume: 0.1 },
|
|
leave: { freq: [880, 440], type: 'sine', duration: 0.2, volume: 0.1 },
|
|
'peer-join': { freq: [660, 990], type: 'sine', duration: 0.15, volume: 0.05 },
|
|
'peer-leave': { freq: [990, 660], type: 'sine', duration: 0.15, volume: 0.05 },
|
|
};
|
|
|
|
const s = sounds[type];
|
|
if (!s) return;
|
|
|
|
const osc = audioCtx.createOscillator();
|
|
const gain = audioCtx.createGain();
|
|
|
|
osc.type = s.type;
|
|
if (Array.isArray(s.freq)) {
|
|
osc.frequency.setValueAtTime(s.freq[0], audioCtx.currentTime);
|
|
osc.frequency.exponentialRampToValueAtTime(s.freq[1], audioCtx.currentTime + s.duration);
|
|
} else {
|
|
osc.frequency.setValueAtTime(s.freq, audioCtx.currentTime);
|
|
}
|
|
|
|
gain.gain.setValueAtTime(s.volume, audioCtx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + s.duration);
|
|
|
|
osc.connect(gain);
|
|
gain.connect(audioCtx.destination);
|
|
|
|
osc.start();
|
|
osc.stop(audioCtx.currentTime + s.duration);
|
|
} catch (err) {
|
|
console.warn("playSound failed", err);
|
|
}
|
|
}
|
|
// --- Renderers ---
|
|
|
|
function renderGuilds() {
|
|
el.guildList.innerHTML = "";
|
|
|
|
for (const guild of state.guilds) {
|
|
const btn = document.createElement("button");
|
|
btn.className = `guild-pill ${state.selectedGuildId === guild.id ? "active" : ""}`;
|
|
btn.title = guild.name;
|
|
btn.textContent = shortName(guild.name);
|
|
btn.onclick = async () => {
|
|
state.selectedGuildId = guild.id;
|
|
state.selectedTextChannelId = null;
|
|
state.selectedVoiceChannelId = null;
|
|
state.selectedDmUserId = null;
|
|
state.selectedDmDisplayName = null;
|
|
storageSet(LAST_GUILD_STORAGE_KEY, guild.id);
|
|
renderGuilds();
|
|
renderDMs();
|
|
await loadChannels();
|
|
await loadGuildMembers();
|
|
await refreshVoicePresence();
|
|
startVoicePresencePolling();
|
|
renderMessages([]);
|
|
updateHeaderLabels();
|
|
};
|
|
el.guildList.appendChild(btn);
|
|
}
|
|
}
|
|
|
|
function renderChannels() {
|
|
el.channelList.innerHTML = "";
|
|
el.voiceChannelList.innerHTML = "";
|
|
|
|
for (const channel of state.channels) {
|
|
const row = document.createElement("button");
|
|
row.className = `channel-row ${(channel.kind === 'text' && state.selectedTextChannelId === channel.id) ||
|
|
(channel.kind === 'voice' && state.selectedVoiceChannelId === channel.id) ? "active" : ""
|
|
}`;
|
|
|
|
const iconName = channel.kind === 'text' ? 'hash' : 'volume-2';
|
|
row.innerHTML = `<i data-lucide="${iconName}"></i> <span>${escapeHtml(channel.name)}</span>`;
|
|
|
|
row.onclick = async () => {
|
|
if (channel.kind === 'text') {
|
|
state.selectedDmUserId = null;
|
|
state.selectedDmDisplayName = null;
|
|
state.selectedTextChannelId = channel.id;
|
|
state.voice.viewMode = 'chat';
|
|
renderChannels();
|
|
renderDMs();
|
|
updateView();
|
|
updateHeaderLabels();
|
|
const messages = await api(`/channels/${channel.id}/messages?limit=100`);
|
|
renderMessages(messages);
|
|
} else {
|
|
state.selectedVoiceChannelId = channel.id;
|
|
state.voice.viewMode = 'video';
|
|
renderChannels();
|
|
renderDMs();
|
|
updateView();
|
|
updateHeaderLabels();
|
|
try {
|
|
await joinVoice();
|
|
} catch (err) {
|
|
console.error("joinVoice failed from channel click", err);
|
|
alert(`Voice join failed: ${err?.message || err}`);
|
|
}
|
|
}
|
|
if (window.innerWidth <= 768) closeMobileMenus();
|
|
};
|
|
|
|
if (channel.kind === 'text') {
|
|
el.channelList.appendChild(row);
|
|
} else {
|
|
el.voiceChannelList.appendChild(row);
|
|
|
|
const participants = state.voicePresence.get(channel.id) || [];
|
|
if (participants.length > 0) {
|
|
const pList = document.createElement("div");
|
|
pList.className = "voice-row-members channel-list";
|
|
pList.style.paddingLeft = "24px";
|
|
for (const p of participants) {
|
|
const pRow = document.createElement("div");
|
|
pRow.className = `channel-row voice-participant-row ${p.is_speaking ? 'voice-speaking' : ''}`;
|
|
pRow.style.display = "block";
|
|
pRow.style.padding = "2px 8px";
|
|
pRow.style.flexWrap = "wrap";
|
|
|
|
const topRow = document.createElement("div");
|
|
topRow.style.display = "flex";
|
|
topRow.style.alignItems = "center";
|
|
topRow.style.gap = "8px";
|
|
topRow.style.width = "100%";
|
|
|
|
const avatar = document.createElement("div");
|
|
avatar.className = "avatar";
|
|
avatar.style.width = "20px";
|
|
avatar.style.height = "20px";
|
|
avatar.style.fontSize = "10px";
|
|
avatar.textContent = shortName(p.display_name);
|
|
topRow.appendChild(avatar);
|
|
|
|
const name = document.createElement("span");
|
|
name.style.flex = "1";
|
|
name.style.overflow = "hidden";
|
|
name.style.textOverflow = "ellipsis";
|
|
name.textContent = p.display_name;
|
|
topRow.appendChild(name);
|
|
|
|
const isRemoteParticipant = p.user_id !== state.me.id;
|
|
if (isRemoteParticipant) {
|
|
const volumeToggle = document.createElement("button");
|
|
volumeToggle.type = "button";
|
|
volumeToggle.title = "Toggle volume slider";
|
|
volumeToggle.style.display = "grid";
|
|
volumeToggle.style.placeItems = "center";
|
|
volumeToggle.style.width = "20px";
|
|
volumeToggle.style.height = "20px";
|
|
volumeToggle.style.color = "var(--text-muted)";
|
|
volumeToggle.innerHTML = '<i data-lucide="volume-2" style="width: 14px; height: 14px;"></i>';
|
|
topRow.appendChild(volumeToggle);
|
|
|
|
volumeToggle.addEventListener('click', (e) => {
|
|
toggleVolumeSlider(e);
|
|
});
|
|
}
|
|
|
|
if (p.is_muted) {
|
|
const muteIcon = document.createElement("i");
|
|
muteIcon.setAttribute("data-lucide", "mic-off");
|
|
muteIcon.className = "voice-muted-icon";
|
|
topRow.appendChild(muteIcon);
|
|
}
|
|
|
|
pRow.appendChild(topRow);
|
|
|
|
const volumeControl = document.createElement("div");
|
|
volumeControl.className = `user-volume-control ${state.voice.visibleVolumeSliders.has(p.user_id) ? 'show-volume' : ''}`;
|
|
volumeControl.dataset.userId = p.user_id;
|
|
|
|
if (isRemoteParticipant) {
|
|
const volumeIcon = document.createElement("i");
|
|
volumeIcon.setAttribute("data-lucide", "volume-2");
|
|
volumeIcon.style.width = "12px";
|
|
volumeIcon.style.height = "12px";
|
|
volumeIcon.style.opacity = "0.6";
|
|
volumeControl.appendChild(volumeIcon);
|
|
|
|
const slider = document.createElement("input");
|
|
slider.type = "range";
|
|
slider.min = "0";
|
|
slider.max = "2";
|
|
slider.step = "0.1";
|
|
slider.value = String(state.userVolumes.get(p.user_id) ?? 1.0);
|
|
slider.className = "volume-slider";
|
|
volumeControl.appendChild(slider);
|
|
|
|
const volumePct = document.createElement("span");
|
|
volumePct.className = "vol-pct";
|
|
volumePct.textContent = `${Math.round((state.userVolumes.get(p.user_id) ?? 1.0) * 100)}%`;
|
|
volumeControl.appendChild(volumePct);
|
|
|
|
slider.addEventListener('input', (e) => {
|
|
const val = parseFloat(e.target.value);
|
|
state.userVolumes.set(p.user_id, val);
|
|
volumePct.textContent = `${Math.round(val * 100)}%`;
|
|
|
|
const gainNode = state.voice.peerGainNodes.get(p.user_id);
|
|
if (gainNode && state.voice.audioContext) {
|
|
gainNode.gain.setTargetAtTime(val, state.voice.audioContext.currentTime, 0.05);
|
|
}
|
|
});
|
|
|
|
volumeControl.addEventListener('click', (e) => e.stopPropagation());
|
|
volumeControl.addEventListener('mousedown', (e) => e.stopPropagation());
|
|
pRow.appendChild(volumeControl);
|
|
}
|
|
|
|
const toggleVolumeSlider = (e) => {
|
|
if (!isRemoteParticipant) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (state.voice.visibleVolumeSliders.has(p.user_id)) {
|
|
state.voice.visibleVolumeSliders.delete(p.user_id);
|
|
} else {
|
|
state.voice.visibleVolumeSliders.add(p.user_id);
|
|
}
|
|
renderChannels();
|
|
};
|
|
|
|
// Electron can swallow contextmenu events on some platforms; use
|
|
// right-button mousedown as a reliable fallback for slider toggle.
|
|
pRow.addEventListener('contextmenu', toggleVolumeSlider);
|
|
pRow.addEventListener('auxclick', (e) => {
|
|
if (e.button === 2) toggleVolumeSlider(e);
|
|
});
|
|
pRow.addEventListener('mousedown', (e) => {
|
|
if (e.button === 2) toggleVolumeSlider(e);
|
|
});
|
|
|
|
pList.appendChild(pRow);
|
|
}
|
|
el.voiceChannelList.appendChild(pList);
|
|
}
|
|
}
|
|
}
|
|
lucide.createIcons();
|
|
}
|
|
|
|
function renderDMs() {
|
|
el.dmList.innerHTML = "";
|
|
for (const dm of state.dmConversations) {
|
|
const isOnline = state.onlineUsers.has(dm.user_id);
|
|
const isIdle = state.idleUsers.has(dm.user_id);
|
|
const row = document.createElement("button");
|
|
row.className = `channel-row ${state.selectedDmUserId === dm.user_id ? "active" : ""}`;
|
|
row.innerHTML = `
|
|
<div class="avatar-wrapper">
|
|
<i data-lucide="message-circle"></i>
|
|
<div class="status-dot ${isOnline ? (isIdle ? 'idle' : 'online') : ''}"></div>
|
|
</div>
|
|
<span>${escapeHtml(dm.display_name)}</span>
|
|
`;
|
|
row.onclick = async () => {
|
|
state.selectedDmUserId = dm.user_id;
|
|
state.selectedDmDisplayName = dm.display_name;
|
|
state.selectedTextChannelId = null;
|
|
state.selectedVoiceChannelId = null;
|
|
state.voice.viewMode = 'chat';
|
|
renderChannels();
|
|
renderDMs();
|
|
updateView();
|
|
updateHeaderLabels();
|
|
const messages = await api(`/dms/${dm.user_id}/messages?limit=100`);
|
|
renderMessages(messages);
|
|
};
|
|
el.dmList.appendChild(row);
|
|
}
|
|
|
|
lucide.createIcons();
|
|
}
|
|
|
|
function renderMessages(messages) {
|
|
el.messageList.innerHTML = "";
|
|
let lastAuthorId = null;
|
|
let lastTime = null;
|
|
|
|
for (const m of messages.slice().reverse()) {
|
|
const row = document.createElement("div");
|
|
const mDate = new Date(m.created_at);
|
|
const isGrouped = lastAuthorId === m.author_user_id &&
|
|
lastTime && (mDate - lastTime < 300000); // 5 minutes
|
|
|
|
row.className = `msg ${isGrouped ? "msg-grouped" : ""}`;
|
|
|
|
const displayName = m.author_display_name || "Unknown User";
|
|
const content = document.createElement("div");
|
|
content.className = "msg-content";
|
|
|
|
if (!isGrouped) {
|
|
const avatar = document.createElement("div");
|
|
avatar.className = "msg-avatar";
|
|
avatar.textContent = shortName(displayName);
|
|
row.appendChild(avatar);
|
|
|
|
const header = document.createElement("div");
|
|
header.className = "msg-header";
|
|
|
|
const author = document.createElement("span");
|
|
author.className = "msg-author";
|
|
author.textContent = displayName;
|
|
header.appendChild(author);
|
|
|
|
const time = document.createElement("span");
|
|
time.className = "msg-time";
|
|
time.textContent = formatDate(m.created_at);
|
|
header.appendChild(time);
|
|
|
|
content.appendChild(header);
|
|
}
|
|
|
|
if (m.body) {
|
|
const body = document.createElement("div");
|
|
body.className = "msg-body";
|
|
body.appendChild(buildMessageBody(m.body));
|
|
content.appendChild(body);
|
|
}
|
|
|
|
content.appendChild(buildAttachments(m.attachments));
|
|
row.appendChild(content);
|
|
el.messageList.appendChild(row);
|
|
lastAuthorId = m.author_user_id;
|
|
lastTime = mDate;
|
|
}
|
|
|
|
if (messages.length > 0) {
|
|
state.lastMessageId = messages[0].id;
|
|
}
|
|
|
|
scrollMessagesToBottom();
|
|
requestAnimationFrame(() => {
|
|
scrollMessagesToBottom();
|
|
});
|
|
|
|
// GIF/image height resolves after initial paint; keep chat pinned to latest.
|
|
for (const gifImage of el.messageList.querySelectorAll(".msg-gif img")) {
|
|
if (gifImage.complete) continue;
|
|
gifImage.addEventListener("load", scrollMessagesToBottom, { once: true });
|
|
gifImage.addEventListener("error", scrollMessagesToBottom, { once: true });
|
|
}
|
|
|
|
for (const media of el.messageList.querySelectorAll(".msg-attachment img, .msg-attachment video, .msg-attachment audio")) {
|
|
if ("complete" in media && media.complete) continue;
|
|
media.addEventListener("loadeddata", scrollMessagesToBottom, { once: true });
|
|
media.addEventListener("load", scrollMessagesToBottom, { once: true });
|
|
media.addEventListener("error", scrollMessagesToBottom, { once: true });
|
|
}
|
|
|
|
lucide.createIcons();
|
|
}
|
|
|
|
function renderMembers() {
|
|
el.memberList.innerHTML = "";
|
|
for (const m of state.members) {
|
|
const isOnline = state.onlineUsers.has(m.id);
|
|
const isIdle = state.idleUsers.has(m.id);
|
|
const row = document.createElement("div");
|
|
row.className = "member-row";
|
|
row.innerHTML = `
|
|
<div class="member-avatar">
|
|
${shortName(m.display_name)}
|
|
<div class="status-dot ${isOnline ? (isIdle ? 'idle' : 'online') : ''}"></div>
|
|
</div>
|
|
<div class="member-name">${escapeHtml(m.display_name)}</div>
|
|
`;
|
|
row.style.cursor = "pointer";
|
|
row.onclick = async () => {
|
|
if (state.me && m.id === state.me.id) return;
|
|
state.selectedDmUserId = m.id;
|
|
state.selectedDmDisplayName = m.display_name;
|
|
state.selectedTextChannelId = null;
|
|
state.selectedVoiceChannelId = null;
|
|
state.voice.viewMode = 'chat';
|
|
renderChannels();
|
|
renderDMs();
|
|
updateView();
|
|
updateHeaderLabels();
|
|
const messages = await api(`/dms/${m.id}/messages?limit=100`);
|
|
renderMessages(messages);
|
|
};
|
|
el.memberList.appendChild(row);
|
|
}
|
|
}
|
|
|
|
function updateHeaderLabels() {
|
|
const guild = state.guilds.find((g) => g.id === state.selectedGuildId);
|
|
el.guildTitle.textContent = guild ? guild.name : "No server selected";
|
|
|
|
if (state.selectedDmUserId) {
|
|
const dmFromConversations = state.dmConversations.find((u) => u.user_id === state.selectedDmUserId);
|
|
const dmFromMembers = state.members.find((m) => m.id === state.selectedDmUserId);
|
|
const displayName = dmFromConversations?.display_name || dmFromMembers?.display_name || state.selectedDmDisplayName;
|
|
const dmName = displayName ? `@${displayName}` : "Direct Message";
|
|
el.channelTitle.textContent = dmName;
|
|
el.messageBody.placeholder = displayName
|
|
? `Message @${displayName}`
|
|
: "Message";
|
|
return;
|
|
}
|
|
|
|
if (state.voice.viewMode === 'video' && state.selectedVoiceChannelId) {
|
|
const channel = state.channels.find((c) => c.id === state.selectedVoiceChannelId);
|
|
el.channelTitle.textContent = channel ? channel.name : "Voice Arena";
|
|
return;
|
|
}
|
|
|
|
const channel = state.channels.find((c) => c.id === state.selectedTextChannelId);
|
|
el.channelTitle.textContent = channel ? channel.name : "Select a channel";
|
|
el.messageBody.placeholder = channel ? `Message #${channel.name}` : "Select a channel";
|
|
}
|
|
|
|
async function createInviteLink() {
|
|
if (!state.selectedGuildId) {
|
|
alert("Select a server first.");
|
|
return;
|
|
}
|
|
try {
|
|
const invite = await api(`/guilds/${state.selectedGuildId}/invites`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ max_uses: 50, expires_in_hours: 24 }),
|
|
});
|
|
// Use the backend URL if available, otherwise fallback to current origin
|
|
const link = `${location.origin}/?invite=${encodeURIComponent(invite.code)}`;
|
|
|
|
// Use Electron's native clipboard API if available (defined in preload.js)
|
|
if (window.electronAPI && window.electronAPI.copyToClipboard) {
|
|
try {
|
|
await window.electronAPI.copyToClipboard(link);
|
|
showCopiedBadge();
|
|
return;
|
|
} catch (e) {
|
|
console.error("Electron clipboard write failed:", e);
|
|
}
|
|
}
|
|
|
|
// Fallback to navigator.clipboard
|
|
try {
|
|
await navigator.clipboard.writeText(link);
|
|
showCopiedBadge();
|
|
} catch (err) {
|
|
console.error("Navigator clipboard write failed:", err);
|
|
prompt("Copy invite link:", link);
|
|
}
|
|
} catch (err) {
|
|
alert(err.message);
|
|
}
|
|
}
|
|
|
|
function showCopiedBadge() {
|
|
el.inviteCopiedBadge.classList.remove("hidden");
|
|
setTimeout(() => {
|
|
el.inviteCopiedBadge.classList.add("hidden");
|
|
}, 2000);
|
|
}
|
|
|
|
// --- Logic ---
|
|
|
|
async function loadGuilds() {
|
|
state.guilds = await api("/guilds");
|
|
renderGuilds();
|
|
}
|
|
|
|
async function loadDMConversations() {
|
|
state.dmConversations = await api("/dms");
|
|
renderDMs();
|
|
}
|
|
|
|
async function loadGuildMembers() {
|
|
if (!state.selectedGuildId) {
|
|
state.members = [];
|
|
renderMembers();
|
|
return;
|
|
}
|
|
state.members = await api(`/guilds/${state.selectedGuildId}/members`);
|
|
renderMembers();
|
|
}
|
|
|
|
async function loadChannels() {
|
|
if (!state.selectedGuildId) {
|
|
state.channels = [];
|
|
renderChannels();
|
|
return;
|
|
}
|
|
|
|
state.channels = await api(`/guilds/${state.selectedGuildId}/channels`);
|
|
renderChannels();
|
|
}
|
|
|
|
async function refreshVoicePresence() {
|
|
if (!state.selectedGuildId) {
|
|
state.voicePresence.clear();
|
|
renderChannels();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await api(`/guilds/${state.selectedGuildId}/voice-presence`);
|
|
state.voicePresence.clear();
|
|
for (const entry of res.channels || []) {
|
|
state.voicePresence.set(entry.channel_id, entry.participants || []);
|
|
}
|
|
renderChannels();
|
|
} catch (err) {
|
|
console.warn("voice presence failed", err);
|
|
}
|
|
}
|
|
|
|
function startVoicePresencePolling() {
|
|
stopVoicePresencePolling();
|
|
state.voicePresencePollId = setInterval(() => {
|
|
if (!state.sessionActive) return;
|
|
refreshVoicePresence().catch(() => { });
|
|
}, 3000);
|
|
}
|
|
|
|
function initChatWs() {
|
|
if (!state.sessionActive) return;
|
|
if (state.chatWs) state.chatWs.close();
|
|
const wsUrl = getWsUrl('/ws');
|
|
const ws = new WebSocket(wsUrl);
|
|
state.chatWs = ws;
|
|
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data);
|
|
const authorId = msg.message?.author_user_id;
|
|
const isFromMe = authorId && state.me && authorId === state.me.id;
|
|
|
|
if (msg.type === "message_created") {
|
|
if (!isFromMe) playSound('message');
|
|
if (state.selectedTextChannelId === msg.channel_id) {
|
|
api(`/channels/${state.selectedTextChannelId}/messages?limit=100`).then(renderMessages);
|
|
}
|
|
} else if (msg.type === "dm_created") {
|
|
if (!isFromMe) playSound('dm');
|
|
if (state.selectedDmUserId === msg.other_user_id) {
|
|
api(`/dms/${state.selectedDmUserId}/messages?limit=100`).then(renderMessages);
|
|
playSound('dm');
|
|
loadDMConversations().then(renderDMs);
|
|
} else {
|
|
loadDMConversations().then(renderDMs);
|
|
}
|
|
} else if (msg.type === "user_presence") {
|
|
if (msg.online) {
|
|
state.onlineUsers.add(msg.user_id);
|
|
if (msg.idle) {
|
|
state.idleUsers.add(msg.user_id);
|
|
} else {
|
|
state.idleUsers.delete(msg.user_id);
|
|
}
|
|
} else {
|
|
state.onlineUsers.delete(msg.user_id);
|
|
state.idleUsers.delete(msg.user_id);
|
|
}
|
|
renderMembers();
|
|
renderDMs();
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
if (!state.sessionActive) return;
|
|
console.log("Chat WS closed, reconnecting...");
|
|
setTimeout(initChatWs, 3000);
|
|
};
|
|
}
|
|
|
|
// --- Voice ---
|
|
|
|
function getVoiceWsUrl(channelId) {
|
|
return getWsUrl(`/channels/${channelId}/voice/ws`);
|
|
}
|
|
|
|
function shouldInitiateOffer(peerId) {
|
|
if (!state.me || !state.me.id) return false;
|
|
return state.me.id > peerId;
|
|
}
|
|
|
|
function disconnectPeerAudioNodes(peerId) {
|
|
const nodes = state.voice.peerAudioNodes.get(peerId);
|
|
if (!nodes) return;
|
|
|
|
for (const node of [nodes.sourceNode, nodes.compressorNode, nodes.gainNode]) {
|
|
try {
|
|
node?.disconnect();
|
|
} catch { }
|
|
}
|
|
|
|
state.voice.peerAudioNodes.delete(peerId);
|
|
state.voice.peerGainNodes.delete(peerId);
|
|
}
|
|
|
|
function stopAndClearAudioPipeline() {
|
|
for (const peerId of state.voice.peerAudioNodes.keys()) {
|
|
disconnectPeerAudioNodes(peerId);
|
|
}
|
|
if (state.voice.deepFilterProcessor) {
|
|
state.voice.deepFilterProcessor.destroy();
|
|
state.voice.deepFilterProcessor = null;
|
|
}
|
|
if (state.voice.localStream) {
|
|
for (const track of state.voice.localStream.getTracks()) track.stop();
|
|
}
|
|
if (state.voice.rawStream && state.voice.rawStream !== state.voice.localStream) {
|
|
for (const track of state.voice.rawStream.getTracks()) track.stop();
|
|
}
|
|
if (state.voice.audioContext) {
|
|
state.voice.audioContext.close().catch(() => { });
|
|
}
|
|
state.voice.localStream = null;
|
|
state.voice.rawStream = null;
|
|
state.voice.audioContext = null;
|
|
}
|
|
|
|
function stopAndClearVideoPipeline() {
|
|
if (state.voice.videoStream) {
|
|
for (const track of state.voice.videoStream.getTracks()) track.stop();
|
|
}
|
|
state.voice.videoStream = null;
|
|
state.voice.sharingVideo = false;
|
|
document.getElementById(`video-${state.me.id}-camera`)?.parentElement?.remove();
|
|
}
|
|
|
|
function stopAndClearScreenPipeline() {
|
|
if (state.voice.screenStream) {
|
|
for (const track of state.voice.screenStream.getTracks()) track.stop();
|
|
}
|
|
state.voice.screenStream = null;
|
|
state.voice.sharingScreen = false;
|
|
document.getElementById(`video-${state.me.id}-screen`)?.parentElement?.remove();
|
|
}
|
|
|
|
function updateVideoGridVisibility() {
|
|
// Persistence logic: we always update the grid visibility based on participants
|
|
// however the actual container visibility in the UI is managed by updateView()
|
|
}
|
|
|
|
function updateView() {
|
|
if (state.voice.viewMode === 'video') {
|
|
el.videoGrid.classList.remove("hidden");
|
|
el.messageList.classList.add("hidden");
|
|
el.messageInputWrapper.classList.add("hidden");
|
|
} else {
|
|
el.videoGrid.classList.add("hidden");
|
|
el.messageList.classList.remove("hidden");
|
|
el.messageInputWrapper.classList.remove("hidden");
|
|
}
|
|
}
|
|
|
|
function renderVideo(peerId, displayName, stream, source) {
|
|
const videoId = `video-${peerId}-${source}`;
|
|
let videoEl = document.getElementById(videoId);
|
|
if (!videoEl) {
|
|
const container = document.createElement("div");
|
|
container.className = "video-item";
|
|
const label = source === 'screen' ? `${escapeHtml(displayName)}'s Screen` : escapeHtml(displayName);
|
|
container.innerHTML = `
|
|
<video id="${videoId}" autoplay playsinline ${peerId === state.me.id ? "muted" : ""}></video>
|
|
<div class="video-label">${label}</div>
|
|
`;
|
|
container.onclick = () => {
|
|
const isFullscreen = container.classList.contains('fullscreen');
|
|
// Reset all
|
|
document.querySelectorAll('.video-item').forEach(el => el.classList.remove('fullscreen'));
|
|
|
|
if (isFullscreen) {
|
|
el.videoGrid.classList.remove('has-fullscreen');
|
|
} else {
|
|
container.classList.add('fullscreen');
|
|
el.videoGrid.classList.add('has-fullscreen');
|
|
}
|
|
};
|
|
|
|
el.videoGrid.appendChild(container);
|
|
videoEl = container.querySelector("video");
|
|
}
|
|
videoEl.srcObject = stream;
|
|
}
|
|
|
|
async function buildAudioPipeline(rawStream) {
|
|
const ctx = new AudioContext();
|
|
state.voice.audioContext = ctx;
|
|
const source = ctx.createMediaStreamSource(rawStream);
|
|
let processedSource = source;
|
|
|
|
try {
|
|
const deepFilter = await getDeepFilterModule();
|
|
const processor = new deepFilter.DeepFilterNet3Core({
|
|
sampleRate: 48000,
|
|
noiseReductionLevel: 70,
|
|
assetConfig: {
|
|
cdnUrl: resolveDeepFilterAssetsBaseUrl(),
|
|
},
|
|
});
|
|
await processor.initialize();
|
|
const workletNode = await processor.createAudioWorkletNode(ctx);
|
|
processor.setNoiseSuppressionEnabled(true);
|
|
source.connect(workletNode);
|
|
processedSource = workletNode;
|
|
state.voice.deepFilterProcessor = processor;
|
|
console.info('DeepFilterNet3 enabled');
|
|
} catch (err) {
|
|
console.warn('DeepFilterNet3 unavailable, falling back to raw mic audio', err);
|
|
state.voice.deepFilterProcessor = null;
|
|
}
|
|
|
|
// Metering/Speaking detection with adaptive threshold and hysteresis.
|
|
const analyser = ctx.createAnalyser();
|
|
analyser.fftSize = 1024;
|
|
processedSource.connect(analyser);
|
|
|
|
const supportsFloatTimeDomain = typeof analyser.getFloatTimeDomainData === 'function';
|
|
const timeData = supportsFloatTimeDomain ? new Float32Array(analyser.fftSize) : new Uint8Array(analyser.fftSize);
|
|
let localIsSpeaking = false;
|
|
let speechFrames = 0;
|
|
let silenceFrames = 0;
|
|
let noiseFloor = 0.004;
|
|
let levelEma = 0;
|
|
|
|
const checkVolume = () => {
|
|
if (!state.voice.audioContext || state.voice.audioContext.state === 'closed') return;
|
|
|
|
try {
|
|
if (supportsFloatTimeDomain) {
|
|
analyser.getFloatTimeDomainData(timeData);
|
|
} else {
|
|
analyser.getByteTimeDomainData(timeData);
|
|
}
|
|
} catch (err) {
|
|
setTimeout(checkVolume, 60);
|
|
return;
|
|
}
|
|
|
|
let sumSquares = 0;
|
|
let peak = 0;
|
|
for (let i = 0; i < timeData.length; i++) {
|
|
const v = supportsFloatTimeDomain
|
|
? timeData[i]
|
|
: (timeData[i] - 128) / 128;
|
|
sumSquares += v * v;
|
|
const abs = Math.abs(v);
|
|
if (abs > peak) peak = abs;
|
|
}
|
|
|
|
const rms = Math.sqrt(sumSquares / timeData.length);
|
|
const level = Math.max(rms, peak * 0.5);
|
|
levelEma = levelEma * 0.75 + level * 0.25;
|
|
|
|
if (!localIsSpeaking) {
|
|
// Learn room noise slowly while idle.
|
|
noiseFloor = noiseFloor * 0.98 + levelEma * 0.02;
|
|
} else {
|
|
// Do not let noise floor jump up while speaking.
|
|
noiseFloor = Math.min(noiseFloor, levelEma);
|
|
}
|
|
|
|
const startThreshold = Math.max(noiseFloor * 2.2, 0.010);
|
|
const stopThreshold = Math.max(noiseFloor * 1.5, 0.006);
|
|
|
|
if (levelEma > startThreshold) {
|
|
speechFrames = Math.min(speechFrames + 1, 8);
|
|
silenceFrames = 0;
|
|
} else if (levelEma < stopThreshold) {
|
|
silenceFrames = Math.min(silenceFrames + 1, 8);
|
|
speechFrames = Math.max(speechFrames - 1, 0);
|
|
}
|
|
|
|
const newSpeakingState = localIsSpeaking ? silenceFrames < 3 : speechFrames >= 2;
|
|
|
|
if (newSpeakingState !== localIsSpeaking) {
|
|
localIsSpeaking = newSpeakingState;
|
|
if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) {
|
|
state.voice.ws.send(JSON.stringify({ type: 'set_speaking_status', is_speaking: localIsSpeaking }));
|
|
}
|
|
// Local UI update
|
|
state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => {
|
|
if (p.user_id === state.me.id) p.is_speaking = localIsSpeaking;
|
|
});
|
|
renderChannels();
|
|
const myVideo = document.getElementById(`video-${state.me.id}-camera`) || document.getElementById(`video-${state.me.id}-screen`);
|
|
if (myVideo) {
|
|
myVideo.parentElement.classList.toggle('speaking', localIsSpeaking);
|
|
}
|
|
}
|
|
setTimeout(checkVolume, 60);
|
|
};
|
|
checkVolume();
|
|
|
|
const destination = ctx.createMediaStreamDestination();
|
|
processedSource.connect(destination);
|
|
return destination.stream;
|
|
}
|
|
|
|
function resolveDeepFilterModuleUrl() {
|
|
if (location.protocol === 'file:') {
|
|
return new URL('./vendor/deepfilternet3-noise-filter.esm.js', location.href).toString();
|
|
}
|
|
return '/static/vendor/deepfilternet3-noise-filter.esm.js';
|
|
}
|
|
|
|
function resolveDeepFilterAssetsBaseUrl() {
|
|
if (location.protocol === 'file:') {
|
|
return new URL('./vendor/deepfilternet3', location.href).toString();
|
|
}
|
|
return '/static/vendor/deepfilternet3';
|
|
}
|
|
|
|
async function getDeepFilterModule() {
|
|
if (state.voice.deepFilterModule) return state.voice.deepFilterModule;
|
|
const moduleUrl = resolveDeepFilterModuleUrl();
|
|
state.voice.deepFilterModule = await import(moduleUrl);
|
|
return state.voice.deepFilterModule;
|
|
}
|
|
|
|
async function createLocalVoiceStream() {
|
|
const constraints = {
|
|
audio: {
|
|
channelCount: 1,
|
|
sampleRate: 48000,
|
|
echoCancellation: true,
|
|
noiseSuppression: false,
|
|
autoGainControl: true,
|
|
},
|
|
video: false,
|
|
};
|
|
const rawStream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
let localStream = rawStream;
|
|
try {
|
|
localStream = await buildAudioPipeline(rawStream);
|
|
} catch (err) {
|
|
console.warn('voice audio pipeline failed, using raw mic stream', err);
|
|
}
|
|
state.voice.rawStream = rawStream;
|
|
state.voice.localStream = localStream;
|
|
if (state.voice.muted) {
|
|
state.voice.localStream.getAudioTracks().forEach((t) => {
|
|
t.enabled = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
function ensurePeerConnection(peerId) {
|
|
if (state.voice.peerConnections.has(peerId)) {
|
|
return state.voice.peerConnections.get(peerId);
|
|
}
|
|
|
|
const pc = new RTCPeerConnection({
|
|
iceServers: state.voice.iceServers
|
|
});
|
|
|
|
pc.makingOffer = false;
|
|
pc.ignoreOffer = false;
|
|
pc.polite = !shouldInitiateOffer(peerId);
|
|
|
|
// 1. Audio
|
|
if (state.voice.localStream) {
|
|
state.voice.localStream.getAudioTracks().forEach(track => {
|
|
pc.addTrack(track, state.voice.localStream);
|
|
});
|
|
} else {
|
|
pc.addTransceiver('audio', { direction: 'recvonly' });
|
|
}
|
|
|
|
// 2. Camera Video
|
|
if (state.voice.videoStream) {
|
|
state.voice.videoStream.getVideoTracks().forEach(track => {
|
|
const sender = pc.addTrack(track, state.voice.videoStream);
|
|
state.voice.videoSenders.set(peerId, sender);
|
|
});
|
|
}
|
|
|
|
// 3. Screen Video
|
|
if (state.voice.screenStream) {
|
|
state.voice.screenStream.getVideoTracks().forEach(track => {
|
|
const sender = pc.addTrack(track, state.voice.screenStream);
|
|
state.voice.screenSenders.set(peerId, sender);
|
|
});
|
|
}
|
|
|
|
pc.onicecandidate = (event) => {
|
|
if (!event.candidate || !state.voice.ws) return;
|
|
state.voice.ws.send(JSON.stringify({
|
|
type: "signal",
|
|
to_user_id: peerId,
|
|
kind: "ice",
|
|
data: event.candidate,
|
|
}));
|
|
};
|
|
|
|
pc.oniceconnectionstatechange = async () => {
|
|
const iceState = pc.iceConnectionState;
|
|
console.debug(`voice ice state (${peerId}):`, iceState);
|
|
if (iceState !== "failed" || pc.restartingIce) return;
|
|
pc.restartingIce = true;
|
|
try {
|
|
const offer = await pc.createOffer({ iceRestart: true });
|
|
await pc.setLocalDescription(offer);
|
|
if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) {
|
|
state.voice.ws.send(JSON.stringify({
|
|
type: "signal",
|
|
to_user_id: peerId,
|
|
kind: "offer",
|
|
data: pc.localDescription,
|
|
}));
|
|
}
|
|
} catch (err) {
|
|
console.warn("ICE restart failed", err);
|
|
} finally {
|
|
pc.restartingIce = false;
|
|
}
|
|
};
|
|
|
|
pc.ontrack = (event) => {
|
|
const stream = event.streams[0];
|
|
if (event.track.kind === "audio") {
|
|
let audio = document.getElementById(`audio-${peerId}`);
|
|
if (!audio) {
|
|
audio = document.createElement("audio");
|
|
audio.id = `audio-${peerId}`;
|
|
audio.autoplay = true;
|
|
audio.playsInline = true;
|
|
document.body.appendChild(audio);
|
|
}
|
|
audio.muted = false;
|
|
audio.srcObject = new MediaStream([event.track]);
|
|
const tryPlay = () => {
|
|
const playPromise = audio.play();
|
|
if (playPromise && typeof playPromise.catch === "function") {
|
|
playPromise.catch((err) => {
|
|
console.warn(`remote audio playback blocked for ${peerId}`, err);
|
|
});
|
|
}
|
|
};
|
|
audio.onloadedmetadata = tryPlay;
|
|
audio.oncanplay = tryPlay;
|
|
tryPlay();
|
|
|
|
// Web Audio Pipeline for Volume Boosting
|
|
if (!state.voice.audioContext) {
|
|
state.voice.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
}
|
|
const ctx = state.voice.audioContext;
|
|
if (ctx.state === 'suspended') ctx.resume();
|
|
|
|
disconnectPeerAudioNodes(peerId);
|
|
|
|
const sourceNode = ctx.createMediaStreamSource(audio.srcObject);
|
|
const compressorNode = ctx.createDynamicsCompressor();
|
|
const gainNode = ctx.createGain();
|
|
|
|
compressorNode.threshold.setValueAtTime(-24, ctx.currentTime);
|
|
compressorNode.knee.setValueAtTime(18, ctx.currentTime);
|
|
compressorNode.ratio.setValueAtTime(4, ctx.currentTime);
|
|
compressorNode.attack.setValueAtTime(0.003, ctx.currentTime);
|
|
compressorNode.release.setValueAtTime(0.25, ctx.currentTime);
|
|
|
|
const currentVolume = state.userVolumes.get(peerId) ?? 1.0;
|
|
gainNode.gain.setValueAtTime(currentVolume, ctx.currentTime);
|
|
|
|
sourceNode.connect(compressorNode);
|
|
compressorNode.connect(gainNode);
|
|
gainNode.connect(ctx.destination);
|
|
|
|
state.voice.peerGainNodes.set(peerId, gainNode);
|
|
state.voice.peerAudioNodes.set(peerId, { sourceNode, compressorNode, gainNode });
|
|
|
|
// Mute the original element as we play through Web Audio destination
|
|
audio.volume = 0;
|
|
audio.muted = true;
|
|
} else if (event.track.kind === "video") {
|
|
const peer = state.members.find(m => m.id === peerId) || { display_name: "Unknown" };
|
|
renderVideo(peerId, peer.display_name, stream, event.track.id);
|
|
|
|
event.track.onmute = () => {
|
|
document.getElementById(`video-${peerId}-${event.track.id}`)?.parentElement?.remove();
|
|
};
|
|
event.track.onended = () => {
|
|
document.getElementById(`video-${peerId}-${event.track.id}`)?.parentElement?.remove();
|
|
};
|
|
}
|
|
};
|
|
|
|
pc.onnegotiationneeded = async () => {
|
|
try {
|
|
pc.makingOffer = true;
|
|
const offer = await pc.createOffer();
|
|
await pc.setLocalDescription(offer);
|
|
state.voice.ws.send(JSON.stringify({
|
|
type: "signal",
|
|
to_user_id: peerId,
|
|
kind: "offer",
|
|
data: pc.localDescription,
|
|
}));
|
|
} catch (err) {
|
|
console.error("negotiation failed", err);
|
|
} finally {
|
|
pc.makingOffer = false;
|
|
}
|
|
};
|
|
|
|
state.voice.peerConnections.set(peerId, pc);
|
|
return pc;
|
|
}
|
|
|
|
async function attachLocalAudioToPeerConnections() {
|
|
if (!state.voice.localStream) return;
|
|
const localTrack = state.voice.localStream.getAudioTracks()[0];
|
|
if (!localTrack) return;
|
|
|
|
await Promise.all(
|
|
Array.from(state.voice.peerConnections.values()).map(async (pc) => {
|
|
const audioTransceiver = pc.getTransceivers().find((t) => t.receiver?.track?.kind === 'audio');
|
|
if (audioTransceiver?.sender) {
|
|
await audioTransceiver.sender.replaceTrack(localTrack);
|
|
if (audioTransceiver.direction === 'recvonly') {
|
|
audioTransceiver.direction = 'sendrecv';
|
|
}
|
|
} else {
|
|
pc.addTrack(localTrack, state.voice.localStream);
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
async function handleSignal(fromPeerId, kind, data) {
|
|
const pc = ensurePeerConnection(fromPeerId);
|
|
|
|
try {
|
|
if (kind === "offer") {
|
|
const offerCollision = pc.makingOffer || pc.signalingState !== "stable";
|
|
pc.ignoreOffer = !pc.polite && offerCollision;
|
|
if (pc.ignoreOffer) return;
|
|
|
|
await pc.setRemoteDescription(new RTCSessionDescription(data));
|
|
const answer = await pc.createAnswer();
|
|
await pc.setLocalDescription(answer);
|
|
state.voice.ws.send(JSON.stringify({
|
|
type: "signal",
|
|
to_user_id: fromPeerId,
|
|
kind: "answer",
|
|
data: pc.localDescription,
|
|
}));
|
|
} else if (kind === "answer") {
|
|
await pc.setRemoteDescription(new RTCSessionDescription(data));
|
|
} else if (kind === "ice") {
|
|
try {
|
|
await pc.addIceCandidate(data ? new RTCIceCandidate(data) : null);
|
|
} catch (err) {
|
|
if (!pc.ignoreOffer) {
|
|
console.warn("failed to add ice candidate", err);
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error("handleSignal failed", err);
|
|
}
|
|
}
|
|
|
|
async function joinVoice() {
|
|
if (!state.selectedVoiceChannelId) {
|
|
alert('No voice channel selected.');
|
|
return;
|
|
}
|
|
if (state.voice.joinedChannelId === state.selectedVoiceChannelId &&
|
|
state.voice.ws &&
|
|
state.voice.ws.readyState === WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
|
|
await leaveVoice();
|
|
|
|
el.voiceConnection.classList.remove('hidden');
|
|
el.vcChannelName.textContent = 'Connecting...';
|
|
|
|
const ws = new WebSocket(getVoiceWsUrl(state.selectedVoiceChannelId));
|
|
const currentWs = ws;
|
|
let wsOpened = false;
|
|
let micUnavailable = false;
|
|
|
|
state.voice.ws = ws;
|
|
state.voice.joinedChannelId = state.selectedVoiceChannelId;
|
|
storageSet("active_voice_channel", state.selectedVoiceChannelId);
|
|
|
|
// Start mic setup in parallel so channel join is not blocked by device init.
|
|
createLocalVoiceStream()
|
|
.then(async () => {
|
|
if (state.voice.ws !== currentWs) {
|
|
stopAndClearAudioPipeline();
|
|
return;
|
|
}
|
|
try {
|
|
await attachLocalAudioToPeerConnections();
|
|
} catch (err) {
|
|
console.warn('failed to attach local audio to existing peer connections', err);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
micUnavailable = true;
|
|
console.warn('failed to initialize local voice stream, joining as listen-only', err);
|
|
state.voice.localStream = null;
|
|
state.voice.rawStream = null;
|
|
if (state.voice.audioContext) {
|
|
state.voice.audioContext.close().catch(() => { });
|
|
state.voice.audioContext = null;
|
|
}
|
|
});
|
|
|
|
const connectTimer = setTimeout(() => {
|
|
if (!wsOpened && state.voice.ws === currentWs) {
|
|
console.error('voice websocket connect timeout');
|
|
try { currentWs.close(); } catch (_) { }
|
|
el.vcChannelName.textContent = 'Connection timeout';
|
|
alert('Could not connect to voice channel (timeout). Please try again.');
|
|
}
|
|
}, 5000);
|
|
|
|
ws.onopen = () => {
|
|
if (state.voice.ws !== currentWs) return;
|
|
wsOpened = true;
|
|
clearTimeout(connectTimer);
|
|
const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId);
|
|
el.vcChannelName.textContent = channel ? channel.name : "Voice";
|
|
if (micUnavailable) {
|
|
console.warn('Voice joined in listen-only mode because microphone is unavailable.');
|
|
}
|
|
el.voiceConnection.classList.remove("hidden");
|
|
el.soundboard.classList.remove("hidden");
|
|
playSound('join');
|
|
refreshVoicePresence().catch(() => { });
|
|
loadSounds().catch(() => { });
|
|
};
|
|
|
|
ws.onmessage = async (event) => {
|
|
if (state.voice.ws !== currentWs) return;
|
|
const msg = JSON.parse(event.data);
|
|
if (msg.type === "peers") {
|
|
for (const peer of msg.peers) {
|
|
ensurePeerConnection(peer.user_id);
|
|
}
|
|
} else if (msg.type === "peer_joined") {
|
|
playSound('peer-join');
|
|
ensurePeerConnection(msg.user_id);
|
|
} else if (msg.type === "peer_left") {
|
|
playSound('peer-leave');
|
|
const pc = state.voice.peerConnections.get(msg.user_id);
|
|
if (pc) {
|
|
pc.close();
|
|
state.voice.peerConnections.delete(msg.user_id);
|
|
}
|
|
disconnectPeerAudioNodes(msg.user_id);
|
|
document.getElementById(`audio-${msg.user_id}`)?.remove();
|
|
} else if (msg.type === "signal") {
|
|
await handleSignal(msg.from_user_id, msg.kind, msg.data);
|
|
} else if (msg.type === "video_status_changed") {
|
|
if (!msg.is_sharing_video) {
|
|
document.getElementById(`video-${msg.user_id}-camera`)?.parentElement?.remove();
|
|
}
|
|
} else if (msg.type === "screen_status_changed") {
|
|
if (!msg.is_sharing_screen) {
|
|
document.getElementById(`video-${msg.user_id}-screen`)?.parentElement?.remove();
|
|
}
|
|
} else if (msg.type === "speaking_status_changed") {
|
|
state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => {
|
|
if (p.user_id === msg.user_id) p.is_speaking = msg.is_speaking;
|
|
});
|
|
renderChannels();
|
|
const videoEl = document.getElementById(`video-${msg.user_id}-camera`) || document.getElementById(`video-${msg.user_id}-screen`);
|
|
if (videoEl) {
|
|
videoEl.parentElement.classList.toggle('speaking', msg.is_speaking);
|
|
}
|
|
} else if (msg.type === "mute_status_changed") {
|
|
state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => {
|
|
if (p.user_id === msg.user_id) p.is_muted = msg.is_muted;
|
|
});
|
|
renderChannels();
|
|
} else if (msg.type === "play_sound") {
|
|
const audio = new Audio(msg.media_url);
|
|
audio.play().catch(console.error);
|
|
}
|
|
refreshVoicePresence().catch(() => { });
|
|
};
|
|
|
|
ws.onerror = (event) => {
|
|
if (state.voice.ws !== currentWs) return;
|
|
console.error('voice websocket error', event);
|
|
el.vcChannelName.textContent = 'Connection failed';
|
|
};
|
|
|
|
ws.onclose = (event) => {
|
|
if (state.voice.ws !== currentWs) return;
|
|
clearTimeout(connectTimer);
|
|
if (!wsOpened) {
|
|
console.error('voice websocket closed before open', event.code, event.reason);
|
|
const code = event && typeof event.code === 'number' ? event.code : 'unknown';
|
|
el.vcChannelName.textContent = `Connection failed (${code})`;
|
|
alert(`Voice connection failed (code ${code}).`);
|
|
}
|
|
if (wsOpened) {
|
|
el.voiceConnection.classList.add("hidden");
|
|
}
|
|
el.soundboard.classList.add("hidden");
|
|
for (const pc of state.voice.peerConnections.values()) pc.close();
|
|
state.voice.peerConnections.clear();
|
|
state.voice.videoSenders.clear();
|
|
state.voice.screenSenders.clear();
|
|
stopAndClearAudioPipeline();
|
|
state.voice.joinedChannelId = null;
|
|
state.voice.ws = null;
|
|
playSound('leave');
|
|
refreshVoicePresence().catch(() => { });
|
|
};
|
|
}
|
|
|
|
async function leaveVoice() {
|
|
storageRemove("active_voice_channel");
|
|
if (state.voice.ws) state.voice.ws.close();
|
|
}
|
|
|
|
function toggleMute() {
|
|
if (!state.voice.localStream) return;
|
|
state.voice.muted = !state.voice.muted;
|
|
state.voice.localStream.getAudioTracks().forEach((t) => {
|
|
t.enabled = !state.voice.muted;
|
|
});
|
|
if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) {
|
|
state.voice.ws.send(JSON.stringify({ type: "set_mute_status", is_muted: state.voice.muted }));
|
|
}
|
|
el.voiceMuteBtn.innerHTML = state.voice.muted ? '<i data-lucide="mic-off"></i>' : '<i data-lucide="mic"></i>';
|
|
el.voiceMuteBtn.style.color = state.voice.muted ? 'var(--danger)' : 'var(--text-muted)';
|
|
lucide.createIcons();
|
|
}
|
|
|
|
async function toggleVideo() {
|
|
if (state.voice.sharingVideo) {
|
|
stopAndClearVideoPipeline();
|
|
if (state.voice.ws) {
|
|
state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: false }));
|
|
}
|
|
for (const [peerId, pc] of state.voice.peerConnections) {
|
|
const sender = state.voice.videoSenders.get(peerId);
|
|
if (sender) {
|
|
pc.removeTrack(sender);
|
|
state.voice.videoSenders.delete(peerId);
|
|
}
|
|
}
|
|
} else {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
|
state.voice.videoStream = stream;
|
|
state.voice.sharingVideo = true;
|
|
renderVideo(state.me.id, state.me.display_name, stream, 'camera');
|
|
|
|
if (state.voice.ws) {
|
|
state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: true }));
|
|
}
|
|
|
|
for (const [peerId, pc] of state.voice.peerConnections) {
|
|
stream.getVideoTracks().forEach(track => {
|
|
const sender = pc.addTrack(track, stream);
|
|
state.voice.videoSenders.set(peerId, sender);
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error("camera denied", err);
|
|
alert("Could not access camera.");
|
|
return;
|
|
}
|
|
}
|
|
|
|
el.voiceVideoBtn.innerHTML = state.voice.sharingVideo ? '<i data-lucide="video"></i>' : '<i data-lucide="video-off"></i>';
|
|
el.voiceVideoBtn.style.color = state.voice.sharingVideo ? 'var(--green)' : 'var(--text-muted)';
|
|
lucide.createIcons();
|
|
}
|
|
|
|
async function toggleScreenShare() {
|
|
if (state.voice.sharingScreen) {
|
|
stopAndClearScreenPipeline();
|
|
if (state.voice.ws) {
|
|
state.voice.ws.send(JSON.stringify({ type: "set_screen_status", is_sharing_screen: false }));
|
|
}
|
|
for (const [peerId, pc] of state.voice.peerConnections) {
|
|
const sender = state.voice.screenSenders.get(peerId);
|
|
if (sender) {
|
|
pc.removeTrack(sender);
|
|
state.voice.screenSenders.delete(peerId);
|
|
}
|
|
}
|
|
} else {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
|
|
state.voice.screenStream = stream;
|
|
state.voice.sharingScreen = true;
|
|
renderVideo(state.me.id, state.me.display_name, stream, 'screen');
|
|
|
|
// Stop sharing if user clicks "Stop sharing" in browser UI
|
|
stream.getVideoTracks()[0].onended = () => {
|
|
if (state.voice.sharingScreen) toggleScreenShare();
|
|
};
|
|
|
|
if (state.voice.ws) {
|
|
state.voice.ws.send(JSON.stringify({ type: "set_screen_status", is_sharing_screen: true }));
|
|
}
|
|
|
|
for (const [peerId, pc] of state.voice.peerConnections) {
|
|
stream.getVideoTracks().forEach(track => {
|
|
const sender = pc.addTrack(track, stream);
|
|
state.voice.screenSenders.set(peerId, sender);
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error("screen share denied", err);
|
|
return;
|
|
}
|
|
}
|
|
|
|
el.voiceScreenBtn.innerHTML = state.voice.sharingScreen ? '<i data-lucide="monitor"></i>' : '<i data-lucide="monitor-off"></i>';
|
|
el.voiceScreenBtn.style.color = state.voice.sharingScreen ? 'var(--green)' : 'var(--text-muted)';
|
|
lucide.createIcons();
|
|
}
|
|
|
|
function toggleWatchVideo() {
|
|
state.voice.watchingVideo = !state.voice.watchingVideo;
|
|
updateVideoGridVisibility();
|
|
|
|
el.voiceWatchBtn.innerHTML = state.voice.watchingVideo ? '<i data-lucide="eye"></i>' : '<i data-lucide="eye-off"></i>';
|
|
el.voiceWatchBtn.style.color = state.voice.watchingVideo ? 'var(--green)' : 'var(--danger)';
|
|
el.voiceWatchBtn.title = state.voice.watchingVideo ? 'Stop Watching' : 'Start Watching';
|
|
lucide.createIcons();
|
|
}
|
|
|
|
// --- Sound Board ---
|
|
|
|
async function loadSounds() {
|
|
if (!state.selectedGuildId) return;
|
|
try {
|
|
const sounds = await api(`/guilds/${state.selectedGuildId}/sounds`);
|
|
renderSounds(sounds);
|
|
} catch (err) {
|
|
console.error("failed to load sounds", err);
|
|
}
|
|
}
|
|
|
|
function renderSounds(sounds) {
|
|
el.soundboardGrid.innerHTML = '';
|
|
sounds.forEach(sound => {
|
|
const item = document.createElement('div');
|
|
item.className = 'sound-item';
|
|
|
|
// Only show delete button if user is creator or owner
|
|
const canDelete = state.me && (state.me.id === sound.created_by_user_id || (state.guilds.find(g => g.id === state.selectedGuildId)?.owner_user_id === state.me.id));
|
|
|
|
const icon = document.createElement('div');
|
|
icon.className = 'sound-icon';
|
|
icon.textContent = sound.icon;
|
|
const name = document.createElement('div');
|
|
name.className = 'sound-name';
|
|
name.textContent = sound.name;
|
|
item.appendChild(icon);
|
|
item.appendChild(name);
|
|
|
|
if (canDelete) {
|
|
const deleteBtn = document.createElement('button');
|
|
deleteBtn.className = 'sound-delete';
|
|
deleteBtn.title = 'Delete sound';
|
|
deleteBtn.innerHTML = '<i data-lucide="x"></i>';
|
|
item.appendChild(deleteBtn);
|
|
}
|
|
|
|
item.onclick = (e) => {
|
|
if (e.target.closest('.sound-delete')) {
|
|
e.stopPropagation();
|
|
if (confirm(`Are you sure you want to delete "${sound.name}"?`)) {
|
|
deleteSound(sound.id);
|
|
}
|
|
return;
|
|
}
|
|
playRemoteSound(sound.id, sound.media_url);
|
|
};
|
|
|
|
el.soundboardGrid.appendChild(item);
|
|
});
|
|
lucide.createIcons();
|
|
}
|
|
|
|
async function deleteSound(soundId) {
|
|
if (!state.selectedGuildId) return;
|
|
try {
|
|
await api(`/guilds/${state.selectedGuildId}/sounds/${soundId}`, { method: 'DELETE' });
|
|
await loadSounds();
|
|
} catch (err) {
|
|
alert(err.message);
|
|
}
|
|
}
|
|
|
|
function playRemoteSound(soundId, mediaUrl) {
|
|
if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) {
|
|
state.voice.ws.send(JSON.stringify({ type: 'play_sound', sound_id: soundId }));
|
|
}
|
|
// Also play locally immediately
|
|
const audio = new Audio(mediaUrl);
|
|
audio.play().catch(console.error);
|
|
}
|
|
|
|
// --- Mobile Logic ---
|
|
|
|
function toggleMobileMenu() {
|
|
const isOpen = el.main.classList.contains("menu-open");
|
|
if (isOpen) {
|
|
el.main.classList.remove("menu-open");
|
|
el.overlay.classList.add("hidden");
|
|
} else {
|
|
el.main.classList.add("menu-open");
|
|
el.utilitySidebar.classList.remove("active"); // close members if open
|
|
el.overlay.classList.remove("hidden");
|
|
}
|
|
}
|
|
|
|
function toggleMobileMembers() {
|
|
const isOpen = el.utilitySidebar.classList.contains("active");
|
|
if (isOpen) {
|
|
el.utilitySidebar.classList.remove("active");
|
|
el.overlay.classList.add("hidden");
|
|
} else {
|
|
el.utilitySidebar.classList.add("active");
|
|
el.main.classList.remove("menu-open"); // close menu if open
|
|
el.overlay.classList.remove("hidden");
|
|
}
|
|
}
|
|
|
|
function closeMobileMenus() {
|
|
el.main.classList.remove("menu-open");
|
|
el.utilitySidebar.classList.remove("active");
|
|
el.overlay.classList.add("hidden");
|
|
}
|
|
|
|
// --- Initialization ---
|
|
|
|
let inactivityTimer = null;
|
|
let isCurrentlyIdle = false;
|
|
let updaterInitialized = false;
|
|
|
|
function resetInactivityTimer() {
|
|
if (isCurrentlyIdle) {
|
|
isCurrentlyIdle = false;
|
|
if (state.chatWs && state.chatWs.readyState === WebSocket.OPEN) {
|
|
state.chatWs.send(JSON.stringify({ type: "set_idle_status", is_idle: false }));
|
|
}
|
|
}
|
|
|
|
if (inactivityTimer) clearTimeout(inactivityTimer);
|
|
inactivityTimer = setTimeout(() => {
|
|
isCurrentlyIdle = true;
|
|
if (state.chatWs && state.chatWs.readyState === WebSocket.OPEN) {
|
|
state.chatWs.send(JSON.stringify({ type: "set_idle_status", is_idle: true }));
|
|
}
|
|
}, 5 * 60 * 1000); // 5 minutes
|
|
}
|
|
|
|
document.addEventListener('mousemove', resetInactivityTimer);
|
|
document.addEventListener('keydown', resetInactivityTimer);
|
|
document.addEventListener('click', resetInactivityTimer);
|
|
|
|
async function init() {
|
|
lucide.createIcons();
|
|
initUpdater();
|
|
|
|
// 2. Global Button Handlers
|
|
el.loginBtn.onclick = () => {
|
|
el.status.textContent = "";
|
|
location.href = "/auth/login";
|
|
};
|
|
|
|
el.logoutBtn.onclick = async () => {
|
|
try {
|
|
await api("/auth/logout", { method: "POST" });
|
|
} catch (e) {
|
|
if (!e?.isAuthError) {
|
|
console.warn("logout request failed", e);
|
|
}
|
|
}
|
|
await showLoggedOutState("Signed out.");
|
|
};
|
|
|
|
el.addGuildBtn.onclick = () => { el.modalContainer.classList.remove("hidden"); };
|
|
el.createInviteBtn.onclick = createInviteLink;
|
|
el.modalCancel.onclick = () => { el.modalContainer.classList.add("hidden"); };
|
|
|
|
el.addTextBtn.onclick = () => {
|
|
if (!state.selectedGuildId) { alert("Create or select a server first."); return; }
|
|
document.querySelector('input[name="channel-kind"][value="text"]').checked = true;
|
|
el.channelModal.classList.remove("hidden");
|
|
};
|
|
el.addVoiceBtn.onclick = () => {
|
|
if (!state.selectedGuildId) { alert("Create or select a server first."); return; }
|
|
document.querySelector('input[name="channel-kind"][value="voice"]').checked = true;
|
|
el.channelModal.classList.remove("hidden");
|
|
};
|
|
el.channelModalCancel.onclick = () => { el.channelModal.classList.add("hidden"); };
|
|
|
|
// Mobile Listeners
|
|
if (el.mobileMenuBtn) el.mobileMenuBtn.onclick = toggleMobileMenu;
|
|
if (el.mobileMembersBtn) el.mobileMembersBtn.onclick = toggleMobileMembers;
|
|
if (el.overlay) el.overlay.onclick = closeMobileMenus;
|
|
|
|
// 3. Form Submissions
|
|
el.guildForm.onsubmit = async (e) => {
|
|
e.preventDefault();
|
|
try {
|
|
const guild = await api("/guilds", { method: "POST", body: JSON.stringify({ name: el.guildName.value }) });
|
|
el.guildName.value = "";
|
|
el.modalContainer.classList.add("hidden");
|
|
state.guilds.push(guild);
|
|
state.selectedGuildId = guild.id;
|
|
state.selectedTextChannelId = null;
|
|
state.selectedVoiceChannelId = null;
|
|
state.selectedDmUserId = null;
|
|
state.selectedDmDisplayName = null;
|
|
storageSet(LAST_GUILD_STORAGE_KEY, guild.id);
|
|
renderGuilds();
|
|
await loadChannels();
|
|
await loadGuildMembers();
|
|
await refreshVoicePresence();
|
|
renderMessages([]);
|
|
updateHeaderLabels();
|
|
} catch (err) { alert(err.message); }
|
|
};
|
|
|
|
el.channelForm.onsubmit = async (e) => {
|
|
e.preventDefault();
|
|
if (!state.selectedGuildId) { alert("Select a server first."); return; }
|
|
const kindInput = document.querySelector('input[name="channel-kind"]:checked');
|
|
const kind = kindInput ? kindInput.value : "text";
|
|
const channelName = el.channelName.value.trim();
|
|
if (!channelName) { alert("Channel name is required."); return; }
|
|
try {
|
|
const created = await api("/channels", {
|
|
method: "POST",
|
|
body: JSON.stringify({ guild_id: state.selectedGuildId, name: channelName, kind: kind }),
|
|
});
|
|
el.channelName.value = "";
|
|
el.channelModal.classList.add("hidden");
|
|
await loadChannels();
|
|
if (created.kind === "text") {
|
|
state.selectedTextChannelId = created.id;
|
|
state.selectedVoiceChannelId = null;
|
|
state.selectedDmUserId = null;
|
|
state.selectedDmDisplayName = null;
|
|
renderChannels();
|
|
renderDMs();
|
|
updateHeaderLabels();
|
|
const messages = await api(`/channels/${created.id}/messages?limit=100`);
|
|
renderMessages(messages);
|
|
} else {
|
|
state.selectedVoiceChannelId = created.id;
|
|
state.selectedTextChannelId = null;
|
|
state.selectedDmUserId = null;
|
|
state.selectedDmDisplayName = null;
|
|
renderChannels();
|
|
renderDMs();
|
|
updateHeaderLabels();
|
|
await joinVoice();
|
|
}
|
|
} catch (err) { alert(err.message); }
|
|
};
|
|
|
|
el.messageForm.onsubmit = async (e) => {
|
|
e.preventDefault();
|
|
const body = el.messageBody.value.trim();
|
|
if (!body) return;
|
|
try {
|
|
if (state.selectedTextChannelId) {
|
|
await api(`/channels/${state.selectedTextChannelId}/messages`, { method: "POST", body: JSON.stringify({ body }) });
|
|
} else if (state.selectedDmUserId) {
|
|
await api(`/dms/${state.selectedDmUserId}/messages`, { method: "POST", body: JSON.stringify({ body }) });
|
|
} else return;
|
|
el.messageBody.value = "";
|
|
const messages = state.selectedTextChannelId
|
|
? await api(`/channels/${state.selectedTextChannelId}/messages?limit=100`)
|
|
: await api(`/dms/${state.selectedDmUserId}/messages?limit=100`);
|
|
renderMessages(messages);
|
|
await loadDMConversations();
|
|
renderDMs();
|
|
} catch (err) { alert(err.message); }
|
|
};
|
|
|
|
if (el.mediaUploadBtn && el.mediaFileInput) {
|
|
el.mediaUploadBtn.onclick = () => {
|
|
if (!state.selectedTextChannelId && !state.selectedDmUserId) {
|
|
alert("Select a chat first.");
|
|
return;
|
|
}
|
|
el.mediaFileInput.click();
|
|
};
|
|
|
|
el.mediaFileInput.onchange = async () => {
|
|
const file = el.mediaFileInput.files?.[0];
|
|
if (!file) return;
|
|
try {
|
|
await uploadMediaFile(file);
|
|
} catch (err) {
|
|
alert(err.message);
|
|
} finally {
|
|
el.mediaFileInput.value = "";
|
|
}
|
|
};
|
|
}
|
|
|
|
// 4. Voice/Soundboard Controls
|
|
el.voiceVideoBtn.onclick = toggleVideo;
|
|
el.voiceScreenBtn.onclick = toggleScreenShare;
|
|
el.voiceMuteBtn.onclick = toggleMute;
|
|
el.voiceLeaveBtn.onclick = leaveVoice;
|
|
|
|
el.addSoundBtn.onclick = () => { el.soundModal.classList.remove("hidden"); };
|
|
el.soundModalCancel.onclick = () => { el.soundModal.classList.add("hidden"); };
|
|
|
|
el.soundForm.onsubmit = async (e) => {
|
|
e.preventDefault();
|
|
if (!state.selectedGuildId) return;
|
|
const formData = new FormData();
|
|
formData.append('name', el.soundName.value);
|
|
formData.append('icon', el.soundIcon.value);
|
|
formData.append('file', el.soundFile.files[0]);
|
|
el.soundSubmitBtn.disabled = true;
|
|
el.soundSubmitBtn.textContent = 'Uploading...';
|
|
try {
|
|
const response = await fetch(`/guilds/${state.selectedGuildId}/sounds`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
credentials: 'same-origin',
|
|
});
|
|
if (!response.ok) { const err = await response.json(); throw new Error(err.error || 'Upload failed'); }
|
|
el.soundModal.classList.add("hidden");
|
|
el.soundForm.reset();
|
|
await loadSounds();
|
|
} catch (err) { alert(err.message); } finally {
|
|
el.soundSubmitBtn.disabled = false;
|
|
el.soundSubmitBtn.textContent = 'Add Sound';
|
|
}
|
|
};
|
|
|
|
if (el.uploadLimitOk && el.uploadLimitModal) {
|
|
el.uploadLimitOk.onclick = () => {
|
|
el.uploadLimitModal.classList.add("hidden");
|
|
};
|
|
el.uploadLimitModal.onclick = (e) => {
|
|
if (e.target === el.uploadLimitModal) {
|
|
el.uploadLimitModal.classList.add("hidden");
|
|
}
|
|
};
|
|
}
|
|
|
|
// 5. GIF Picker
|
|
const TENOR_API_KEY = "exTiFGKJ0CzESIHzVQWy3pRO8I1MAdpRomg95DBSu2sg6e7YcHgThMI4giGAx8D0";
|
|
const TENOR_CLIENT_KEY = "pavel-discord";
|
|
|
|
el.gifBtn.onclick = () => { el.gifPicker.classList.remove("hidden"); searchGifs(""); };
|
|
el.gifPickerClose.onclick = () => { el.gifPicker.classList.add("hidden"); };
|
|
|
|
let gifSearchTimeout = null;
|
|
el.gifSearchInput.oninput = () => {
|
|
clearTimeout(gifSearchTimeout);
|
|
gifSearchTimeout = setTimeout(() => { searchGifs(el.gifSearchInput.value); }, 500);
|
|
};
|
|
|
|
async function searchGifs(query) {
|
|
el.gifResults.innerHTML = '<div class="gif-loading">Searching...</div>';
|
|
const baseUrl = "https://api.klipy.com/v2";
|
|
const endpoint = query
|
|
? `${baseUrl}/search?q=${encodeURIComponent(query)}&key=${TENOR_API_KEY}&client_key=${TENOR_CLIENT_KEY}&limit=20`
|
|
: `${baseUrl}/featured?key=${TENOR_API_KEY}&client_key=${TENOR_CLIENT_KEY}&limit=20`;
|
|
try {
|
|
const res = await fetch(endpoint);
|
|
const data = await res.json();
|
|
renderGifs(data.results);
|
|
} catch (err) { el.gifResults.innerHTML = '<div class="gif-loading">Error loading GIFs</div>'; }
|
|
}
|
|
|
|
function renderGifs(gifs) {
|
|
el.gifResults.innerHTML = "";
|
|
if (!gifs || gifs.length === 0) { el.gifResults.innerHTML = '<div class="gif-loading">No GIFs found</div>'; return; }
|
|
gifs.forEach(gif => {
|
|
const previewUrl = gif.media_formats.tinygif.url;
|
|
const fullUrl = gif.media_formats.gif.url;
|
|
const item = document.createElement("div");
|
|
item.className = "gif-item";
|
|
const img = document.createElement("img");
|
|
img.src = previewUrl;
|
|
img.loading = "lazy";
|
|
img.onclick = () => {
|
|
el.gifPicker.classList.add("hidden");
|
|
el.gifSearchInput.value = "";
|
|
sendGif(fullUrl);
|
|
};
|
|
item.appendChild(img);
|
|
el.gifResults.appendChild(item);
|
|
});
|
|
}
|
|
|
|
async function sendGif(url) {
|
|
try {
|
|
if (state.selectedTextChannelId) {
|
|
await api(`/channels/${state.selectedTextChannelId}/messages`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ body: url }),
|
|
});
|
|
} else if (state.selectedDmUserId) {
|
|
await api(`/dms/${state.selectedDmUserId}/messages`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ body: url }),
|
|
});
|
|
}
|
|
const messages = state.selectedTextChannelId
|
|
? await api(`/channels/${state.selectedTextChannelId}/messages?limit=100`)
|
|
: await api(`/dms/${state.selectedDmUserId}/messages?limit=100`);
|
|
renderMessages(messages);
|
|
} catch (err) {
|
|
console.error("Failed to send GIF", err);
|
|
}
|
|
}
|
|
|
|
async function uploadMediaFile(file) {
|
|
if (file.size > MAX_MEDIA_UPLOAD_BYTES) {
|
|
showMediaUploadLimitError(file);
|
|
return;
|
|
}
|
|
|
|
const path = state.selectedTextChannelId
|
|
? `/channels/${state.selectedTextChannelId}/attachments`
|
|
: state.selectedDmUserId
|
|
? `/dms/${state.selectedDmUserId}/attachments`
|
|
: null;
|
|
if (!path) throw new Error("Select a chat first.");
|
|
|
|
el.mediaUploadBtn.disabled = true;
|
|
el.mediaUploadBtn.title = "Uploading...";
|
|
|
|
try {
|
|
const response = await fetch(path, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": file.type || "application/octet-stream",
|
|
"X-File-Name": file.name,
|
|
},
|
|
body: file,
|
|
credentials: "same-origin",
|
|
});
|
|
|
|
if (!response.ok) {
|
|
let detail = "upload failed";
|
|
try {
|
|
const payload = await response.json();
|
|
detail = payload.error || detail;
|
|
} catch { }
|
|
throw new Error(`${response.status}: ${detail}`);
|
|
}
|
|
|
|
const messages = state.selectedTextChannelId
|
|
? await api(`/channels/${state.selectedTextChannelId}/messages?limit=100`)
|
|
: await api(`/dms/${state.selectedDmUserId}/messages?limit=100`);
|
|
renderMessages(messages);
|
|
await loadDMConversations();
|
|
renderDMs();
|
|
} finally {
|
|
el.mediaUploadBtn.disabled = false;
|
|
el.mediaUploadBtn.title = "Upload File";
|
|
}
|
|
}
|
|
|
|
// 6. Initial Data Loading & App Start
|
|
try {
|
|
state.me = await api("/me");
|
|
state.sessionActive = true;
|
|
if (state.me && state.me.display_name) {
|
|
el.userName.textContent = state.me.display_name;
|
|
el.userAvatar.textContent = shortName(state.me.display_name);
|
|
}
|
|
el.status.textContent = "";
|
|
el.authScreen.classList.add("hidden");
|
|
el.main.classList.remove("hidden");
|
|
|
|
// Check for invite code in URL
|
|
const params = new URLSearchParams(location.search);
|
|
const inviteCode = params.get("invite");
|
|
if (inviteCode) {
|
|
try {
|
|
const joinedGuild = await api(`/invites/${encodeURIComponent(inviteCode)}/join`, { method: "POST" });
|
|
storageSet(LAST_GUILD_STORAGE_KEY, joinedGuild.id);
|
|
} catch (err) { alert(`Failed to join invite: ${err.message}`); } finally {
|
|
params.delete("invite");
|
|
const nextQuery = params.toString();
|
|
const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`;
|
|
history.replaceState(null, "", nextUrl);
|
|
}
|
|
}
|
|
|
|
state.guilds = await api("/guilds");
|
|
renderGuilds();
|
|
await loadDMConversations();
|
|
|
|
try {
|
|
const online = await api("/presence");
|
|
state.onlineUsers = new Set(online.filter((entry) => entry.online).map((entry) => entry.user_id));
|
|
state.idleUsers = new Set(online.filter((entry) => entry.idle).map((entry) => entry.user_id));
|
|
} catch (err) { console.warn("presence sync failed", err); }
|
|
|
|
if (state.guilds.length > 0) {
|
|
const lastGuildId = storageGet(LAST_GUILD_STORAGE_KEY);
|
|
const guild = state.guilds.find((g) => g.id === lastGuildId) || state.guilds[0];
|
|
state.selectedGuildId = guild.id;
|
|
storageSet(LAST_GUILD_STORAGE_KEY, guild.id);
|
|
renderGuilds();
|
|
await loadChannels();
|
|
await loadGuildMembers();
|
|
await refreshVoicePresence();
|
|
updateHeaderLabels();
|
|
} else {
|
|
state.members = [];
|
|
renderMembers();
|
|
updateHeaderLabels();
|
|
}
|
|
|
|
initChatWs();
|
|
startVoicePresencePolling();
|
|
|
|
// Auto-reconnect to voice if previously joined
|
|
const savedVoiceChannel = storageGet("active_voice_channel");
|
|
if (savedVoiceChannel) {
|
|
console.log("Auto-reconnecting to voice channel:", savedVoiceChannel);
|
|
state.selectedVoiceChannelId = savedVoiceChannel;
|
|
setTimeout(() => {
|
|
joinVoice().catch(err => console.error("Auto-reconnect failed", err));
|
|
}, 800);
|
|
}
|
|
|
|
lucide.createIcons();
|
|
} catch (err) {
|
|
if (!err?.isAuthError) {
|
|
console.error("init failed", err);
|
|
await showLoggedOutState("Unable to load the app.");
|
|
}
|
|
}
|
|
}
|
|
|
|
function initUpdater() {
|
|
if (!window.electronAPI || !el.updateNotifier || !el.updateDownloadBtn || !el.updateInstallBtn) return;
|
|
if (updaterInitialized) return;
|
|
updaterInitialized = true;
|
|
|
|
const applyUpdateState = (state) => {
|
|
if (!state || !state.status) return;
|
|
if (state.status === 'available') {
|
|
const v = state.info?.version ? ` ${state.info.version}` : '';
|
|
el.updateNotifier.classList.remove("hidden");
|
|
el.updateDownloadBtn.classList.remove("hidden");
|
|
el.updateInstallBtn.classList.add("hidden");
|
|
el.updateDownloadBtn.disabled = false;
|
|
el.updateDownloadBtn.style.opacity = "";
|
|
el.updateDownloadBtn.title = `Download Update${v}`;
|
|
return;
|
|
}
|
|
if (state.status === 'downloading') {
|
|
el.updateNotifier.classList.remove("hidden");
|
|
el.updateDownloadBtn.classList.remove("hidden");
|
|
el.updateInstallBtn.classList.add("hidden");
|
|
el.updateDownloadBtn.disabled = true;
|
|
el.updateDownloadBtn.style.opacity = "0.5";
|
|
el.updateDownloadBtn.title = "Downloading update...";
|
|
return;
|
|
}
|
|
if (state.status === 'downloaded') {
|
|
const v = state.info?.version ? ` ${state.info.version}` : '';
|
|
el.updateNotifier.classList.remove("hidden");
|
|
el.updateDownloadBtn.classList.add("hidden");
|
|
el.updateInstallBtn.classList.remove("hidden");
|
|
el.updateInstallBtn.title = `Install Update${v}`;
|
|
return;
|
|
}
|
|
if (state.status === 'not-available') {
|
|
el.updateNotifier.classList.add("hidden");
|
|
el.updateDownloadBtn.disabled = false;
|
|
el.updateDownloadBtn.style.opacity = "";
|
|
return;
|
|
}
|
|
if (state.status === 'error') {
|
|
el.updateDownloadBtn.disabled = false;
|
|
el.updateDownloadBtn.style.opacity = "";
|
|
}
|
|
};
|
|
|
|
window.electronAPI.onUpdateState((state) => {
|
|
applyUpdateState(state);
|
|
});
|
|
|
|
window.electronAPI.onUpdateAvailable((info) => {
|
|
console.log("Update available:", info.version);
|
|
el.updateNotifier.classList.remove("hidden");
|
|
el.updateDownloadBtn.classList.remove("hidden");
|
|
el.updateInstallBtn.classList.add("hidden");
|
|
});
|
|
|
|
window.electronAPI.onUpdateDownloaded((info) => {
|
|
console.log("Update downloaded:", info.version);
|
|
el.updateNotifier.classList.remove("hidden");
|
|
el.updateDownloadBtn.classList.add("hidden");
|
|
el.updateInstallBtn.classList.remove("hidden");
|
|
el.updateInstallBtn.title = `Install Update ${info.version}`;
|
|
});
|
|
|
|
window.electronAPI.onUpdateError((err) => {
|
|
console.error("Update error:", err);
|
|
// Optionally hide indicator on error
|
|
// el.updateNotifier.classList.add("hidden");
|
|
});
|
|
|
|
el.updateDownloadBtn.onclick = () => {
|
|
el.updateDownloadBtn.disabled = true;
|
|
el.updateDownloadBtn.style.opacity = "0.5";
|
|
window.electronAPI.downloadUpdate();
|
|
};
|
|
|
|
el.updateInstallBtn.onclick = () => {
|
|
window.electronAPI.quitAndInstall();
|
|
};
|
|
|
|
// Recover missed startup events and then run a fresh check now that listeners exist.
|
|
window.electronAPI.getUpdateState?.()
|
|
.then((state) => applyUpdateState(state))
|
|
.catch((err) => console.warn("Failed to fetch initial update state", err))
|
|
.finally(() => {
|
|
if (window.electronAPI.checkForUpdatesNow) {
|
|
void window.electronAPI.checkForUpdatesNow()
|
|
.then((state) => applyUpdateState(state))
|
|
.catch((err) => console.warn("Direct update check failed", err));
|
|
} else {
|
|
window.electronAPI.checkForUpdates?.();
|
|
}
|
|
});
|
|
}
|
|
|
|
init();
|