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 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 = ` ${escapeHtml(channel.name)}`; 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 = ''; 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 = `
${escapeHtml(dm.display_name)} `; 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 = `
${shortName(m.display_name)}
${escapeHtml(m.display_name)}
`; 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 stopAndClearAudioPipeline() { 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 = `
${label}
`; 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: false, }, 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(); const sourceNode = ctx.createMediaStreamSource(audio.srcObject); const gainNode = ctx.createGain(); const currentVolume = state.userVolumes.get(peerId) ?? 1.0; gainNode.gain.setValueAtTime(currentVolume, ctx.currentTime); sourceNode.connect(gainNode); gainNode.connect(ctx.destination); state.voice.peerGainNodes.set(peerId, 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); } 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 ? '' : ''; 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 ? '' : ''; 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 ? '' : ''; 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 ? '' : ''; 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 = ''; 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 = '
Searching...
'; 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 = '
Error loading GIFs
'; } } function renderGifs(gifs) { el.gifResults.innerHTML = ""; if (!gifs || gifs.length === 0) { el.gifResults.innerHTML = '
No GIFs found
'; 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();