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)}
+
+
+
${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 @@
-
+