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, audioContext: null, denoiserNode: null, deepFilterCore: null, peerConnections: new Map(), muted: false, iceServers: [{ urls: "stun:stun.l.google.com:19302" }], }, voicePresencePollId: null, }; 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"), // 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"), voiceMuteBtn: document.getElementById("voice-mute-btn"), voiceLeaveBtn: document.getElementById("voice-leave-btn"), // 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"), }; // --- API Helpers --- async function api(path, options = {}) { const res = await fetch(path, { ...options, headers: { "content-type": "application/json", ...(options.headers || {}), }, credentials: "same-origin", }); 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 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(); } const DEEPFILTERNET_LIB_PATH = "/static/vendor/deepfilternet3/index.esm.js"; const NSNET2_COMPAT_SUPPRESSION = 56; let deepFilterLibPromise = null; 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; renderChannels(); renderDMs(); updateHeaderLabels(); const messages = await api(`/channels/${channel.id}/messages?limit=100`); renderMessages(messages); } else { state.selectedDmUserId = null; state.selectedDmDisplayName = null; state.selectedVoiceChannelId = channel.id; renderChannels(); renderDMs(); joinVoice(); } }; 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"; pRow.style.padding = "2px 8px"; pRow.innerHTML = `
${shortName(p.display_name)}
${escapeHtml(p.display_name)}`; pList.appendChild(pRow); } el.voiceChannelList.appendChild(pList); } } } lucide.createIcons(); } function renderDMs() { el.dmList.innerHTML = ""; for (const dm of state.dmConversations) { 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; renderChannels(); renderDMs(); 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 = `
${escapeHtml(m.body)}
`; } else { row.innerHTML = `
${shortName(displayName)}
${escapeHtml(displayName)} ${formatDate(m.created_at)}
${escapeHtml(m.body)}
`; } el.messageList.appendChild(row); lastAuthorId = m.author_user_id; lastTime = mDate; } el.messageList.scrollTop = el.messageList.scrollHeight; } function renderMembers() { el.memberList.innerHTML = ""; for (const m of state.members) { 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; renderChannels(); renderDMs(); 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; } 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); alert(`Invite link copied:\n${link}`); } catch { prompt("Copy invite link:", link); } } catch (err) { alert(err.message); } } // --- 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); } // --- Voice --- function getVoiceWsUrl(channelId) { const proto = location.protocol === "https:" ? "wss" : "ws"; return `${proto}://${location.host}/channels/${channelId}/voice/ws`; } function shouldInitiateOffer(peerId) { if (!state.me || !state.me.id) return false; return state.me.id > peerId; } async function loadDeepFilterLib() { if (!deepFilterLibPromise) { deepFilterLibPromise = import(DEEPFILTERNET_LIB_PATH); } return deepFilterLibPromise; } async function buildDenoiserNode(audioContext) { if (!audioContext.audioWorklet) { throw new Error("AudioWorklet is not supported in this browser"); } const df = await loadDeepFilterLib(); const core = new df.DeepFilterNet3Core({ sampleRate: 48000, noiseReductionLevel: NSNET2_COMPAT_SUPPRESSION, assetConfig: { cdnUrl: "/static/vendor/deepfilternet3", }, }); await core.initialize(); const workletNode = await core.createAudioWorkletNode(audioContext); state.voice.deepFilterCore = core; return workletNode; } function stopAndClearAudioPipeline() { if (state.voice.deepFilterCore) { state.voice.deepFilterCore.destroy(); state.voice.deepFilterCore = null; } if (state.voice.denoiserNode && typeof state.voice.denoiserNode.destroy === "function") { state.voice.denoiserNode.destroy(); } 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; state.voice.denoiserNode = null; state.voice.deepFilterCore = null; } async function buildAudioPipeline(rawStream) { const audioContext = new AudioContext(); const source = audioContext.createMediaStreamSource(rawStream); const destination = audioContext.createMediaStreamDestination(); state.voice.audioContext = audioContext; let head = source; const denoiserNode = await buildDenoiserNode(audioContext); if (denoiserNode) { head.connect(denoiserNode); head = denoiserNode; } head.connect(destination); state.voice.denoiserNode = denoiserNode; return destination.stream; } 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); const localStream = await buildAudioPipeline(rawStream); 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 }); if (state.voice.localStream) { for (const track of state.voice.localStream.getTracks()) { pc.addTrack(track, state.voice.localStream); } } 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.ontrack = (event) => { 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.srcObject = event.streams[0]; }; state.voice.peerConnections.set(peerId, pc); return pc; } async function sendOffer(peerId) { const pc = ensurePeerConnection(peerId); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); state.voice.ws.send(JSON.stringify({ type: "signal", to_user_id: peerId, kind: "offer", data: offer, })); } async function handleSignal(fromPeerId, kind, data) { const pc = ensurePeerConnection(fromPeerId); if (kind === "offer") { 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: answer, })); } 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) { console.warn("failed to add ice candidate", err); } } } async function joinVoice() { if (!state.selectedVoiceChannelId) return; if (state.voice.joinedChannelId === state.selectedVoiceChannelId) return; await leaveVoice(); try { await createLocalVoiceStream(); } catch (err) { console.error("microphone denied", err); return; } const ws = new WebSocket(getVoiceWsUrl(state.selectedVoiceChannelId)); state.voice.ws = ws; state.voice.joinedChannelId = state.selectedVoiceChannelId; ws.onopen = () => { const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId); el.vcChannelName.textContent = channel ? channel.name : "Voice"; el.voiceConnection.classList.remove("hidden"); refreshVoicePresence().catch(() => {}); }; ws.onmessage = async (event) => { const msg = JSON.parse(event.data); if (msg.type === "peers") { for (const peer of msg.peers) { if (shouldInitiateOffer(peer.user_id)) await sendOffer(peer.user_id); } } else if (msg.type === "peer_joined") { if (shouldInitiateOffer(msg.user_id)) await sendOffer(msg.user_id); } else if (msg.type === "peer_left") { 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); } refreshVoicePresence().catch(() => {}); }; ws.onclose = () => { el.voiceConnection.classList.add("hidden"); for (const pc of state.voice.peerConnections.values()) pc.close(); state.voice.peerConnections.clear(); stopAndClearAudioPipeline(); state.voice.joinedChannelId = null; state.voice.ws = null; 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; }); el.voiceMuteBtn.innerHTML = state.voice.muted ? '' : ''; el.voiceMuteBtn.style.color = state.voice.muted ? 'var(--danger)' : 'var(--text-muted)'; lucide.createIcons(); } // --- Initialization --- async function init() { lucide.createIcons(); el.loginBtn.onclick = () => { location.href = "/auth/login"; }; el.logoutBtn.onclick = async () => { await leaveVoice(); await api("/auth/logout", { method: "POST" }); 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"); }; 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.voiceMuteBtn.onclick = toggleMute; el.voiceLeaveBtn.onclick = leaveVoice; 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(); 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(); } startVoicePresencePolling(); lucide.createIcons(); } catch (err) { console.error("init failed", err); el.authScreen.classList.remove("hidden"); el.main.classList.add("hidden"); } } init();