diff --git a/.forgejo/workflows/pipeline.yaml b/.forgejo/workflows/pipeline.yaml index 089f873..9e241e8 100644 --- a/.forgejo/workflows/pipeline.yaml +++ b/.forgejo/workflows/pipeline.yaml @@ -50,20 +50,11 @@ jobs: cp "$file" "static/installers/$out" fi } - # Copy versioned artifacts for electron-updater - cp dist/*.AppImage static/installers/ || true - cp dist/*.exe static/installers/ || true - cp dist/*.rpm static/installers/ || true - cp dist/*.deb static/installers/ || true - cp dist/*.msi static/installers/ || true - - # Maintain generic names for stable website links copy_first '*.rpm' 'chattz-linux.rpm' copy_first '*.deb' 'chattz-linux.deb' copy_first '*.AppImage' 'chattz-linux.AppImage' copy_first '*.exe' 'chattz-windows.exe' copy_first '*.msi' 'chattz-windows.msi' - # Metadata files for electron-updater copy_first 'latest-linux.yml' 'latest-linux.yml' copy_first 'latest.yml' 'latest.yml' diff --git a/desktop/app.js b/desktop/app.js index e0b9fa7..bff5420 100644 --- a/desktop/app.js +++ b/desktop/app.js @@ -1 +1,1946 @@ -import "../static/shared/app-core.js"; +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, + 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 + +async function initializeConfig() { + try { + const config = await window.electronAPI.getConfig(); + API_BASE_URL = config.backendUrl; + console.log(`Backend initialized: ${API_BASE_URL}`); + + // Start the app now that config is ready + init(); + } 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') || ''; + } +} + +// Start config fetch immediately +initializeConfig(); + +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 = localStorage.getItem("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"), + + // 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 = localStorage.getItem("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 = localStorage.getItem("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(); + localStorage.setItem("chattz_token", newTokens.access_token); + localStorage.setItem("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) { + localStorage.removeItem("chattz_token"); + localStorage.removeItem("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 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(); +} + +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); + } +} +const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId"; + +// --- 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; + localStorage.setItem(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"; + + if (isGrouped) { + row.innerHTML = `
${formatMessageBody(m.body)}
`; + } else { + row.innerHTML = ` +
${shortName(displayName)}
+
+
+ ${escapeHtml(displayName)} + ${formatDate(m.created_at)} +
+
${formatMessageBody(m.body)}
+
+ `; + } + el.messageList.appendChild(row); + lastAuthorId = m.author_user_id; + lastTime = mDate; + } + + if (messages.length > 0) { + state.lastMessageId = messages[0].id; + } + + el.messageList.scrollTop = el.messageList.scrollHeight; +} + +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; + + // 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() { + 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; + +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(); + + // 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) localStorage.setItem("chattz_token", jwtToken); + if (refreshToken) localStorage.setItem("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(); + localStorage.removeItem("chattz_token"); + localStorage.removeItem("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; + localStorage.setItem(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); } + }; + + // 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.messageBody.value = fullUrl; + el.gifPicker.classList.add("hidden"); + el.messageForm.dispatchEvent(new Event('submit')); + }; + item.appendChild(img); + el.gifResults.appendChild(item); + }); + } + + // 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" }); + localStorage.setItem(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 = localStorage.getItem(LAST_GUILD_STORAGE_KEY); + const guild = state.guilds.find((g) => g.id === lastGuildId) || state.guilds[0]; + state.selectedGuildId = guild.id; + localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id); + renderGuilds(); + await loadChannels(); + await loadGuildMembers(); + await refreshVoicePresence(); + updateHeaderLabels(); + } else { + state.members = []; + renderMembers(); + updateHeaderLabels(); + } + + initChatWs(); + startVoicePresencePolling(); + initUpdater(); + lucide.createIcons(); + } catch (err) { + console.error("init failed", err); + el.authScreen.classList.remove("hidden"); + el.main.classList.add("hidden"); + } +} + +function initUpdater() { + if (!window.electronAPI) return; + + 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(); + }; +} + +// Config fetcher will trigger init() +initializeConfig(); diff --git a/desktop/index.html b/desktop/index.html index ed754ec..ac9fa8d 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -265,7 +265,7 @@ - + \ No newline at end of file diff --git a/desktop/preload.js b/desktop/preload.js index dfcc665..ae9d90e 100644 --- a/desktop/preload.js +++ b/desktop/preload.js @@ -3,15 +3,9 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('electronAPI', { copyToClipboard: (text) => ipcRenderer.invoke('clipboard-write', text), getConfig: () => ipcRenderer.invoke('get-config'), - storageGet: (key) => ipcRenderer.invoke('storage-get', key), - storageSet: (key, value) => ipcRenderer.invoke('storage-set', key, value), - storageRemove: (key) => ipcRenderer.invoke('storage-remove', key), - getUpdateState: () => ipcRenderer.invoke('get-update-state'), - checkForUpdatesNow: () => ipcRenderer.invoke('check-for-updates-now'), checkForUpdates: () => ipcRenderer.send('check-for-updates'), downloadUpdate: () => ipcRenderer.send('download-update'), quitAndInstall: () => ipcRenderer.send('quit-and-install'), - onUpdateState: (callback) => ipcRenderer.on('update-state', (event, state) => callback(state)), onUpdateAvailable: (callback) => ipcRenderer.on('update-available', (event, info) => callback(info)), onUpdateDownloaded: (callback) => ipcRenderer.on('update-downloaded', (event, info) => callback(info)), onUpdateError: (callback) => ipcRenderer.on('update-error', (event, error) => callback(error)) diff --git a/main.js b/main.js index 3a32c94..0f0878d 100644 --- a/main.js +++ b/main.js @@ -1,88 +1,8 @@ const { app, BrowserWindow, session, desktopCapturer, ipcMain, clipboard } = require('electron'); const { autoUpdater } = require('electron-updater'); const path = require('path'); -const fs = require('fs'); const url = require('url'); -const updateState = { - status: 'idle', // idle | checking | available | downloading | downloaded | not-available | error - info: null, - error: null, -}; - -function broadcastUpdateState() { - const wins = BrowserWindow.getAllWindows(); - for (const win of wins) { - if (!win.isDestroyed()) { - win.webContents.send('update-state', updateState); - } - } -} - -function getStorageFilePath() { - return path.join(app.getPath('userData'), 'renderer-storage.json'); -} - -function readPersistentStore() { - const filePath = getStorageFilePath(); - try { - if (!fs.existsSync(filePath)) return {}; - const raw = fs.readFileSync(filePath, 'utf8'); - const parsed = JSON.parse(raw); - return parsed && typeof parsed === 'object' ? parsed : {}; - } catch (err) { - console.error('Failed to read persistent store', err); - return {}; - } -} - -function writePersistentStore(store) { - const filePath = getStorageFilePath(); - try { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, JSON.stringify(store), 'utf8'); - } catch (err) { - console.error('Failed to write persistent store', err); - } -} - -function isNewerVersionAvailable(info) { - const next = info && typeof info.version === 'string' ? info.version : ''; - return Boolean(next) && next !== app.getVersion(); -} - -let updateCheckInProgress = false; - -async function runUpdateCheck(reason = 'manual') { - if (updateCheckInProgress) { - console.log(`Update check skipped (${reason}): another check is already in progress`); - return; - } - updateCheckInProgress = true; - updateState.status = 'checking'; - updateState.error = null; - try { - const result = await autoUpdater.checkForUpdates(); - const info = result && result.updateInfo ? result.updateInfo : null; - if (isNewerVersionAvailable(info)) { - updateState.status = 'available'; - updateState.info = info; - updateState.error = null; - } else { - updateState.status = 'not-available'; - updateState.info = info; - updateState.error = null; - } - } catch (err) { - updateState.status = 'error'; - updateState.error = err && err.message ? err.message : String(err); - console.error(`Update check failed (${reason})`, err); - } finally { - updateCheckInProgress = false; - broadcastUpdateState(); - } -} - function createWindow() { // Create a persistent session for chattz to keep the user logged in const sess = session.fromPartition('persist:chattz'); @@ -101,7 +21,18 @@ function createWindow() { }); win.setAutoHideMenuBar(true); - win.setMenuBarVisibility(true); + win.setMenuBarVisibility(false); + + ipcMain.handle('get-config', () => { + return { + backendUrl: (process.env.CHATTZ_URL || 'https://discord.flegr.me').replace(/\/$/, '') + }; + }); + + ipcMain.handle('clipboard-write', (event, text) => { + clipboard.writeText(text); + return true; + }); // Auto-approve media permissions (camera, microphone) sess.setPermissionCheckHandler((webContents, permission) => { @@ -161,14 +92,12 @@ function createWindow() { }); }; - // Intercept navigations/redirects that return to the backend with a token - // after the OAuth login flow. Two handlers are needed because: - // - will-navigate fires for client-side navigations (location.href, link clicks) - // - will-redirect fires for server-side 302 redirects (OAuth callback chain) - const interceptLoginRedirect = (event, navigatedUrl) => { + // Use a navigation listener to detect when the remote login is complete + win.webContents.on('will-navigate', (event, navigatedUrl) => { try { const urlObj = new URL(navigatedUrl); const backendObj = new URL(backendUrl); + // Detect the redirect back to the home page with a token if (urlObj.origin === backendObj.origin && urlObj.pathname === '/') { if (urlObj.searchParams.has('token')) { console.log("Detected login success redirect, returning to desktop UI..."); @@ -179,22 +108,10 @@ function createWindow() { } catch (e) { console.error(e); } - }; - - win.webContents.on('will-navigate', interceptLoginRedirect); - win.webContents.on('will-redirect', interceptLoginRedirect); + }); loadDesktopApp(); - // Ensure renderer receives latest updater state after any (re)load. - // Delay broadcast by 300ms to give the renderer time to register its - // onUpdateState IPC listener before we push state. - win.webContents.on('did-finish-load', () => { - setTimeout(() => { - if (!win.isDestroyed()) broadcastUpdateState(); - }, 300); - }); - // Uncomment to debug // win.webContents.openDevTools(); } @@ -203,46 +120,6 @@ app.commandLine.appendSwitch('disable-webrtc-hw-encoding'); // Sometime helps re app.commandLine.appendSwitch('log-level', '3'); // Silences standard Chromium warning logs (like SRTP transport unprotect logs which are benign timing issues) app.whenReady().then(() => { - // Register IPC handlers once (before creating any windows) - ipcMain.handle('get-config', () => { - return { - backendUrl: (process.env.CHATTZ_URL || 'https://discord.flegr.me').replace(/\/$/, '') - }; - }); - - ipcMain.handle('clipboard-write', (event, text) => { - clipboard.writeText(text); - return true; - }); - - ipcMain.handle('storage-get', (event, key) => { - if (typeof key !== 'string' || key.length === 0) return null; - const store = readPersistentStore(); - return Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null; - }); - - ipcMain.handle('storage-set', (event, key, value) => { - if (typeof key !== 'string' || key.length === 0) return false; - const store = readPersistentStore(); - store[key] = value; - writePersistentStore(store); - return true; - }); - - ipcMain.handle('storage-remove', (event, key) => { - if (typeof key !== 'string' || key.length === 0) return false; - const store = readPersistentStore(); - delete store[key]; - writePersistentStore(store); - return true; - }); - - ipcMain.handle('get-update-state', () => ({ ...updateState })); - ipcMain.handle('check-for-updates-now', async () => { - await runUpdateCheck('renderer-direct'); - return { ...updateState }; - }); - createWindow(); // Configure Auto-Updater @@ -250,46 +127,27 @@ app.whenReady().then(() => { autoUpdater.logger = console; autoUpdater.on('update-available', (info) => { - updateState.status = 'available'; - updateState.info = info; - updateState.error = null; - broadcastUpdateState(); const wins = BrowserWindow.getAllWindows(); if (wins.length > 0) wins[0].webContents.send('update-available', info); }); - autoUpdater.on('update-not-available', (info) => { - updateState.status = 'not-available'; - updateState.info = info || null; - updateState.error = null; - broadcastUpdateState(); - }); - autoUpdater.on('update-downloaded', (info) => { - updateState.status = 'downloaded'; - updateState.info = info; - updateState.error = null; - broadcastUpdateState(); const wins = BrowserWindow.getAllWindows(); if (wins.length > 0) wins[0].webContents.send('update-downloaded', info); }); autoUpdater.on('error', (err) => { - updateState.status = 'error'; - updateState.error = err.message; - broadcastUpdateState(); const wins = BrowserWindow.getAllWindows(); if (wins.length > 0) wins[0].webContents.send('update-error', err.message); }); ipcMain.on('check-for-updates', () => { - void runUpdateCheck('manual'); + autoUpdater.checkForUpdatesAndNotify().catch(err => { + console.error("Manual update check failed", err); + }); }); ipcMain.on('download-update', () => { - updateState.status = 'downloading'; - updateState.error = null; - broadcastUpdateState(); autoUpdater.downloadUpdate(); }); @@ -299,7 +157,7 @@ app.whenReady().then(() => { // Check once on startup setTimeout(() => { - void runUpdateCheck('startup'); + autoUpdater.checkForUpdatesAndNotify().catch(() => { }); }, 5000); app.on('activate', () => { diff --git a/static/app.js b/static/app.js index 2794dc8..d6b264f 100644 --- a/static/app.js +++ b/static/app.js @@ -1 +1,1931 @@ -import "./shared/app-core.js"; +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 currently visible + }, + voicePresencePollId: null, + chatWs: null, + lastMessageId: null, + onlineUsers: new Set(), + idleUsers: new Set(), + userVolumes: new Map(), // userId -> volume (0.0 to 2.0) +}; + + + +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"), + + // User Panel + userName: document.getElementById("user-name"), + userAvatar: document.getElementById("user-avatar"), + logoutBtn: document.getElementById("logout-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 headers = { + "content-type": "application/json", + ...(options.headers || {}), + }; + + const token = localStorage.getItem("chattz_token"); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + let res = await fetch(path, { + ...options, + headers, + }); + + // Handle 401 Unauthorized via Refresh Token + if (res.status === 401 && !path.includes('/auth/refresh')) { + const refreshToken = localStorage.getItem("chattz_refresh_token"); + if (refreshToken) { + try { + const refreshRes = await fetch('/auth/refresh', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }) + }); + + if (refreshRes.ok) { + const newTokens = await refreshRes.json(); + localStorage.setItem("chattz_token", newTokens.access_token); + localStorage.setItem("chattz_refresh_token", newTokens.refresh_token); + + // Retry original request with new token + headers["Authorization"] = `Bearer ${newTokens.access_token}`; + res = await fetch(path, { ...options, headers }); + } else { + // Both tokens invalid/expired + throw new Error("Refresh token expired or invalid"); + } + } catch (err) { + localStorage.removeItem("chattz_token"); + localStorage.removeItem("chattz_refresh_token"); + location.href = "/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 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(); +} + +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); + } +} +const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId"; + +// --- 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; + localStorage.setItem(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(); + }; + + 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"; + + if (isGrouped) { + row.innerHTML = `
${formatMessageBody(m.body)}
`; + } else { + row.innerHTML = ` +
${shortName(displayName)}
+
+
+ ${escapeHtml(displayName)} + ${formatDate(m.created_at)} +
+
${formatMessageBody(m.body)}
+
+ `; + } + el.messageList.appendChild(row); + lastAuthorId = m.author_user_id; + lastTime = mDate; + } + + if (messages.length > 0) { + state.lastMessageId = messages[0].id; + } + + el.messageList.scrollTop = el.messageList.scrollHeight; +} + +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 }), + }); + const link = `${location.origin}/?invite=${encodeURIComponent(invite.code)}`; + try { + await navigator.clipboard.writeText(link); + showCopiedBadge(); + } catch { + 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 protocol = location.protocol === "https:" ? "wss:" : "ws:"; + let wsUrl = `${protocol}//${location.host}/ws`; + const token = localStorage.getItem("chattz_token"); + if (token) { + wsUrl += (wsUrl.includes('?') ? '&' : '?') + `token=${token}`; + } + 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, refreshing page for resilience..."); + // Automatically refresh on connection loss instead of just background reconnecting + location.reload(); + }; +} + +// --- Voice --- + +function getVoiceWsUrl(channelId) { + const proto = location.protocol === "https:" ? "wss" : "ws"; + let urlStr = `${proto}://${location.host}/channels/${channelId}/voice/ws`; + const token = localStorage.getItem("chattz_token"); + if (token) { + urlStr += (urlStr.includes('?') ? '&' : '?') + `token=${token}`; + } + return urlStr; +} + +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) { + noiseFloor = noiseFloor * 0.98 + levelEma * 0.02; + } else { + 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 })); + } + 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; + localStorage.setItem("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() { + localStorage.removeItem("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 response = await fetch(`/guilds/${state.selectedGuildId}/sounds/${soundId}`, { + 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) { + if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) { + state.voice.ws.send(JSON.stringify({ type: 'play_sound', sound_url: url })); + } + // Also play locally immediately + const audio = new Audio(url); + 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; + +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(); + + try { + const searchParams = new URLSearchParams(location.search); + const jwtToken = searchParams.get("token"); + const refreshToken = searchParams.get("refresh_token"); + + if (jwtToken || refreshToken) { + if (jwtToken) localStorage.setItem("chattz_token", jwtToken); + if (refreshToken) localStorage.setItem("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); + } + + el.loginBtn.onclick = () => { location.href = "/auth/login"; }; + + el.logoutBtn.onclick = async () => { + await leaveVoice(); + localStorage.removeItem("chattz_token"); + localStorage.removeItem("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; + + 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; + localStorage.setItem(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(); + } + } 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.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 { + // Need to use fetch directly because api() might not handle FormData correctly depending on implementation + const response = await fetch(`/guilds/${state.selectedGuildId}/sounds`, { + 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'; + } + }; + + // --- GIF Picker Logic --- + const TENOR_API_KEY = "exTiFGKJ0CzESIHzVQWy3pRO8I1MAdpRomg95DBSu2sg6e7YcHgThMI4giGAx8D0"; // Placeholder / Public key if available, otherwise "YOUR_API_KEY" + const TENOR_CLIENT_KEY = "pavel-discord"; + + el.gifBtn.onclick = () => { + el.gifPicker.classList.remove("hidden"); + searchGifs(""); // Initial featured search + }; + + el.gifPickerClose.onclick = () => { + el.gifPicker.classList.add("hidden"); + }; + + let searchTimeout = null; + el.gifSearchInput.oninput = () => { + clearTimeout(searchTimeout); + searchTimeout = 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 => { + // Use tinygif for preview, standard gif for sending + const previewUrl = gif.media_formats.tinygif.url; + const fullUrl = gif.media_formats.gif.url; + + const item = document.createElement("div"); + item.className = "gif-item"; + item.innerHTML = ``; + item.onclick = () => { + sendGif(fullUrl); + el.gifPicker.classList.add("hidden"); + el.gifSearchInput.value = ""; + }; + 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); + } + } + + 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"); + + const params = new URLSearchParams(location.search); + const inviteCode = params.get("invite"); + if (inviteCode) { + try { + const joinedGuild = await api(`/invites/${encodeURIComponent(inviteCode)}/join`, { + method: "POST", + }); + localStorage.setItem(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); + } + } + + await loadGuilds(); + 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 = localStorage.getItem(LAST_GUILD_STORAGE_KEY); + const guild = state.guilds.find((g) => g.id === lastGuildId) || state.guilds[0]; + state.selectedGuildId = guild.id; + localStorage.setItem(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 = localStorage.getItem("active_voice_channel"); + if (savedVoiceChannel) { + console.log("Auto-reconnecting to voice channel:", savedVoiceChannel); + state.selectedVoiceChannelId = savedVoiceChannel; + // Wait for components to be ready + 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"); + } +} + +init(); diff --git a/static/index.html b/static/index.html index 5aef5f7..fb34084 100644 --- a/static/index.html +++ b/static/index.html @@ -265,7 +265,7 @@ - + diff --git a/static/shared/app-core.js b/static/shared/app-core.js deleted file mode 100644 index f14b88d..0000000 --- a/static/shared/app-core.js +++ /dev/null @@ -1,2118 +0,0 @@ -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"), - - // 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 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(); -} - -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"; - - if (isGrouped) { - row.innerHTML = `
${formatMessageBody(m.body)}
`; - } else { - row.innerHTML = ` -
${shortName(displayName)}
-
-
- ${escapeHtml(displayName)} - ${formatDate(m.created_at)} -
-
${formatMessageBody(m.body)}
-
- `; - } - el.messageList.appendChild(row); - lastAuthorId = m.author_user_id; - lastTime = mDate; - } - - if (messages.length > 0) { - state.lastMessageId = messages[0].id; - } - - el.messageList.scrollTop = el.messageList.scrollHeight; -} - -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); } - }; - - // 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); - } - } - - // 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();