const state = { 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) }; // --- Desktop Backend Configuration --- let API_BASE_URL = ''; // Will be initialized via IPC const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId"; const PERSISTED_STORAGE_KEYS = [ "chattz_token", "chattz_refresh_token", LAST_GUILD_STORAGE_KEY, "active_voice_channel", ]; function storageGet(key) { return localStorage.getItem(key); } function storageSet(key, value) { localStorage.setItem(key, value); if (window.electronAPI?.storageSet) { void window.electronAPI.storageSet(key, value).catch((err) => { console.warn("Failed to persist storage key", key, err); }); } } function storageRemove(key) { localStorage.removeItem(key); if (window.electronAPI?.storageRemove) { void window.electronAPI.storageRemove(key).catch((err) => { console.warn("Failed to remove persisted storage key", key, err); }); } } async function storageSetCritical(key, value) { localStorage.setItem(key, value); if (window.electronAPI?.storageSet) { try { await window.electronAPI.storageSet(key, value); } catch (err) { console.warn("Failed to persist critical storage key", key, err); } } } async function storageRemoveCritical(key) { localStorage.removeItem(key); if (window.electronAPI?.storageRemove) { try { await window.electronAPI.storageRemove(key); } catch (err) { console.warn("Failed to remove critical storage key", key, err); } } } async function hydrateDesktopStorage() { if (!window.electronAPI?.storageGet) return; for (const key of PERSISTED_STORAGE_KEYS) { try { // IPC file store (renderer-storage.json) is the source of truth — // it always survives restarts, unlike localStorage on file:// URLs. const val = await window.electronAPI.storageGet(key); if (typeof val === "string") { localStorage.setItem(key, val); } else { // IPC store is empty; backfill from localStorage if available const localVal = localStorage.getItem(key); if (localVal !== null) { await window.electronAPI.storageSet(key, localVal); } } } catch (err) { console.warn("Failed to hydrate storage key", key, err); } } } async function initializeConfig() { try { if (window.electronAPI?.getConfig) { const config = await window.electronAPI.getConfig(); API_BASE_URL = config.backendUrl; console.log(`Backend initialized: ${API_BASE_URL}`); } else { API_BASE_URL = ''; } } catch (err) { console.error("Failed to fetch config via IPC", err); // Fallback to URL search params if IPC fails (unlikely in desktop app) const urlParams = new URLSearchParams(window.location.search); API_BASE_URL = urlParams.get('backend') || ''; } finally { // Start the app once config resolution has completed. init(); } } function getWsUrl(path) { let urlStr; if (!API_BASE_URL) { const proto = location.protocol === "https:" ? "wss" : "ws"; urlStr = `${proto}://${location.host}${path}`; } else { const url = new URL(API_BASE_URL); const proto = url.protocol === "https:" ? "wss" : "ws"; const normalizedPath = path.startsWith('/') ? path : `/${path}`; urlStr = `${proto}://${url.host}${normalizedPath}`; } const token = storageGet("chattz_token"); if (token) { urlStr += (urlStr.includes('?') ? '&' : '?') + `token=${token}`; } return urlStr; } 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'), // 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"), }; // --- API Helpers --- async function api(path, options = {}) { const fullUrl = API_BASE_URL ? (path.startsWith('http') ? path : `${API_BASE_URL}${path}`) : path; const headers = { "content-type": "application/json", ...(options.headers || {}), }; const token = storageGet("chattz_token"); if (token) { headers["Authorization"] = `Bearer ${token}`; } let res = await fetch(fullUrl, { ...options, headers, }); // Handle 401 Unauthorized via Refresh Token if (res.status === 401 && !path.includes('/auth/refresh')) { const refreshToken = storageGet("chattz_refresh_token"); if (refreshToken) { try { const refreshUrl = API_BASE_URL ? `${API_BASE_URL}/auth/refresh` : '/auth/refresh'; const refreshRes = await fetch(refreshUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ refresh_token: refreshToken }) }); if (refreshRes.ok) { const newTokens = await refreshRes.json(); await storageSetCritical("chattz_token", newTokens.access_token); await storageSetCritical("chattz_refresh_token", newTokens.refresh_token); // Retry original request with new token headers["Authorization"] = `Bearer ${newTokens.access_token}`; res = await fetch(fullUrl, { ...options, headers }); } else { // Both tokens invalid/expired, wipe out to force clear state throw new Error("Refresh token expired or invalid"); } } catch (err) { await storageRemoveCritical("chattz_token"); await storageRemoveCritical("chattz_refresh_token"); location.href = API_BASE_URL ? `${API_BASE_URL}/auth/login` : "/auth/login"; throw new Error("Session expired, please log in again"); } } } 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("'", "'"); } function formatMessageBody(body) { const escaped = escapeHtml(body); // Detect GIF URLs (simple regex for demo) const urlRegex = /(https?:\/\/[^\s]+(?:\.gif|\/giphy\.gif)[^\s]*)/gi; if (urlRegex.test(body)) { return escaped.replace(urlRegex, (url) => { return `
`; }); } return escaped; } 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]}`; } function renderAttachments(attachments = []) { if (!attachments.length) return ""; return attachments.map((attachment) => { const url = escapeHtml(attachment.media_url); const mime = attachment.mime_type || "application/octet-stream"; const fileName = escapeHtml(attachment.original_filename || "file"); const size = formatBytes(attachment.size_bytes); if (mime.startsWith("image/")) { return `${fileName}`; } if (mime.startsWith("video/")) { return `
`; } if (mime.startsWith("audio/")) { return `
${fileName}
`; } return `${fileName}${size}`; }).join(""); } 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 ${p.is_speaking ? 'voice-speaking' : ''}`; pRow.style.padding = "2px 8px"; pRow.style.flexWrap = "wrap"; let sliderHtml = ''; if (p.user_id !== state.me.id) { const vol = state.userVolumes.get(p.user_id) ?? 1.0; const isVisible = state.voice.visibleVolumeSliders.has(p.user_id); sliderHtml = `
${Math.round(vol * 100)}%
`; } pRow.innerHTML = `
${shortName(p.display_name)}
${escapeHtml(p.display_name)} ${p.is_muted ? '' : ''}
${sliderHtml} `; const slider = pRow.querySelector('.volume-slider'); if (slider) { slider.addEventListener('input', (e) => { const val = parseFloat(e.target.value); state.userVolumes.set(p.user_id, val); pRow.querySelector('.vol-pct').textContent = `${Math.round(val * 100)}%`; const gainNode = state.voice.peerGainNodes.get(p.user_id); if (gainNode) { gainNode.gain.setTargetAtTime(val, state.voice.audioContext.currentTime, 0.05); } }); // Stop propagation to prevent joining channel again when clicking slider slider.addEventListener('click', (e) => e.stopPropagation()); } const toggleVolumeSlider = (e) => { if (p.user_id === state.me.id) 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('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 formattedBody = formatMessageBody(m.body || ""); const attachmentsHtml = renderAttachments(m.attachments); const bodyHtml = formattedBody ? `
${formattedBody}
` : ""; const contentHtml = `${bodyHtml}${attachmentsHtml}`; if (isGrouped) { row.innerHTML = `
${contentHtml}
`; } else { row.innerHTML = `
${shortName(displayName)}
${escapeHtml(displayName)} ${formatDate(m.created_at)}
${contentHtml}
`; } 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 base = API_BASE_URL ? new URL(API_BASE_URL).origin : location.origin; const link = `${base}/?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() { if (state.voicePresencePollId) clearInterval(state.voicePresencePollId); state.voicePresencePollId = setInterval(() => { refreshVoicePresence().catch(() => { }); }, 3000); } function initChatWs() { 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 = () => { 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.sound_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)); item.innerHTML = `
${sound.icon}
${sound.name}
${canDelete ? `` : ''} `; 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.file_path); }; el.soundboardGrid.appendChild(item); }); lucide.createIcons(); } async function deleteSound(soundId) { if (!state.selectedGuildId) return; try { const url = API_BASE_URL ? `${API_BASE_URL}/guilds/${state.selectedGuildId}/sounds/${soundId}` : `/guilds/${state.selectedGuildId}/sounds/${soundId}`; const response = await fetch(url, { method: 'DELETE' }); if (!response.ok) { const err = await response.json(); throw new Error(err.error || 'Delete failed'); } await loadSounds(); } catch (err) { alert(err.message); } } function playRemoteSound(url) { const fullUrl = API_BASE_URL && !url.startsWith('http') ? `${API_BASE_URL}${url}` : url; if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) { state.voice.ws.send(JSON.stringify({ type: 'play_sound', sound_url: fullUrl })); } // Also play locally immediately const audio = new Audio(fullUrl); 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(); await hydrateDesktopStorage(); initUpdater(); // 1. Handle tokens in URL (from successful login redirects) try { const searchParams = new URLSearchParams(location.search); const jwtToken = searchParams.get("token"); const refreshToken = searchParams.get("refresh_token"); if (jwtToken || refreshToken) { if (jwtToken) await storageSetCritical("chattz_token", jwtToken); if (refreshToken) await storageSetCritical("chattz_refresh_token", refreshToken); searchParams.delete("token"); searchParams.delete("refresh_token"); const nextQuery = searchParams.toString(); const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`; history.replaceState(null, "", nextUrl); } } catch (e) { console.error("Failed to parse token from URL:", e); } // 2. Global Button Handlers el.loginBtn.onclick = () => { const loginPath = "/auth/login"; location.href = API_BASE_URL ? `${API_BASE_URL}${loginPath}` : loginPath; }; el.logoutBtn.onclick = async () => { await leaveVoice(); await storageRemoveCritical("chattz_token"); await storageRemoveCritical("chattz_refresh_token"); try { await api("/auth/logout", { method: "POST" }); } catch (e) { } location.reload(); }; 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); } }; 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 url = API_BASE_URL ? `${API_BASE_URL}/guilds/${state.selectedGuildId}/sounds` : `/guilds/${state.selectedGuildId}/sounds`; const response = await fetch(url, { method: 'POST', body: formData }); 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'; } }; // 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) { const token = storageGet("chattz_token"); if (!token) throw new Error("You need to log in again."); const path = state.selectedTextChannelId ? `/channels/${state.selectedTextChannelId}/attachments` : state.selectedDmUserId ? `/dms/${state.selectedDmUserId}/attachments` : null; if (!path) throw new Error("Select a chat first."); const formData = new FormData(); formData.append("file", file); el.mediaUploadBtn.disabled = true; el.mediaUploadBtn.title = "Uploading..."; const fullUrl = API_BASE_URL ? `${API_BASE_URL}${path}` : path; try { const response = await fetch(fullUrl, { method: "POST", headers: { Authorization: `Bearer ${token}`, }, body: formData, }); 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"); if (state.me && state.me.display_name) { el.userName.textContent = state.me.display_name; el.userAvatar.textContent = shortName(state.me.display_name); } 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); } 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) { console.error("init failed", err); el.authScreen.classList.remove("hidden"); el.main.classList.add("hidden"); } } 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?.(); } }); } // Config fetcher will trigger init() initializeConfig();