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, viewMode: 'chat', // 'chat' or 'video' iceServers: [{ urls: "stun:stun.l.google.com:19302" }], }, voicePresencePollId: null, chatWs: null, lastMessageId: null, onlineUsers: new Set(), }; // --- Desktop Backend Configuration --- const urlParams = new URLSearchParams(window.location.search); const API_BASE_URL = urlParams.get('backend') || ''; // Absolute URL to the remote server 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"), // 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 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(); joinVoice(); } 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.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 isOnline = state.onlineUsers.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 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); 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); } 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); } else { state.onlineUsers.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.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); // Metering/Speaking detection const analyser = ctx.createAnalyser(); analyser.fftSize = 512; source.connect(analyser); const dataArray = new Uint8Array(analyser.frequencyBinCount); let localIsSpeaking = false; let speakCounter = 0; const checkVolume = () => { if (!state.voice.audioContext || state.voice.audioContext.state === 'closed') return; analyser.getByteFrequencyData(dataArray); let sum = 0; for (let i = 0; i < dataArray.length; i++) sum += dataArray[i]; const avg = sum / dataArray.length; const isCurrentlySpeaking = avg > 30; // Calibrated for normal speech if (isCurrentlySpeaking) { speakCounter = Math.min(speakCounter + 1, 5); } else { speakCounter = Math.max(speakCounter - 1, 0); } const newSpeakingState = speakCounter >= 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, 100); }; checkVolume(); const destination = ctx.createMediaStreamDestination(); source.connect(destination); return destination.stream; } async function createLocalVoiceStream() { const constraints = { audio: { channelCount: 1, sampleRate: 48000, echoCancellation: true, noiseSuppression: true, 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 }); 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.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.srcObject = stream; } 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 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) 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"); el.soundboard.classList.remove("hidden"); playSound('join'); refreshVoicePresence().catch(() => { }); loadSounds().catch(() => { }); }; ws.onmessage = async (event) => { 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 === "play_sound") { const audio = new Audio(msg.sound_url); audio.play().catch(console.error); } refreshVoicePresence().catch(() => { }); }; ws.onclose = () => { 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; }); 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 --- 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 = () => { 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; 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 GIPHY_API_KEY = "dc6zaTOxFJmzC"; // Public beta key el.gifBtn.onclick = () => { el.gifPicker.classList.remove("hidden"); searchGifs(""); // Initial trending 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 endpoint = query ? `https://api.giphy.com/v1/gifs/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(query)}&limit=20` : `https://api.giphy.com/v1/gifs/trending?api_key=${GIPHY_API_KEY}&limit=20`; try { const res = await fetch(endpoint); const data = await res.json(); renderGifs(data.data); } 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 imgUrl = gif.images.fixed_height.url; const item = document.createElement("div"); item.className = "gif-item"; item.innerHTML = ``; item.onclick = () => { sendGif(imgUrl); 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(); lucide.createIcons(); } catch (err) { console.error("init failed", err); el.authScreen.classList.remove("hidden"); el.main.classList.add("hidden"); } } init();